Resolve protocol version per request and expose it as ctx.protocol_version (#2886)

This commit is contained in:
Max 2026-06-17 08:46:42 +01:00 • committed by GitHub
commit 734746a3d9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 175 additions and 28 deletions

View file

@ -21,6 +21,7 @@ async def test_progress_token_zero_first_call():
session=mock_session,
meta={"progress_token": 0},
lifespan_context=None,
protocol_version="2025-11-25",
)
# Create context with our mocks

View file

@ -1501,6 +1501,7 @@ async def test_report_progress_passes_related_request_id():
session=mock_session,
meta={"progress_token": "tok-1"},
lifespan_context=None,
protocol_version="2025-11-25",
)
ctx = Context(request_context=request_context, mcp_server=MagicMock())

View file

@ -18,11 +18,12 @@ import mcp.server.runner
from mcp.server.context import ServerRequestContext
from mcp.server.lowlevel.server import NotificationOptions, Server
from mcp.server.models import InitializationOptions
from mcp.server.runner import ServerRunner, _extract_meta, otel_middleware
from mcp.server.runner import ServerRunner, _extract_meta, _resolve_protocol_version, otel_middleware
from mcp.server.session import ServerSession
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
from mcp.shared.exceptions import MCPError
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata
from mcp.shared.peer import dump_params
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
@ -31,6 +32,7 @@ from mcp.types import (
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
CallToolRequestParams,
ClientCapabilities,
ErrorData,
@ -41,6 +43,7 @@ from mcp.types import (
PaginatedRequestParams,
ProgressNotificationParams,
RequestParams,
RequestParamsMeta,
SetLevelRequestParams,
Tool,
)
@ -218,6 +221,7 @@ async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
assert isinstance(ctx.session, ServerSession)
assert ctx.session is runner.session
assert ctx.request_id is not None
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
@pytest.mark.anyio
@ -650,6 +654,65 @@ async def test_runner_server_middleware_wraps_notifications(server: SrvT):
]
def test_resolve_protocol_version_handshake_committed_value_wins():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-03-26"}
assert _resolve_protocol_version("2025-06-18", meta, md) == "2025-06-18"
def test_resolve_protocol_version_reads_per_request_meta_when_no_handshake():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-06-18"}
assert _resolve_protocol_version(None, meta, md) == "2025-06-18"
def test_resolve_protocol_version_skips_unsupported_meta_value():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "1900-01-01"}
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
def test_resolve_protocol_version_skips_non_string_meta_value():
md = ServerMessageMetadata(protocol_version="2025-03-26")
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: 42}
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
def test_resolve_protocol_version_reads_transport_hint_when_no_handshake_or_meta():
md = ServerMessageMetadata(protocol_version="2025-06-18")
assert _resolve_protocol_version(None, None, md) == "2025-06-18"
assert _resolve_protocol_version(None, {}, md) == "2025-06-18"
def test_resolve_protocol_version_skips_unsupported_transport_hint():
"""The `initialize` params version reaches the metadata unvalidated; surface validation must never see it."""
md = ServerMessageMetadata(protocol_version="1900-01-01")
assert _resolve_protocol_version(None, None, md) == "2025-11-25"
def test_resolve_protocol_version_terminal_default_with_no_signals():
assert _resolve_protocol_version(None, None, None) == "2025-11-25"
assert _resolve_protocol_version(None, None, ServerMessageMetadata()) == "2025-11-25"
assert _resolve_protocol_version(None, None, ClientMessageMetadata()) == "2025-11-25"
@pytest.mark.anyio
async def test_runner_ctx_protocol_version_is_terminal_default_on_stateless_in_memory(server: SrvT):
async with connected_runner(server, initialized=False, stateless=True) as (client, runner):
await client.send_raw_request("tools/list", None)
ctx = _seen_ctx[0]
assert ctx.protocol_version == "2025-11-25"
assert ctx.session.protocol_version is None
assert runner.connection.protocol_version is None
@pytest.mark.anyio
async def test_runner_ctx_protocol_version_tracks_per_request_meta_on_stateless(server: SrvT):
async with connected_runner(server, initialized=False, stateless=True) as (client, _):
await client.send_raw_request("tools/list", {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-06-18"}})
assert _seen_ctx[0].protocol_version == "2025-06-18"
def test_extract_meta_returns_none_for_absent_or_malformed():
"""Context construction is independent of `_meta` validity; the params
validation inside `call_next()` is what surfaces the error."""

View file

@ -49,6 +49,7 @@ from mcp.shared._context_streams import create_context_streams
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata, SessionMessage
from mcp.shared.session import RequestResponder
from mcp.types import (
DEFAULT_NEGOTIATED_VERSION,
CallToolRequestParams,
CallToolResult,
InitializeResult,
@ -81,14 +82,18 @@ BASE_URL = "http://127.0.0.1:8000"
# Helper functions
def extract_protocol_version_from_sse(response: httpx.Response) -> str:
"""Extract the negotiated protocol version from an SSE initialization response."""
def first_sse_data(response: httpx.Response) -> dict[str, Any]:
"""Return the first SSE `data:` payload of a response, parsed as JSON."""
assert response.headers.get("Content-Type") == "text/event-stream"
for line in response.text.splitlines():
if line.startswith("data: "):
init_data = json.loads(line[6:])
return init_data["result"]["protocolVersion"]
raise ValueError("Could not extract protocol version from SSE response") # pragma: no cover
return json.loads(line.removeprefix("data: "))
raise ValueError("No data event in SSE response") # pragma: no cover
def extract_protocol_version_from_sse(response: httpx.Response) -> str:
"""Extract the negotiated protocol version from an SSE initialization response."""
return first_sse_data(response)["result"]["protocolVersion"]
# Simple in-memory event store for testing
@ -1318,13 +1323,14 @@ async def _handle_context_call_tool(ctx: ServerRequestContext, params: CallToolR
"headers": dict(ctx.request.headers),
"method": ctx.request.method,
"path": ctx.request.url.path,
"protocol_version": ctx.protocol_version,
"session_protocol_version": ctx.session.protocol_version,
}
return CallToolResult(content=[TextContent(type="text", text=json.dumps(context_data))])
@pytest.fixture
async def context_app() -> AsyncIterator[Starlette]:
"""An app whose server echoes request context, served in process."""
@asynccontextmanager
async def _run_context_app(*, stateless: bool) -> AsyncIterator[Starlette]:
server = Server(
"ContextAwareServer",
on_list_tools=_handle_context_list_tools,
@ -1332,6 +1338,7 @@ async def context_app() -> AsyncIterator[Starlette]:
)
session_manager = StreamableHTTPSessionManager(
app=server,
stateless=stateless,
security_settings=TransportSecuritySettings(enable_dns_rebinding_protection=False),
)
app = Starlette(routes=[Mount("/mcp", app=session_manager.handle_request)])
@ -1339,6 +1346,51 @@ async def context_app() -> AsyncIterator[Starlette]:
yield app
@pytest.fixture
async def context_app() -> AsyncIterator[Starlette]:
"""An app whose server echoes request context, served in process."""
async with _run_context_app(stateless=False) as app:
yield app
@pytest.fixture
async def stateless_context_app() -> AsyncIterator[Starlette]:
async with _run_context_app(stateless=True) as app:
yield app
@pytest.mark.anyio
@pytest.mark.parametrize(
("header_value", "expected"),
[
("2025-06-18", "2025-06-18"),
("2025-11-25", "2025-11-25"),
(None, DEFAULT_NEGOTIATED_VERSION),
],
)
async def test_streamablehttp_stateless_ctx_protocol_version_tracks_the_header(
stateless_context_app: Starlette, header_value: str | None, expected: str
) -> None:
"""No handshake on stateless: the header (or the spec's 2025-03-26 default) reaches `ctx.protocol_version`."""
body = JSONRPCRequest(
jsonrpc="2.0",
id=1,
method="tools/call",
params={"name": "echo_context", "arguments": {"request_id": "r"}},
)
headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
if header_value is not None:
headers[MCP_PROTOCOL_VERSION_HEADER] = header_value
async with make_client(stateless_context_app) as client:
response = await client.post(
f"{BASE_URL}/mcp", json=body.model_dump(by_alias=True, exclude_none=True), headers=headers
)
assert response.status_code == 200
echoed = json.loads(first_sse_data(response)["result"]["content"][0]["text"])
assert echoed["protocol_version"] == expected
assert echoed["session_protocol_version"] is None
@pytest.mark.anyio
async def test_streamablehttp_request_context_propagation(context_app: Starlette) -> None:
"""Custom HTTP headers on the connection are visible to server handlers via ctx.request."""