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
86 lines
4.1 KiB
Python
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)
|