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
2
examples/ingest-golden-mcp/expected-bundle/index.md
Normal file
2
examples/ingest-golden-mcp/expected-bundle/index.md
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Golden extraction case: one MCP stdio extract (http source family, mcp+stdio transport).
|
||||
- [Cost documentation](ingest-cost-docs.md)
|
||||
|
|
@ -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"}
|
||||
```
|
||||
1
examples/ingest-golden-mcp/ingested-at.txt
Normal file
1
examples/ingest-golden-mcp/ingested-at.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
2026-08-03T12:00:00Z
|
||||
14
examples/ingest-golden-mcp/manifest.json
Normal file
14
examples/ingest-golden-mcp/manifest.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
27
examples/ingest-golden-mcp/server.py
Normal file
27
examples/ingest-golden-mcp/server.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue