fix(ingest): narrow mcp_timeout to our OWN deadline, not the exception type (kø-z follow-up)

Advisor review of the prior commit (5269b7d) found the except TimeoutError
branch was wider than the brief asked for: builtin TimeoutError is also
socket.timeout (3.10+) and asyncio.TimeoutError (3.11+), so any TimeoutError
reaching that clause got relabeled mcp_timeout regardless of source. Gate on
anyio.CancelScope.cancelled_caught instead, mirroring _unwrap_ingest_error's
ownership rule (own it, wrap it; otherwise, untouched).

Measured: no live trigger exists today (MCP's own internal read-timeout
converts to McpError before reaching us; a server-side TimeoutError becomes
an ordinary isError result) -- pinned with a synthetic test raising from
StdioServerParameters construction, inside our fail_after scope but before
either nested task group, so it arrives ungrouped. Four mutations red against
the full 625-test suite: drop the translation, revert to asyncio.wait_for,
relabel the code, and drop the cancelled_caught gate.

Also promotes anyio to a declared direct dependency (was transitive via mcp
only) -- ingest_mcp.py now imports it directly.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-03 21:22:00 +02:00
commit 8d1d29edf0
4 changed files with 64 additions and 10 deletions

View file

@ -157,6 +157,12 @@ def stdio_call_tool(
if credential is not None:
env["MCP_CREDENTIAL"] = credential
# Populated with OUR OWN cancel scope once `run()` enters it — read back after
# `asyncio.run` returns/raises, to tell "our deadline fired" apart from any other
# `TimeoutError` (builtin `TimeoutError` is also `socket.timeout`/`asyncio.TimeoutError`
# since 3.10/3.11, so the exception TYPE alone does not prove ownership).
deadline: dict[str, anyio.CancelScope] = {}
async def run() -> str:
# The deadline is an anyio cancel scope, not `asyncio.wait_for`, and it wraps BOTH
# nested task groups (`stdio_client`, `ClientSession`) from the INSIDE. `wait_for`
@ -166,7 +172,8 @@ def stdio_call_tool(
# — because a background reader task lost its write end mid-teardown. anyio's own scope
# is what the task groups already coordinate cancellation through, so nesting inside it
# tears down cleanly and raises a plain `TimeoutError` at the `with` statement.
with anyio.fail_after(timeout_seconds):
with anyio.fail_after(timeout_seconds) as scope:
deadline["scope"] = scope
params = StdioServerParameters(command=command, args=list(args), env=env)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
@ -182,10 +189,20 @@ def stdio_call_tool(
try:
return asyncio.run(run())
except TimeoutError as exc:
raise IngestError(
f"MCP tool {tool!r} on {server_ref!r} did not respond within {timeout_seconds}s",
code="mcp_timeout",
) from exc
# Mirrors `_unwrap_ingest_error`'s ownership rule: own it, wrap it; otherwise,
# untouched. Only OUR scope hitting ITS OWN deadline earns `mcp_timeout` — a
# `TimeoutError` from elsewhere (there is no live source today: MCP's own internal
# read-timeout converts to `McpError` before reaching us, and a server-side
# `TimeoutError` becomes an ordinary `isError` result, both measured — but the type
# alone does not guarantee it) is re-raised exactly as `_unwrap_ingest_error` would.
scope = deadline.get("scope")
if scope is not None and scope.cancelled_caught:
raise IngestError(
f"MCP tool {tool!r} on {server_ref!r} did not respond within "
f"{timeout_seconds}s",
code="mcp_timeout",
) from exc
raise
except BaseException as exc: # noqa: BLE001 — re-raised unchanged unless we own it
owned = _unwrap_ingest_error(exc)
if owned is None: