Files
modelcontextprotocol--pytho…/tests/interaction/transports/test_legacy_wire.py
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

86 lines
4.1 KiB
Python

"""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)