fix(ingest): run the MCP stdio transport against a real server, and repair its error contract (kø-x)
`stdio_call_tool` shipped never having been executed end to end — docs said so explicitly. Running it found a real defect: `stdio_client` and `ClientSession` are each an anyio task group, and anyio re-packages anything leaving one in a `BaseExceptionGroup`. Both errors the transport raises from inside the session (`mcp_tool_error`, `mcp_non_text_content`) therefore reached callers as exception groups, never as the `IngestError` the whole Door A path catches and switches on by `code`. No canned-tool test could see this: they never enter a task group. `_unwrap_ingest_error` recovers the owned error and re-raises it; anything unowned is re-raised untouched, so this narrows an exception group rather than blanket-catching. Duck-typed on `.exceptions` because `except*`/`ExceptionGroup` are 3.11+ and this project supports >=3.10. Verified against a REAL server subprocess (a local process costs no model tokens, so the repo's cost discipline is untouched; the contract tests still spawn nothing): `examples/ingest-golden-mcp/` + `tests/test_ingest_golden_mcp.py` — byte-identical golden extraction mirroring the http/sql goldens, plus the tool-error and missing-`server_ref` branches. Also recorded: a server on the ingest path must expose a NULL-ARGUMENT tool, so `datasource.build_mcp_server` cannot serve it (`retrieve_cost_docs(query)` has a required parameter, verified to return an error result). The two are separate seams by design. Load-bearing MEASURED, five mutations all RED: detach the unwrap · detach `initialize()` · make the error code generic · detach the `isError` branch · change one byte of the served body. 612 -> 615 tests. ruff + format + mypy clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WiY53sm8JFqk7NN75g5wRS
This commit is contained in:
parent
126807aee7
commit
9dc3722161
9 changed files with 272 additions and 9 deletions
|
|
@ -127,8 +127,16 @@ def stdio_call_tool(
|
|||
variable rather than as a tool argument: tool arguments are echoed in MCP progress and result
|
||||
payloads and would land in logs.
|
||||
|
||||
Kept thin and separate from :func:`mcp_get` on purpose — every contract test injects a canned
|
||||
``McpCallTool`` instead, so the suite spawns no subprocess (cost discipline: no heavy runs).
|
||||
Kept thin and separate from :func:`mcp_get` on purpose — the CONTRACT tests
|
||||
(``tests/test_ingest_mcp_loadbearing.py``) inject a canned ``McpCallTool``, so they spawn no
|
||||
subprocess. The stdio path itself is covered separately by ``tests/test_ingest_golden_mcp.py``,
|
||||
which runs it against a REAL server subprocess (a local process costs no model tokens, so the
|
||||
cost discipline that governs this repo is untouched).
|
||||
|
||||
A server consumed here must expose a NULL-ARGUMENT tool: the URL carries both coordinates and
|
||||
the tool is invoked with an empty argument dict. ``datasource.build_mcp_server`` does not
|
||||
qualify — its ``retrieve_cost_docs(query)`` has a required parameter; it serves the agents'
|
||||
retrieval path, not ingest.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
|
|
@ -161,11 +169,42 @@ def stdio_call_tool(
|
|||
)
|
||||
return _body_from_content(result.content, tool=tool, server_ref=server_ref)
|
||||
|
||||
return asyncio.run(asyncio.wait_for(run(), timeout=timeout_seconds))
|
||||
try:
|
||||
return asyncio.run(asyncio.wait_for(run(), timeout=timeout_seconds))
|
||||
except BaseException as exc: # noqa: BLE001 — re-raised unchanged unless we own it
|
||||
owned = _unwrap_ingest_error(exc)
|
||||
if owned is None:
|
||||
raise
|
||||
raise owned from exc
|
||||
|
||||
return call_tool
|
||||
|
||||
|
||||
def _unwrap_ingest_error(exc: BaseException) -> IngestError | None:
|
||||
"""Recover an :class:`IngestError` that anyio re-packaged into a task-group exception group.
|
||||
|
||||
``stdio_client`` and ``ClientSession`` are each an anyio task group, and anyio wraps ANYTHING
|
||||
leaving one in a ``BaseExceptionGroup`` — nested once per group. So every error this transport
|
||||
raises from inside the session (``mcp_tool_error``, ``mcp_non_text_content``) reached callers as
|
||||
an exception group, never as the ``IngestError`` the whole Door A path catches and switches on by
|
||||
``code``. Found only by running against a real server; the canned-tool tests never enter a task
|
||||
group.
|
||||
|
||||
Duck-typed on ``.exceptions`` rather than ``except*`` / ``ExceptionGroup``: both are 3.11+, and
|
||||
this project supports ``>=3.10``, where anyio raises the ``exceptiongroup`` backport instead.
|
||||
|
||||
Returns ``None`` for anything we do not own, so the caller re-raises it untouched — this
|
||||
narrows an exception group, it is never a blanket re-raise.
|
||||
"""
|
||||
if isinstance(exc, IngestError):
|
||||
return exc
|
||||
for sub in getattr(exc, "exceptions", ()):
|
||||
owned = _unwrap_ingest_error(sub)
|
||||
if owned is not None:
|
||||
return owned
|
||||
return None
|
||||
|
||||
|
||||
def _body_from_content(content: list[Any], *, tool: str, server_ref: str) -> str:
|
||||
"""Concatenate an MCP tool result's TEXT blocks into the verbatim body (§5).
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue