49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
79 lines
3.5 KiB
Python
79 lines
3.5 KiB
Python
"""Guard against 2026-era protocol vocabulary leaking onto legacy (2025-era) exchanges.
|
|
|
|
The 2026-07-28 spec revision introduces wire vocabulary that did not exist before it --
|
|
result-envelope fields (`resultType`, `ttlMs`, `cacheScope`), namespaced
|
|
`io.modelcontextprotocol/*` `_meta` keys, the version literal itself, and the per-request HTTP
|
|
headers `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*`. None of that may appear on a connection
|
|
negotiated at an earlier protocol version: a test that records a plain legacy round trip and
|
|
runs it through :func:`assert_no_modern_vocabulary` will start failing the moment a 2026 change
|
|
leaks onto the existing wire.
|
|
|
|
Tests construct a :class:`RecordedExchange` from whatever instrumentation they have to hand --
|
|
the `on_request` / `on_response` hooks on :func:`tests.interaction._connect.mounted_app` for the
|
|
HTTP seam, and :class:`tests.interaction._helpers.RecordingTransport` for the JSON-RPC frames --
|
|
and pass it to the assertion. The helper scans header names and serialised bodies; it makes no
|
|
assumptions about which side produced what.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
import httpx
|
|
from mcp_types import JSONRPCMessage, jsonrpc_message_adapter
|
|
|
|
#: Substrings that must not appear anywhere in a request body or JSON-RPC frame on a legacy
|
|
#: exchange. Matching is by raw substring against the by-alias JSON serialisation, so a leaked
|
|
#: field name, `_meta` key prefix, or version literal is caught regardless of where in the
|
|
#: payload it sits.
|
|
MODERN_BODY_TOKENS: frozenset[str] = frozenset(
|
|
{
|
|
"resultType",
|
|
"ttlMs",
|
|
"cacheScope",
|
|
"io.modelcontextprotocol/",
|
|
"2026-07-28",
|
|
}
|
|
)
|
|
|
|
#: Lower-cased HTTP header names introduced by the 2026-07-28 transport.
|
|
MODERN_HEADER_NAMES: frozenset[str] = frozenset({"mcp-method", "mcp-name"})
|
|
|
|
#: Lower-cased prefix for the 2026-07-28 per-parameter header family.
|
|
MODERN_HEADER_PREFIX = "mcp-param-"
|
|
|
|
|
|
@dataclass
|
|
class RecordedExchange:
|
|
"""Everything a test captured from one streamable-HTTP conversation, for vocabulary scanning.
|
|
|
|
`requests` and `responses` are inspected for header names and (for requests) body bytes;
|
|
`frames` are re-serialised to their wire JSON and scanned as body text. Response bodies are
|
|
not read here -- streamable-HTTP responses are SSE streams that are consumed elsewhere -- so
|
|
the server-to-client body content must be supplied via `frames`.
|
|
"""
|
|
|
|
requests: list[httpx.Request]
|
|
responses: list[httpx.Response]
|
|
frames: list[JSONRPCMessage]
|
|
|
|
|
|
def assert_no_modern_vocabulary(recorded: RecordedExchange) -> None:
|
|
"""Fail if any 2026-era header name or body token appears anywhere in `recorded`.
|
|
|
|
All findings are collected before asserting so a single failure reports every leak.
|
|
"""
|
|
header_names = [name.lower() for request in recorded.requests for name in request.headers]
|
|
header_names += [name.lower() for response in recorded.responses for name in response.headers]
|
|
leaked = [
|
|
f"header {name!r}"
|
|
for name in header_names
|
|
if name in MODERN_HEADER_NAMES or name.startswith(MODERN_HEADER_PREFIX)
|
|
]
|
|
|
|
corpus = b"".join(request.content for request in recorded.requests).decode()
|
|
corpus += "".join(
|
|
jsonrpc_message_adapter.dump_json(frame, by_alias=True, exclude_none=True).decode() for frame in recorded.frames
|
|
)
|
|
leaked.extend(f"body token {token!r}" for token in MODERN_BODY_TOKENS if token in corpus)
|
|
|
|
assert not leaked, f"Modern (2026-07-28) protocol vocabulary on a legacy exchange: {leaked}"
|