mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 16:21:50 +03:00
Move blocking filesystem, persistence, subprocess, media, and DNS work off the gateway event loop while preserving existing contracts. Add bounded cancellation and responsiveness regression coverage.
268 lines
9.0 KiB
Python
268 lines
9.0 KiB
Python
"""Tests for MCP HTTP probe guard (prevents event-loop crash on unreachable servers)."""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import httpx
|
|
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.registry import ToolRegistry
|
|
from nanobot.config.schema import MCPServerConfig
|
|
from nanobot.security.network import configure_ssrf_whitelist
|
|
|
|
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for name in (*_PROXY_ENV_VARS, "NO_PROXY", "no_proxy"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _probe_http_url unit tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_returns_true_for_open_port(tmp_path):
|
|
"""Start a trivial TCP server, probe should return True."""
|
|
async def _close_connection(_reader, writer):
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
|
|
server = await asyncio.start_server(_close_connection, "127.0.0.1", 0)
|
|
port = server.sockets[0].getsockname()[1]
|
|
configure_ssrf_whitelist(["127.0.0.1/32"])
|
|
try:
|
|
assert await _probe_http_url(f"http://127.0.0.1:{port}/mcp") is True
|
|
finally:
|
|
configure_ssrf_whitelist([])
|
|
server.close()
|
|
await server.wait_closed()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_returns_false_for_closed_port():
|
|
"""Port 19999 is almost certainly not listening."""
|
|
assert await _probe_http_url("http://127.0.0.1:19999/mcp") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_uses_default_port_for_http(monkeypatch: pytest.MonkeyPatch):
|
|
"""When no port is present, probe the validated address on port 80."""
|
|
attempts: list[tuple[str, int]] = []
|
|
|
|
async def _resolve_url_target(
|
|
_url: str,
|
|
) -> tuple[bool, str, tuple[str, ...]]:
|
|
return True, "", ("93.184.216.34",)
|
|
|
|
monkeypatch.setattr(mcp_mod, "async_resolve_url_target", _resolve_url_target)
|
|
|
|
async def _open_connection(host: str, port: int):
|
|
attempts.append((host, port))
|
|
raise ConnectionRefusedError
|
|
|
|
monkeypatch.setattr("nanobot.agent.tools.mcp.asyncio.open_connection", _open_connection)
|
|
|
|
assert await _probe_http_url("http://unreachable-host.test/mcp") is False
|
|
assert attempts == [("93.184.216.34", 80)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_rejects_public_name_resolving_to_loopback(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def _resolve_url_target(
|
|
_url: str,
|
|
) -> tuple[bool, str, tuple[str, ...]]:
|
|
return False, "Blocked: example.com resolves to private/internal address 127.0.0.1", ()
|
|
|
|
monkeypatch.setattr(mcp_mod, "async_resolve_url_target", _resolve_url_target)
|
|
|
|
assert await _probe_http_url("http://example.com:8765/mcp") is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_skips_direct_tcp_when_global_proxy_env_is_set(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
async def _resolve_url_target(
|
|
_url: str,
|
|
) -> tuple[bool, str, tuple[str, ...]]:
|
|
return True, "", ("93.184.216.34",)
|
|
|
|
async def _open_connection(*args, **kwargs):
|
|
raise AssertionError("global proxy env should skip direct TCP probe")
|
|
|
|
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
|
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
|
|
monkeypatch.setattr(mcp_mod, "async_resolve_url_target", _resolve_url_target)
|
|
monkeypatch.setattr("nanobot.agent.tools.mcp.asyncio.open_connection", _open_connection)
|
|
|
|
assert await _probe_http_url("https://mcp.example.com/mcp") is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_tries_next_validated_ip_when_first_is_unreachable(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
attempts: list[tuple[str, int]] = []
|
|
|
|
class FakeWriter:
|
|
def close(self):
|
|
return None
|
|
|
|
async def wait_closed(self):
|
|
return None
|
|
|
|
async def _resolve_url_target(
|
|
_url: str,
|
|
) -> tuple[bool, str, tuple[str, ...]]:
|
|
return True, "", ("93.184.216.34", "93.184.216.35")
|
|
|
|
async def _open_connection(host: str, port: int):
|
|
attempts.append((host, port))
|
|
if host == "93.184.216.34":
|
|
raise OSError("first address unreachable")
|
|
return object(), FakeWriter()
|
|
|
|
monkeypatch.setattr(mcp_mod, "async_resolve_url_target", _resolve_url_target)
|
|
monkeypatch.setattr("nanobot.agent.tools.mcp.asyncio.open_connection", _open_connection)
|
|
|
|
assert await _probe_http_url("http://mcp.example:8765/mcp") is True
|
|
assert attempts == [
|
|
("93.184.216.34", 8765),
|
|
("93.184.216.35", 8765),
|
|
]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# connect_mcp_servers skips unreachable HTTP servers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_http_cfg(url: str, transport: str = "streamableHttp"):
|
|
cfg = MagicMock()
|
|
cfg.type = transport
|
|
cfg.url = url
|
|
cfg.command = None
|
|
cfg.args = []
|
|
cfg.env = {}
|
|
cfg.headers = None
|
|
cfg.tool_timeout = 30
|
|
cfg.enabled_tools = ["*"]
|
|
return cfg
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_skips_unreachable_streamable_http():
|
|
"""Unreachable streamableHttp server should be skipped with a warning, no crash."""
|
|
async def _unreachable(_url: str) -> bool:
|
|
return False
|
|
|
|
registry = ToolRegistry()
|
|
servers = {"dead": _make_http_cfg("http://93.184.216.34:19999/mcp")}
|
|
with patch("nanobot.agent.tools.mcp._probe_http_url", _unreachable):
|
|
stacks = await connect_mcp_servers(servers, registry)
|
|
assert stacks == {}
|
|
assert len(registry._tools) == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_connect_skips_unreachable_sse():
|
|
"""Unreachable SSE server should be skipped with a warning, no crash."""
|
|
async def _unreachable(_url: str) -> bool:
|
|
return False
|
|
|
|
registry = ToolRegistry()
|
|
servers = {"dead": _make_http_cfg("http://93.184.216.34:19999/sse", transport="sse")}
|
|
with patch("nanobot.agent.tools.mcp._probe_http_url", _unreachable):
|
|
stacks = await connect_mcp_servers(servers, registry)
|
|
assert stacks == {}
|
|
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
|
|
|
|
async def _validate_url_target(_url: str) -> tuple[bool, str]:
|
|
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, "async_validate_url_target", _validate_url_target)
|
|
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
|
|
async def test_probe_not_called_for_stdio():
|
|
"""stdio transport should not be probed — it spawns a local process."""
|
|
called = False
|
|
original_probe = _probe_http_url
|
|
|
|
async def _spy_probe(url, **kw):
|
|
nonlocal called
|
|
called = True
|
|
return await original_probe(url, **kw)
|
|
|
|
with patch("nanobot.agent.tools.mcp._probe_http_url", _spy_probe):
|
|
cfg = MagicMock()
|
|
cfg.type = "stdio"
|
|
cfg.url = None
|
|
cfg.command = "nonexistent-command-xyz"
|
|
cfg.args = []
|
|
cfg.env = None
|
|
cfg.headers = None
|
|
cfg.tool_timeout = 30
|
|
cfg.enabled_tools = ["*"]
|
|
registry = ToolRegistry()
|
|
await connect_mcp_servers({"s": cfg}, registry)
|
|
|
|
assert not called, "probe should not be called for stdio transport"
|