Skip to content

Commit 108eb47

Browse files
authored
fix(discovery): exhaust paginated tools/list before comparison (#633)
* fix(discovery): exhaust upstream tool catalog pagination Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> * test(discovery): write cancellation handshake as exact bytes Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> * test(stdio): cover later-page transport failure Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> * fix(discovery): share first-contact acquisition across checks Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> * test(discovery): tolerate cancelled child socket reset on Windows Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com> --------- Signed-off-by: Noah Ingwers <98993329+noah-ing@users.noreply.github.com>
1 parent 2ed37e5 commit 108eb47

11 files changed

Lines changed: 1523 additions & 30 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
161161

162162
### Fixed
163163

164+
- **Exhaust upstream `tools/list` pagination before drift and provenance
165+
comparisons** (#631). HTTP and stdio share bounded acquisition: later-page
166+
failures, malformed or ambiguous listings, and cursor cycles are unchecked,
167+
never a comparison against a partial catalog. Existing drift policy and
168+
unchecked-call behavior are unchanged. Observed by solloek369-arch on #566
169+
and confirmed in #631 by Imran Siddique.
170+
Drift and provenance now share one completed discovery acquisition per
171+
server/authority per session, including unchecked outcomes, avoiding a second
172+
full pagination walk on cold calls with provenance configured. Concurrent
173+
readers wait for completion; cancelled reads are not cached.
174+
Session rebinding resets acquisition and comparison caches together. The
175+
duplicate-fetch cost was identified by qubeena07 during review of #633.
176+
164177
- TLS pinning test fixtures set `minimum_version = TLSv1_2`; the server was built
165178
with `PROTOCOL_TLS_SERVER` and no floor, leaving TLSv1 and TLSv1.1 reachable in
166179
the test that asserts the gateway's transport rules.

LIMITATIONS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ cMCP compares what each upstream server advertises against the approved catalog
2525

2626
Separately, the approved description rather than the live one is what the gateway serves to the agent on `tools/list`, so a mutated description does not reach the model through cMCP even in the windows above. That is a structural property of proxying an approved catalog, not a detection result, and it does not extend to the tool's behaviour once called.
2727

28+
HTTP and stdio discovery exhaust `tools/list` pagination before comparing either drift or provenance. A later-page failure, malformed discovery shape, duplicate tool name, repeated/cyclic cursor, or continuation beyond 1,000 pages makes the entire acquisition unchecked; no partial list is compared. Cursors are passed back unchanged, including an empty string. This bound limits page count, not total elapsed time or response bytes, and pagination does not establish an atomic snapshot of a changing server. This is acquisition validation, not full MCP schema validation or a new approval/hash policy. The unchecked-call behavior above is unchanged.
29+
30+
Drift and provenance share the completed first-contact acquisition for the same server and publisher authority within a session, rather than independently walking all pages. An unchecked acquisition is also cached until the next session. Cancellation leaves no cached acquisition; existing stdio child-close behavior is unchanged, not an automatic child restart. Close/reset drains admitted calls before cleanup invalidates both comparisons and the shared acquisition, even if resource cleanup subsequently fails. This avoids duplicate discovery work; it does not add continuous monitoring or make the listing an atomic snapshot.
31+
2832
**Phase 2 completeness: server-side attestation**
2933
Phase 1 attests the gateway boundary. It does not attest what happens on the other side of that boundary. The `tool_transcript.hash` field in the TRACE Claim records a hash of the audit chain tip, but the tool transcript binding that ties a specific tool execution to a specific response is Phase 2 work. Phase 1 partially addresses P1.4 (transitive trust into upstream dependencies) and P4.1 (typosquatted packages added to catalog) -- both are fully closed by Phase 2. Any compliance claim that relies on server-side proof must wait for Phase 2.
3034

src/cmcp_runtime/mcp/discovery.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Acquire a complete tools/list before drift or provenance comparison.
2+
3+
This is acquisition validation, not approval or a catalog hash construction.
4+
No partial result escapes: every page must be attributable and well-shaped,
5+
and pagination must terminate within the local page budget.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from collections.abc import Awaitable, Callable
11+
from typing import Any
12+
13+
# A page bound also terminates servers that issue endlessly distinct cursors.
14+
# It is not a wall-clock deadline or a guarantee of an atomic server snapshot.
15+
MAX_DISCOVERY_PAGES = 1000
16+
17+
PageFetcher = Callable[[str, dict[str, Any]], Awaitable[dict[str, Any]]]
18+
19+
20+
class DiscoveryError(ValueError):
21+
"""A bounded local reason; never embed upstream payloads or cursor values."""
22+
23+
24+
async def collect_tools(fetch_page: PageFetcher) -> list[dict[str, Any]]:
25+
"""Return only an exhausted, unambiguous listing; otherwise raise.
26+
27+
Transport failures and cancellation propagate to the owning transport.
28+
Cursors are opaque strings, including the empty string. Only absence of
29+
nextCursor terminates a listing; an empty page alone does not.
30+
"""
31+
tools: list[dict[str, Any]] = []
32+
names: set[str] = set()
33+
cursors: set[str] = set()
34+
params: dict[str, Any] = {}
35+
for page in range(MAX_DISCOVERY_PAGES):
36+
request_id = f"provenance-tools-list-{page}"
37+
body = await fetch_page(request_id, params)
38+
if (
39+
not isinstance(body, dict)
40+
or body.get("jsonrpc") != "2.0"
41+
or body.get("id") != request_id
42+
or "error" in body
43+
):
44+
raise DiscoveryError("invalid response envelope")
45+
result = body.get("result")
46+
if not isinstance(result, dict) or not isinstance(result.get("tools"), list):
47+
raise DiscoveryError("invalid tools page")
48+
for tool in result["tools"]:
49+
if (
50+
not isinstance(tool, dict)
51+
or not isinstance(tool.get("name"), str)
52+
or not tool["name"]
53+
):
54+
raise DiscoveryError("invalid tool name")
55+
if tool["name"] in names:
56+
raise DiscoveryError("duplicate tool name")
57+
names.add(tool["name"])
58+
tools.append(tool)
59+
if "nextCursor" not in result:
60+
return tools
61+
cursor = result["nextCursor"]
62+
if not isinstance(cursor, str):
63+
raise DiscoveryError("invalid continuation cursor")
64+
if cursor in cursors:
65+
raise DiscoveryError("repeated continuation cursor")
66+
cursors.add(cursor)
67+
params = {"cursor": cursor}
68+
raise DiscoveryError("discovery page limit exceeded")

src/cmcp_runtime/mcp/proxy.py

Lines changed: 54 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
)
4343
from cmcp_runtime.execution import valid_execution_id
4444
from cmcp_runtime.mcp import tls_pinning
45+
from cmcp_runtime.mcp.discovery import DiscoveryError, collect_tools
4546
from cmcp_runtime.mcp.stdio import StdioServer
4647
from cmcp_runtime.mcp.streamable_http import (
4748
build_request,
@@ -291,18 +292,9 @@ def __init__(
291292
# in memory would carry it from one agent's session into the next, and
292293
# the audit chain cannot see that happen (docs/spec/stdio-transport.md).
293294
self._stdio_servers: dict[tuple[str, ...], StdioServer] = {}
294-
# Provenance outcome per server, decided once per session on first use.
295-
# Cached because the answer cannot change within a session without the
296-
# server being replaced underneath us, and re-listing tools on every call
297-
# would make the check expensive enough to be turned off.
298-
self._provenance: dict[tuple[str, ...], ProvenanceResult] = {}
295+
self._reset_upstream_checks()
299296
# Servers already warned about unenforceable pinning (warn once each).
300297
self._tls_pin_warned: set[str] = set()
301-
# #521: servers whose advertised tool definitions have been compared against
302-
# the catalog. Cached per server for the same reason provenance is: one
303-
# tools/list round trip per server per session is affordable, one per call
304-
# is not, and a check expensive enough to hurt is a check that gets disabled.
305-
self._drift_checked: set[tuple[str, ...]] = set()
306298
self._catalog_scanner = catalog_scanner
307299
# #625: serialises first-use stdio spawns so two calls racing on the
308300
# same server's first use cannot both spawn a child - see `_stdio_for`.
@@ -328,6 +320,17 @@ def __init__(
328320
self._failed_terminal_call: str | None = None
329321
self._shutting_down = False
330322

323+
def _reset_upstream_checks(self) -> None:
324+
# Drift and provenance share one completed paginated acquisition per
325+
# server/authority per session, including an unchecked (None) outcome.
326+
# These are first-contact observations, not continuous monitoring.
327+
# Replace, rather than clear: an in-flight acquisition retains its old
328+
# cache identity and must retry before returning into a new session.
329+
self._advertised: dict[tuple[str, ...], list[dict[str, Any]] | None] = {}
330+
self._discovery_locks: dict[tuple[str, ...], asyncio.Lock] = {}
331+
self._provenance: dict[tuple[str, ...], ProvenanceResult] = {}
332+
self._drift_checked: set[tuple[str, ...]] = set()
333+
331334
def _ensure_running(self) -> None:
332335
if self._shutting_down:
333336
raise UpstreamUnavailable("gateway is shutting down")
@@ -718,8 +721,7 @@ async def aclose(self) -> None:
718721
"""
719722
stdio_items = tuple(self._stdio_servers.items())
720723
http_items = tuple(self._http_clients.items())
721-
self._provenance.clear()
722-
self._drift_checked.clear()
724+
self._reset_upstream_checks()
723725

724726
if not stdio_items and not http_items:
725727
self._cleanup_incomplete = False
@@ -766,30 +768,54 @@ async def aclose(self) -> None:
766768
self._cleanup_incomplete = False
767769

768770
async def _advertised_tools(self, entry: CatalogEntry) -> list[dict[str, Any]] | None:
769-
"""What the server offers *this gateway*, for the provenance comparison.
771+
"""One first-contact acquisition shared by drift and provenance checks.
770772
771773
Returns ``None`` when the server will not say, which the caller records as
772774
``unchecked`` rather than as a pass. Never falls back to the catalog's own
773775
approved definitions: comparing a record against our approval instead of
774776
against the server is the substitution that turns the check into theatre.
775777
"""
778+
key = _server_provenance_key(entry)
779+
while True:
780+
cache = self._advertised
781+
lock = self._discovery_locks.setdefault(key, asyncio.Lock())
782+
async with lock:
783+
if cache is not self._advertised:
784+
continue
785+
if key in cache:
786+
return cache[key]
787+
advertised = await self._discover_tools(entry)
788+
if cache is not self._advertised:
789+
continue
790+
# Only completed acquisition outcomes are cached. In particular,
791+
# cancellation propagates without storing partial data or None.
792+
cache[key] = advertised
793+
return advertised
794+
795+
async def _discover_tools(self, entry: CatalogEntry) -> list[dict[str, Any]] | None:
796+
"""Acquire the entire listing, or None on an ordinary acquisition failure."""
776797
if entry.server.is_stdio:
777798
return await (await self._stdio_for(entry)).list_tools()
778-
try:
799+
800+
async def fetch_page(request_id: str, params: dict[str, Any]) -> dict[str, Any]:
801+
payload, headers = build_request(request_id, "tools/list", params)
779802
client = self._client_for_upstream(entry)
780-
payload, headers = build_request("provenance-tools-list", "tools/list", {})
781803
resp = await client.post(
782804
entry.server.url,
783805
json=payload,
784806
headers=headers,
785807
)
786808
resp.raise_for_status()
787-
result = parse_response(resp, "provenance-tools-list").get("result")
788-
except Exception as exc: # noqa: BLE001 - any failure means "could not check"
789-
logger.warning("could not list tools for provenance check: %s", exc)
790-
return None
791-
tools = result.get("tools") if isinstance(result, dict) else None
792-
return tools if isinstance(tools, list) else None
809+
return parse_response(resp, request_id)
810+
811+
try:
812+
return await collect_tools(fetch_page)
813+
except DiscoveryError as exc:
814+
logger.warning("tools discovery incomplete: %s", exc)
815+
except Exception: # noqa: BLE001 - any acquisition failure means "could not check"
816+
# Upstream exceptions may contain response bodies or opaque cursors.
817+
logger.warning("tools discovery incomplete: upstream request failed")
818+
return None
793819

794820
async def _check_upstream_drift(self, entry: CatalogEntry) -> bool:
795821
"""Compare what a server advertises against what we approved (P4.2).
@@ -810,14 +836,17 @@ async def _check_upstream_drift(self, entry: CatalogEntry) -> bool:
810836
key = _server_provenance_key(entry)
811837
if key in self._drift_checked:
812838
return self._session.catalog_drift
813-
self._drift_checked.add(key)
814-
815839
advertised = await self._advertised_tools(entry)
840+
# Another caller may have completed the comparison while this one was
841+
# waiting for discovery. An in-flight check is never marked completed.
842+
if key in self._drift_checked:
843+
return self._session.catalog_drift
816844
if advertised is None:
817845
logger.info(
818846
"upstream drift: server=%s outcome=unchecked (server would not list tools)",
819847
key,
820848
)
849+
self._drift_checked.add(key)
821850
return self._session.catalog_drift
822851

823852
by_name = {
@@ -840,6 +869,7 @@ async def _check_upstream_drift(self, entry: CatalogEntry) -> bool:
840869

841870
if not drifted:
842871
logger.info("upstream drift: server=%s outcome=match", key)
872+
self._drift_checked.add(key)
843873
return self._session.catalog_drift
844874

845875
fail_closed = self._config.catalog.drift_policy is DriftPolicy.FAIL_CLOSED
@@ -872,6 +902,7 @@ async def _check_upstream_drift(self, entry: CatalogEntry) -> bool:
872902

873903
if fail_closed:
874904
self._session.catalog_drift = True
905+
self._drift_checked.add(key)
875906
return self._session.catalog_drift
876907

877908
async def _check_provenance(self, entry: CatalogEntry) -> ProvenanceResult:

src/cmcp_runtime/mcp/stdio.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class rather than folded into hardware attestation.
4747
from typing import Any
4848

4949
from cmcp_runtime.errors import ConfigError, UpstreamToolError, UpstreamUnavailable
50+
from cmcp_runtime.mcp.discovery import DiscoveryError, collect_tools
5051

5152
logger = logging.getLogger(__name__)
5253

@@ -244,14 +245,21 @@ async def list_tools(self) -> list[dict[str, Any]] | None:
244245
is one whose provenance could not be checked, and the two are recorded
245246
differently.
246247
"""
248+
async def fetch_page(request_id: str, params: dict[str, Any]) -> dict[str, Any]:
249+
return await self._request(request_id, "tools/list", params)
250+
247251
try:
248-
body = await self._request("provenance-tools-list", "tools/list", {})
249-
except (UpstreamUnavailable, UpstreamToolError) as exc:
250-
logger.warning("could not list tools for provenance check: %s", exc)
251-
return None
252-
result = body.get("result")
253-
tools = result.get("tools") if isinstance(result, dict) else None
254-
return tools if isinstance(tools, list) else None
252+
return await collect_tools(fetch_page)
253+
except asyncio.CancelledError:
254+
# An interrupted read can leave a late reply in the pipe. Do not
255+
# let a later tool call consume it as that call's response.
256+
await self.close()
257+
raise
258+
except DiscoveryError as exc:
259+
logger.warning("tools discovery incomplete: %s", exc)
260+
except (UpstreamUnavailable, UpstreamToolError):
261+
logger.warning("tools discovery incomplete: upstream request failed")
262+
return None
255263

256264
async def call(self, call_id: str, tool_name: str, arguments: dict[str, Any]) -> str:
257265
"""One JSON-RPC ``tools/call`` over the child's stdin/stdout."""

0 commit comments

Comments
 (0)