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
|
|
@ -150,12 +150,28 @@ forget. The transport discriminator **gates rather than labels**: `mcp_get` refu
|
|||
not own, so an MCP transport can never quietly serve a plain `https://` manifest and leave the
|
||||
bundle's provenance claiming a transport that was never used.
|
||||
|
||||
**Not verified against a live MCP server.** Every test injects a canned tool call, so the suite
|
||||
spawns no subprocess and opens no socket (cost discipline). `stdio_call_tool` — the real stdio path
|
||||
— is therefore **written but never executed end to end**; a deployer using it should expect to shake
|
||||
it out. The seam it plugs into (`mcp_get`, the discriminator, the gate) *is* measured, including six
|
||||
detach mutations. There is no `examples/ingest-golden-mcp/` fixture, and MCP remains **unwired in
|
||||
the optimiser run path** — the in-process `FunctionTool` seam stays the default there.
|
||||
**Verified against a real MCP server subprocess (2026-08-03).** `stdio_call_tool` was previously
|
||||
written but never executed end to end; `examples/ingest-golden-mcp/` + `tests/test_ingest_golden_mcp.py`
|
||||
now run it against a live server process — byte-identical golden extraction, plus the tool-error and
|
||||
missing-`server_ref` branches. Five detach mutations measured RED (unwrap, `initialize()`, error-code
|
||||
identity, the `isError` branch, one body byte). A local subprocess costs no model tokens, so the cost
|
||||
discipline is untouched; the contract tests still inject a canned tool and spawn nothing.
|
||||
|
||||
**What running it actually found — the error contract was broken.** `stdio_client` and
|
||||
`ClientSession` are each an anyio task group, and anyio re-packages anything leaving one in a
|
||||
`BaseExceptionGroup`. Every error raised inside the session (`mcp_tool_error`,
|
||||
`mcp_non_text_content`) therefore reached callers as an exception group, never as the `IngestError`
|
||||
the whole Door A path catches and switches on by `code`. Fixed by unwrapping the group and
|
||||
re-raising the owned error; anything unowned is re-raised untouched. **No canned-tool test could
|
||||
have caught this** — they never enter a task group. This is the case for running what you ship.
|
||||
|
||||
**A server on this path must expose a null-argument tool.** The URL carries both coordinates and the
|
||||
tool is called with an empty argument dict, so `datasource.build_mcp_server` **cannot** serve ingest:
|
||||
its `retrieve_cost_docs(query)` has a required parameter (verified — it returns an error result).
|
||||
The two are separate seams by design: `build_mcp_server` serves the agents' retrieval path.
|
||||
|
||||
**Still true:** MCP remains **unwired in the optimiser run path** — the in-process `FunctionTool`
|
||||
seam stays the default there. The timeout path (`asyncio.wait_for`) is not covered by a test.
|
||||
|
||||
**Where the D7 sibling stands (målbilde §11 boundary).** The Claude Agent SDK sibling built the
|
||||
**file/CSV and SQL** connectors — mirroring I3/I5 — with bit-identical golden extractions. **HTTP
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue