"""Every wait on the MCP path is bounded.
A server that accepts a request and never answers is the shape of the prod
failure. With no read timeout the SDK waits forever (``anyio.fail_after(None)``),
the caller's future is never resolved, and the runner task stays wedged for the
life of the process — so every later reload blocked on that same client and
``disconnect_all`` walked the pool one stuck client at a time.
These tests talk to a fake transport through a *real* ``ClientSession``: it reads
requests and replies to nothing, which is what a silent server looks like from
here. The bounds are the code under test, so each test patches them down to
fractions of a second.
"""
import asyncio
import contextlib
import time
import anyio
import httpx
import pytest
from mcp.shared.message import SessionMessage
from mcp.types import (
LATEST_PROTOCOL_VERSION,
Implementation,
InitializeResult,
JSONRPCMessage,
JSONRPCResponse,
ServerCapabilities,
)
import navi.mcp.client as client_mod
from navi.mcp.client import McpClient, McpOperationTimeout
from navi.mcp.config import McpServerConfig
from navi.mcp.manager import McpManager
class SilentServer:
"""A stand-in stdio transport: reads every request, answers almost none.
``answer_handshake=False`` makes it silent from the very first byte — the
server that accepts the connection and says nothing at all. Otherwise it
completes the ``initialize`` handshake (so the session is real and usable)
and then goes quiet, which is how a server that died mid-session behaves.
"""
def __init__(self, *, answer_handshake: bool = True) -> None:
self.answer_handshake = answer_handshake
self.requests: list[str] = []
self._tasks: list[asyncio.Task] = []
def __call__(self, params): # stands in for stdio_client(params)
return self
async def __aenter__(self):
# (read_stream, write_stream) from the client's side: it reads what we
# send, and we read what it writes. Streams are buffered, so a client
# write never blocks on us reading it.
to_client_w, to_client = anyio.create_memory_object_stream[SessionMessage](8)
from_client, from_client_r = anyio.create_memory_object_stream[SessionMessage](8)
self._to_client = to_client_w
self._from_client = from_client
self._tasks.append(asyncio.create_task(self._serve(from_client_r)))
return to_client, from_client
async def __aexit__(self, *exc_info) -> bool:
for task in self._tasks:
task.cancel()
for task in self._tasks:
with contextlib.suppress(BaseException):
await task
self._tasks.clear()
return False
async def _serve(self, reader) -> None:
async for session_message in reader:
request = session_message.message.root
method = getattr(request, "method", None)
if method is None:
continue
self.requests.append(method)
if method != "initialize" or not self.answer_handshake:
continue # read it, never answer it
result = InitializeResult(
protocolVersion=LATEST_PROTOCOL_VERSION,
capabilities=ServerCapabilities(),
serverInfo=Implementation(name="silent", version="1"),
)
await self._to_client.send(
SessionMessage(
message=JSONRPCMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=request.id,
result=result.model_dump(by_alias=True, mode="json"),
)
)
)
)
def _cfg() -> McpServerConfig:
return McpServerConfig(transport="stdio", command="python", args=["-c", "pass"])
@pytest.fixture
def quiet_after_handshake(monkeypatch):
"""A server that completes the handshake, then never answers again."""
server = SilentServer(answer_handshake=True)
monkeypatch.setattr(client_mod, "stdio_client", server)
return server
@pytest.fixture
def silent_from_the_start(monkeypatch):
"""A server that accepts the connection and answers nothing at all."""
server = SilentServer(answer_handshake=False)
monkeypatch.setattr(client_mod, "stdio_client", server)
return server
async def test_a_silent_server_cannot_wedge_list_tools(quiet_after_handshake, monkeypatch):
"""The SDK-level read timeout is what ends a request nobody answers — and it
ends it *inside* the runner, so the client is still usable afterwards."""
monkeypatch.setattr(client_mod, "_SESSION_READ_TIMEOUT_S", 0.2)
monkeypatch.setattr(client_mod, "_LIST_TOOLS_TIMEOUT_S", 10.0)
client = McpClient("quiet", _cfg())
await asyncio.wait_for(client.connect(), timeout=5)
started = time.monotonic()
with pytest.raises(Exception, match="Timed out"):
await asyncio.wait_for(client.list_tools(), timeout=5)
assert time.monotonic() - started < 3
assert quiet_after_handshake.requests.count("tools/list") >= 1
await client.disconnect()
async def test_the_caller_bound_covers_an_op_the_sdk_never_times_out(
quiet_after_handshake, monkeypatch
):
"""The layer below the SDK's: an op that never reaches the SDK's read wait —
a server that stops reading its requests, a caller queued behind one — is
caught here, and the wedged runner is aborted rather than left for the next
command to queue behind."""
monkeypatch.setattr(client_mod, "_SESSION_READ_TIMEOUT_S", 60.0)
monkeypatch.setattr(client_mod, "_LIST_TOOLS_TIMEOUT_S", 0.2)
monkeypatch.setattr(client_mod, "_CONNECT_TIMEOUT_S", 5.0)
client = McpClient("wedged", _cfg())
await client.connect()
with pytest.raises(McpOperationTimeout) as excinfo:
await asyncio.wait_for(client.list_tools(), timeout=5)
assert excinfo.value.server == "wedged"
assert excinfo.value.op == "list_tools"
# Aborted, not merely abandoned: the next command starts a fresh runner.
assert client._runner_task is None
assert client.connected is False
async def test_a_server_that_never_answers_cannot_hang_load_all(
silent_from_the_start, monkeypatch
):
"""Startup (and every reload) connects to every configured server. One that
accepts the connection and says nothing used to hang the whole loop."""
monkeypatch.setattr(client_mod, "_SESSION_READ_TIMEOUT_S", 0.2)
manager = McpManager()
started = time.monotonic()
await asyncio.wait_for(
manager.load_all({"quiet": _cfg()}), timeout=5
)
assert time.monotonic() - started < 3
assert "quiet" in manager.clients # kept in the pool for the health check
assert manager._connected_status["quiet"] is False
await manager.disconnect_all()
async def test_a_wedged_client_does_not_block_load_all(silent_from_the_start, monkeypatch):
"""The prod symptom, at the manager level: ``load_all`` disconnects the whole
pool first, so one client stuck in teardown blocked every reload after it."""
monkeypatch.setattr(client_mod, "_SESSION_READ_TIMEOUT_S", 60.0)
monkeypatch.setattr(client_mod, "_CONNECT_TIMEOUT_S", 60.0)
monkeypatch.setattr(client_mod, "_DISCONNECT_TIMEOUT_S", 0.2)
manager = McpManager()
wedged = McpClient("wedged", _cfg())
manager._clients["wedged"] = wedged
pending = asyncio.create_task(wedged.connect()) # never answers
await asyncio.sleep(0.1)
started = time.monotonic()
await asyncio.wait_for(manager.load_all({}), timeout=5)
assert time.monotonic() - started < 3
assert manager.clients == {}
# The caller that was waiting on the abandoned runner is released, not left
# hanging: a reload's budget has to be able to reach the end of its work.
with pytest.raises(BaseException):
await asyncio.wait_for(pending, timeout=2)
async def test_the_streamable_http_transport_is_not_left_on_httpx_defaults(monkeypatch):
"""A bare ``httpx.AsyncClient`` defaults to 5s for *everything*, so the GET
stream of an idle streamable-http server — and the POST that carries a slow
tool call — died on a timeout nobody chose."""
monkeypatch.setattr(client_mod, "_SESSION_READ_TIMEOUT_S", 0.2)
captured: dict = {}
class RecordingClient(httpx.AsyncClient):
def __init__(self, **kwargs):
captured.update(kwargs)
super().__init__(**kwargs)
monkeypatch.setattr(client_mod.httpx, "AsyncClient", RecordingClient)
client = McpClient(
"streamy",
McpServerConfig(transport="streamable_http", url="http://127.0.0.1:9/mcp"),
)
with pytest.raises(Exception):
await client.connect()
assert captured["timeout"].read == client_mod._HTTP_STREAM_READ_TIMEOUT_S
assert captured["timeout"].connect == client_mod._HTTP_TIMEOUT_S
await client.disconnect()