tools/list is paginated in MCP and cMCP consumes one page, so a server with more tools than fit in a single response gives the gateway a partial catalog. Raised by @solloek369-arch on #566 and verified on main.
The evidence
src/cmcp_runtime/mcp/proxy.py, _upstream_tools:
payload, headers = build_request("provenance-tools-list", "tools/list", {})
resp = await client.post(entry.server.url, json=payload, headers=headers)
resp.raise_for_status()
result = parse_response(resp, "provenance-tools-list").get("result")
...
tools = result.get("tools") if isinstance(result, dict) else None
return tools if isinstance(tools, list) else None
Empty params, so no cursor is sent. One read of result["tools"], and return.
The string cursor appears zero times in the entire src/ tree, so this is not a gap in one path. There is no pagination handling anywhere in the codebase, and nextCursor is discarded silently because nothing looks for it.
Why it matters here specifically
_check_upstream_drift compares what a server advertises against what was approved. With a truncated advertisement, that comparison runs against a catalog that is missing tools rather than one that has changed.
The failure direction is the bad one. A tool present in the approval and absent from page one looks like a removal; a tool added on page two is invisible entirely. Neither reads as "we did not finish reading the catalog", which is what actually happened.
What is needed
- Exhaust pagination: follow
nextCursor until the server stops returning one.
- Guard the loop: a repeated cursor and a cyclic cursor must both terminate and be reported as a discovery failure rather than as a completed read.
- Report an incomplete read as incomplete. A catalog that could not be fully acquired is not a catalog that changed, and the drift comparison must not run on one.
Point 3 is the one that matters beyond correctness: an unfinished acquisition currently becomes a drift verdict, which is a not-established case being reported as a result.
Scope
Discovery and drift only. No change to approval semantics, checkpoint binding, or the acceptance matrix being designed on #566; the matrix will want pagination-exhaustion cases once this exists.
Related: #566, #568.
tools/listis paginated in MCP and cMCP consumes one page, so a server with more tools than fit in a single response gives the gateway a partial catalog. Raised by @solloek369-arch on #566 and verified onmain.The evidence
src/cmcp_runtime/mcp/proxy.py,_upstream_tools:Empty params, so no cursor is sent. One read of
result["tools"], and return.The string
cursorappears zero times in the entiresrc/tree, so this is not a gap in one path. There is no pagination handling anywhere in the codebase, andnextCursoris discarded silently because nothing looks for it.Why it matters here specifically
_check_upstream_driftcompares what a server advertises against what was approved. With a truncated advertisement, that comparison runs against a catalog that is missing tools rather than one that has changed.The failure direction is the bad one. A tool present in the approval and absent from page one looks like a removal; a tool added on page two is invisible entirely. Neither reads as "we did not finish reading the catalog", which is what actually happened.
What is needed
nextCursoruntil the server stops returning one.Point 3 is the one that matters beyond correctness: an unfinished acquisition currently becomes a drift verdict, which is a not-established case being reported as a result.
Scope
Discovery and drift only. No change to approval semantics, checkpoint binding, or the acceptance matrix being designed on #566; the matrix will want pagination-exhaustion cases once this exists.
Related: #566, #568.