"""Legacy-wire protection: a 2025-era streamable-HTTP exchange stays free of 2026 vocabulary. Records a full SDK client -> SDK server round trip at both seams (HTTP request/response headers via httpx event hooks; JSON-RPC frames in both directions via the recording transport) and runs the result through :func:`tests.interaction._modern_vocab.assert_no_modern_vocabulary`. The test pins today's wire so any future 2026-07-28 work that leaks new fields, `_meta` keys, or headers onto a connection negotiated at the current protocol version fails here. """ import httpx import pytest from inline_snapshot import snapshot from mcp_types import ( CallToolRequestParams, CallToolResult, ListToolsResult, PaginatedRequestParams, TextContent, Tool, ) from mcp.client.client import Client from mcp.client.streamable_http import streamable_http_client from mcp.server import Server, ServerRequestContext from mcp.shared.message import SessionMessage from tests.interaction._connect import BASE_URL, mounted_app from tests.interaction._helpers import RecordingTransport from tests.interaction._modern_vocab import RecordedExchange, assert_no_modern_vocabulary from tests.interaction._requirements import requirement pytestmark = pytest.mark.anyio def _server() -> Server: """A low-level server with one echo tool, so the recorded exchange covers tools/list and tools/call.""" async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult: return ListToolsResult(tools=[Tool(name="echo", description="Echo text.", input_schema={"type": "object"})]) async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult: assert params.name == "echo" assert params.arguments is not None return CallToolResult(content=[TextContent(text=str(params.arguments["text"]))]) return Server("legacy", on_list_tools=list_tools, on_call_tool=call_tool) @requirement("hosting:http:legacy-no-modern-vocabulary") async def test_legacy_streamable_http_exchange_carries_no_modern_protocol_vocabulary() -> None: """A 2025-era client/server round trip emits none of the 2026-07-28 wire vocabulary. SDK-defined under the draft versioning rules: pins the current wire so future 2026 work cannot leak `resultType` / `ttlMs` / `cacheScope`, `io.modelcontextprotocol/*` `_meta` keys, the `2026-07-28` literal, or `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers onto a connection negotiated at the current protocol version. Recorded at the HTTP seam (every request and response header) and the transport seam (every JSON-RPC frame in either direction); the SDK client never exposes either, so the assertion is necessarily wire-level. """ recorded = RecordedExchange(requests=[], responses=[], frames=[]) async def on_request(request: httpx.Request) -> None: recorded.requests.append(request) async def on_response(response: httpx.Response) -> None: recorded.responses.append(response) async with mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _): recording = RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http)) async with Client(recording, mode="legacy") as client: result = await client.call_tool("echo", {"text": "legacy"}) assert result == snapshot(CallToolResult(content=[TextContent(text="legacy")])) recorded.frames.extend(m.message for m in recording.sent) recorded.frames.extend(m.message for m in recording.received if isinstance(m, SessionMessage)) # The handshake, the implicit tools/list (output-schema cache), tools/call, the standalone GET # stream, and the closing DELETE all crossed the HTTP seam; the transport seam saw a JSON-RPC # frame for each direction of each. Asserting non-empty so the vocabulary scan cannot pass on # nothing recorded. assert {r.method for r in recorded.requests} == snapshot({"POST", "GET", "DELETE"}) assert len(recorded.responses) == len(recorded.requests) assert len(recorded.frames) >= 6 assert_no_modern_vocabulary(recorded)