fix(ingest): compose the MCP timeout with anyio's own cancel scope, not asyncio.wait_for (kø-z)

asyncio.wait_for cancelling stdio_call_tool's run() from outside the anyio
task groups it awaits (stdio_client, ClientSession) never surfaced a
TimeoutError: measured against a real hanging server, the mismatch produced
an anyio.BrokenResourceError wrapped in a BaseExceptionGroup instead. Moving
the deadline to anyio.fail_after, nested inside both task groups, lets
anyio tear down its own structure cleanly and raise a plain TimeoutError,
which is now translated into IngestError(code="mcp_timeout") alongside the
mcp_tool_error/mcp_non_text_content family.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-03 21:02:52 +02:00
commit 5269b7ddd5
2 changed files with 49 additions and 12 deletions

View file

@ -120,6 +120,28 @@ def test_tool_error_reaches_the_caller_as_ingest_error(
assert excinfo.value.code == "mcp_tool_error"
def test_tool_timeout_reaches_the_caller_as_ingest_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A tool that outlives ``timeout_seconds`` must surface as a typed ``IngestError``.
``asyncio.wait_for`` wraps ``run()`` itself two nested anyio task groups
(``stdio_client``, ``ClientSession``). Only a REAL hanging server proves what the timeout
cancellation actually surfaces as: a canned-tool test never enters a task group and cannot
observe this at all (the same reason (x)'s ``mcp_tool_error`` unwrap needed a real subprocess).
RED until the timeout is caught and re-raised as an owned ``IngestError``.
"""
script = _write_server(
tmp_path,
"import time\n@server.tool()\ndef cost_docs() -> str:\n time.sleep(5)\n return 'late'\n",
)
get = _transport(script, monkeypatch, timeout_seconds=0.2)
with pytest.raises(IngestError) as excinfo:
get(f"mcp+stdio://{_SERVER_REF}/cost_docs", None)
assert excinfo.value.code == "mcp_timeout"
def test_missing_server_ref_fails_before_any_process_is_spawned(
monkeypatch: pytest.MonkeyPatch,
) -> None: