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

@ -0,0 +1,2 @@
Golden extraction case: one MCP stdio extract (http source family, mcp+stdio transport).
- [Cost documentation](ingest-cost-docs.md)

View file

@ -0,0 +1,13 @@
---
type: concept
title: Cost documentation
source_system: docs-mcp
source_query: cost_docs
ingested_at: 2026-08-03T12:00:00Z
ingest_manifest: manifest@18577a264477cd11
generated: true
---
```
{"service": "cost-docs", "state": "ready | partial", "path": "c:\temp\cache"}
```

View file

@ -0,0 +1 @@
2026-08-03T12:00:00Z

View file

@ -0,0 +1,14 @@
{
"manifest_version": 1,
"source": {"type": "http", "id": "docs-mcp", "base_url": "mcp+stdio://PORTFOLIO_GOLDEN_MCP", "credential_ref": null},
"bundle_summary": "Golden extraction case: one MCP stdio extract (http source family, mcp+stdio transport).",
"extractions": [
{
"id": "cost-docs",
"title": "Cost documentation",
"query": "cost_docs",
"okf_type": "concept",
"max_rows": 10
}
]
}

View file

@ -0,0 +1,27 @@
r"""Golden-case MCP stdio server — the counterpart `stdio_call_tool` is measured against.
Deliberately a NULL-ARGUMENT tool. The ingest transport carries its two coordinates in the URL
(`mcp+stdio://<server_ref>/<tool>`) and calls the tool with an empty argument dict, so a server
consumed by Door A must expose a tool that needs none. `datasource.build_mcp_server` does NOT
qualify its `retrieve_cost_docs(query)` takes a required argument; it serves the agents'
retrieval path, not the ingest path.
The body is a fixed literal so the golden is byte-deterministic, and it carries a `|` and a `\`
to pin the §5 rule that an http-family body is rendered VERBATIM inside a fence, never escaped
into a markdown table.
"""
from mcp.server.fastmcp import FastMCP
server = FastMCP("portfolio-optimiser-golden")
_BODY = '{"service": "cost-docs", "state": "ready | partial", "path": "c:\\temp\\cache"}\n'
@server.tool()
def cost_docs() -> str:
return _BODY
if __name__ == "__main__":
server.run()