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:
Kjell Tore Guttormsen 2026-08-03 17:56:19 +02:00
commit 9dc3722161
9 changed files with 272 additions and 9 deletions

View file

@ -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).