mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
fix(mcp): clean up failed HTTP connections
This commit is contained in:
+19
-29
@@ -975,11 +975,8 @@ async def connect_mcp_servers(
|
|||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
async def open_single_server(
|
async def open_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig", server_stack: AsyncExitStack
|
||||||
) -> tuple[str, AsyncExitStack | None]:
|
) -> bool:
|
||||||
server_stack = AsyncExitStack()
|
|
||||||
await server_stack.__aenter__()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
transport_type = cfg.type
|
transport_type = cfg.type
|
||||||
if not transport_type:
|
if not transport_type:
|
||||||
@@ -991,8 +988,7 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
if transport_type in {"sse", "streamableHttp"}:
|
if transport_type in {"sse", "streamableHttp"}:
|
||||||
ok, error = validate_url_target(cfg.url)
|
ok, error = validate_url_target(cfg.url)
|
||||||
@@ -1003,8 +999,7 @@ async def connect_mcp_servers(
|
|||||||
_redact_url(cfg.url),
|
_redact_url(cfg.url),
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
if transport_type == "stdio":
|
if transport_type == "stdio":
|
||||||
command, args, env = _normalize_windows_stdio_command(
|
command, args, env = _normalize_windows_stdio_command(
|
||||||
@@ -1022,8 +1017,7 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "sse":
|
elif transport_type == "sse":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
def httpx_client_factory(
|
def httpx_client_factory(
|
||||||
headers: dict[str, str] | None = None,
|
headers: dict[str, str] | None = None,
|
||||||
@@ -1050,8 +1044,7 @@ async def connect_mcp_servers(
|
|||||||
elif transport_type == "streamableHttp":
|
elif transport_type == "streamableHttp":
|
||||||
if not await _probe_http_url(cfg.url):
|
if not await _probe_http_url(cfg.url):
|
||||||
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
logger.warning("MCP server '{}': {} unreachable, skipping", name, _redact_url(cfg.url))
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
http_client = await server_stack.enter_async_context(
|
http_client = await server_stack.enter_async_context(
|
||||||
httpx.AsyncClient(
|
httpx.AsyncClient(
|
||||||
@@ -1067,8 +1060,7 @@ async def connect_mcp_servers(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||||
await server_stack.aclose()
|
return False
|
||||||
return name, None
|
|
||||||
|
|
||||||
read = _filter_malformed_mcp_progress_notifications(read, name)
|
read = _filter_malformed_mcp_progress_notifications(read, name)
|
||||||
session = await server_stack.enter_async_context(ClientSession(read, write))
|
session = await server_stack.enter_async_context(ClientSession(read, write))
|
||||||
@@ -1171,7 +1163,7 @@ async def connect_mcp_servers(
|
|||||||
logger.info(
|
logger.info(
|
||||||
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
"MCP server '{}': connected, {} capabilities registered", name, registered_count
|
||||||
)
|
)
|
||||||
return name, server_stack
|
return True
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
hint = ""
|
hint = ""
|
||||||
@@ -1191,9 +1183,7 @@ async def connect_mcp_servers(
|
|||||||
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
"only JSON-RPC to stdout and sends logs/debug output to stderr instead."
|
||||||
)
|
)
|
||||||
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
logger.exception("MCP server '{}': failed to connect: {}", name, hint)
|
||||||
with suppress(Exception):
|
return False
|
||||||
await server_stack.aclose()
|
|
||||||
return name, None
|
|
||||||
|
|
||||||
async def connect_single_server(
|
async def connect_single_server(
|
||||||
name: str, cfg: "MCPServerConfig"
|
name: str, cfg: "MCPServerConfig"
|
||||||
@@ -1203,30 +1193,30 @@ async def connect_mcp_servers(
|
|||||||
close_requested = asyncio.Event()
|
close_requested = asyncio.Event()
|
||||||
|
|
||||||
async def own_connection() -> None:
|
async def own_connection() -> None:
|
||||||
stack: AsyncExitStack | None = None
|
|
||||||
try:
|
try:
|
||||||
_, stack = await open_single_server(name, cfg)
|
async with AsyncExitStack() as stack:
|
||||||
if not ready.done():
|
connected = await open_single_server(name, cfg, stack)
|
||||||
ready.set_result(stack is not None)
|
if not ready.done():
|
||||||
if stack is not None:
|
ready.set_result(connected)
|
||||||
await close_requested.wait()
|
if connected:
|
||||||
|
await close_requested.wait()
|
||||||
except BaseException as exc:
|
except BaseException as exc:
|
||||||
if not ready.done():
|
if not ready.done():
|
||||||
ready.set_exception(exc)
|
ready.set_exception(exc)
|
||||||
raise
|
raise
|
||||||
finally:
|
|
||||||
if stack is not None:
|
|
||||||
await stack.aclose()
|
|
||||||
|
|
||||||
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
owner = asyncio.create_task(own_connection(), name=f"mcp:{name}")
|
||||||
connection = _OwnedMCPConnection(owner, close_requested)
|
connection = _OwnedMCPConnection(owner, close_requested)
|
||||||
try:
|
try:
|
||||||
connected = await ready
|
connected = await ready
|
||||||
except BaseException:
|
except BaseException as exc:
|
||||||
close_requested.set()
|
close_requested.set()
|
||||||
owner.cancel()
|
owner.cancel()
|
||||||
with suppress(BaseException):
|
with suppress(BaseException):
|
||||||
await asyncio.shield(owner)
|
await asyncio.shield(owner)
|
||||||
|
if isinstance(exc, asyncio.CancelledError) and not task_is_cancelling():
|
||||||
|
logger.warning("MCP server '{}': connection cancelled by server/SDK", name)
|
||||||
|
return name, None
|
||||||
raise
|
raise
|
||||||
if not connected:
|
if not connected:
|
||||||
await connection.aclose()
|
await connection.aclose()
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import asyncio
|
|||||||
import socket
|
import socket
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nanobot.agent.tools import mcp as mcp_mod
|
||||||
from nanobot.agent.tools.mcp import _probe_http_url, connect_mcp_servers
|
from nanobot.agent.tools.mcp import _probe_http_url, connect_mcp_servers
|
||||||
from nanobot.agent.tools.registry import ToolRegistry
|
from nanobot.agent.tools.registry import ToolRegistry
|
||||||
|
from nanobot.config.schema import MCPServerConfig
|
||||||
from nanobot.security.network import configure_ssrf_whitelist
|
from nanobot.security.network import configure_ssrf_whitelist
|
||||||
|
|
||||||
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
||||||
@@ -171,6 +174,58 @@ async def test_connect_skips_unreachable_sse():
|
|||||||
assert len(registry._tools) == 0
|
assert len(registry._tools) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_isolates_streamable_http_status_failure(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""A reachable endpoint returning HTTP 530 must not poison the event loop."""
|
||||||
|
async def _reachable(_url: str) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _return_http_530(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(530, text="cloudflare error 1033", request=request)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mcp_mod, "validate_url_target", lambda _url: (True, ""))
|
||||||
|
monkeypatch.setattr(mcp_mod, "_probe_http_url", _reachable)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mcp_mod,
|
||||||
|
"PinnedDNSAsyncTransport",
|
||||||
|
lambda: httpx.MockTransport(_return_http_530),
|
||||||
|
)
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
previous_exception_handler = loop.get_exception_handler()
|
||||||
|
unhandled: list[BaseException] = []
|
||||||
|
|
||||||
|
def _capture_unhandled(_loop: asyncio.AbstractEventLoop, context: dict) -> None:
|
||||||
|
if isinstance(context.get("exception"), BaseException):
|
||||||
|
unhandled.append(context["exception"])
|
||||||
|
|
||||||
|
loop.set_exception_handler(_capture_unhandled)
|
||||||
|
try:
|
||||||
|
registry = ToolRegistry()
|
||||||
|
stacks = await asyncio.wait_for(
|
||||||
|
connect_mcp_servers(
|
||||||
|
{
|
||||||
|
"cloudflare": MCPServerConfig(
|
||||||
|
type="streamableHttp",
|
||||||
|
url="https://mcp.example.com/mcp",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
registry,
|
||||||
|
),
|
||||||
|
timeout=5.0,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
assert stacks == {}
|
||||||
|
assert registry.tool_names == []
|
||||||
|
assert unhandled == []
|
||||||
|
assert not any(task.get_name() == "mcp:cloudflare" for task in asyncio.all_tasks())
|
||||||
|
finally:
|
||||||
|
loop.set_exception_handler(previous_exception_handler)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_probe_not_called_for_stdio():
|
async def test_probe_not_called_for_stdio():
|
||||||
"""stdio transport should not be probed — it spawns a local process."""
|
"""stdio transport should not be probed — it spawns a local process."""
|
||||||
|
|||||||
@@ -1082,10 +1082,22 @@ async def test_connect_mcp_servers_http_clients_reject_unsafe_redirect_targets(
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("failure_mode", ["exception", "cancellation"])
|
||||||
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
failure_mode: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
sessions = {"good": _make_fake_session(["demo"])}
|
bad_session = _make_fake_session([])
|
||||||
|
|
||||||
|
async def _cancel_initialize() -> None:
|
||||||
|
raise asyncio.CancelledError("cancelled by SDK")
|
||||||
|
|
||||||
|
if failure_mode == "cancellation":
|
||||||
|
bad_session.initialize = _cancel_initialize
|
||||||
|
sessions = {
|
||||||
|
"bad": bad_session,
|
||||||
|
"good": _make_fake_session(["demo"]),
|
||||||
|
}
|
||||||
|
|
||||||
class _SelectiveClientSession:
|
class _SelectiveClientSession:
|
||||||
def __init__(self, read: object, _write: object) -> None:
|
def __init__(self, read: object, _write: object) -> None:
|
||||||
@@ -1099,7 +1111,7 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _selective_stdio_client(params: object):
|
async def _selective_stdio_client(params: object):
|
||||||
if params.command == "bad":
|
if params.command == "bad" and failure_mode == "exception":
|
||||||
raise RuntimeError("boom")
|
raise RuntimeError("boom")
|
||||||
yield params.command, object()
|
yield params.command, object()
|
||||||
|
|
||||||
@@ -1109,8 +1121,8 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
registry = ToolRegistry()
|
registry = ToolRegistry()
|
||||||
stacks = await connect_mcp_servers(
|
stacks = await connect_mcp_servers(
|
||||||
{
|
{
|
||||||
"good": MCPServerConfig(command="good"),
|
|
||||||
"bad": MCPServerConfig(command="bad"),
|
"bad": MCPServerConfig(command="bad"),
|
||||||
|
"good": MCPServerConfig(command="good"),
|
||||||
},
|
},
|
||||||
registry,
|
registry,
|
||||||
)
|
)
|
||||||
@@ -1121,6 +1133,36 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
|
|||||||
assert set(stacks) == {"good"}
|
assert set(stacks) == {"good"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connect_mcp_servers_propagates_external_cancellation(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
started = asyncio.Event()
|
||||||
|
closed = asyncio.Event()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _blocking_stdio_client(_params: object):
|
||||||
|
try:
|
||||||
|
started.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
yield object(), object()
|
||||||
|
finally:
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _blocking_stdio_client)
|
||||||
|
|
||||||
|
task = asyncio.create_task(
|
||||||
|
connect_mcp_servers({"slow": MCPServerConfig(command="slow")}, ToolRegistry())
|
||||||
|
)
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=1.0)
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
|
||||||
|
await asyncio.wait_for(closed.wait(), timeout=1.0)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
async def test_connect_mcp_servers_streamable_http_uses_finite_timeout(
|
||||||
fake_mcp_runtime: dict[str, object | None],
|
fake_mcp_runtime: dict[str, object | None],
|
||||||
|
|||||||
Reference in New Issue
Block a user