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:
parent
5269b7ddd5
commit
8d1d29edf0
4 changed files with 64 additions and 10 deletions
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue