From 2105980b2da9d59cdeb8956049d3aaa89b75afc8 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:53:56 +0000 Subject: [PATCH 1/4] Resolve protocol version per request and expose it as ctx.protocol_version The runner's version-keyed surface validation (added in #2849) needs a version on every request, but Connection.protocol_version is set only by the initialize handshake and so stays None on stateless streamable-HTTP connections. The transport already reads and validates the MCP-Protocol-Version header per request but only uses it for SSE priming/close-callback gating. This threads that value through to the runner via a new optional ServerMessageMetadata.protocol_version field and replaces the hard-coded '2025-11-25' fallback in _on_request/_on_notify with a single _resolve_protocol_version() helper: a handshake-committed value governs the whole connection when present; otherwise per-request signals apply (_meta then the transport hint), then the literal 2025-11-25 terminal default. The result is also exposed to handlers as ServerRequestContext.protocol_version (always set). Connection.protocol_version and ServerSession.protocol_version keep their meaning (handshake result, None if no handshake); their docstrings now point at ctx.protocol_version for the per-request value. --- docs/migration.md | 4 +- src/mcp/server/connection.py | 4 +- src/mcp/server/context.py | 11 +++- src/mcp/server/runner.py | 49 ++++++++++++---- src/mcp/server/session.py | 7 ++- src/mcp/server/streamable_http.py | 10 +++- src/mcp/shared/message.py | 4 ++ tests/server/test_runner.py | 84 +++++++++++++++++++++++++++- tests/shared/test_streamable_http.py | 71 ++++++++++++++++++++--- 9 files changed, 216 insertions(+), 28 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index bd70bca995..c9f585f4c7 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -674,7 +674,9 @@ ctx: ClientRequestContext server_ctx: ServerRequestContext[LifespanContextT, RequestT] ``` -`ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`), so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`. +`ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) plus a new `protocol_version: str` field, so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`. + +`ctx.protocol_version` is the protocol version this request is being served at. It is always set, including on stateless connections, so prefer it over `ctx.session.protocol_version` (which is the `initialize`-negotiated value only and is `None` when no handshake has run). The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient: diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index 8f72962023..dc22705df9 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -87,7 +87,9 @@ class Connection: """The protocol version negotiated during `initialize`; `None` before initialization. Stateless connections don't require the handshake, so this normally stays `None` there (a client that sends `initialize` anyway still - commits it). Handlers read this as `ServerSession.protocol_version`.""" + commits it). For the version that applies to the current request - which is + always set, including on stateless connections - read + `ctx.protocol_version` instead.""" initialized: anyio.Event """Set when `notifications/initialized` arrives (matches TS `oninitialized`); diff --git a/src/mcp/server/context.py b/src/mcp/server/context.py index 61003ac9f8..14990445c9 100644 --- a/src/mcp/server/context.py +++ b/src/mcp/server/context.py @@ -12,7 +12,7 @@ from mcp.shared.message import CloseSSEStreamCallback from mcp.shared.peer import Meta from mcp.shared.transport_context import TransportContext -from mcp.types import LoggingLevel, RequestId, RequestParamsMeta +from mcp.types import LATEST_PROTOCOL_VERSION, LoggingLevel, RequestId, RequestParamsMeta # Invariant: parameterizes a mutable dataclass field; dict default matches the default lifespan. LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any]) @@ -31,6 +31,15 @@ class ServerRequestContext(Generic[LifespanContextT, RequestT]): session: ServerSession lifespan_context: LifespanContextT + protocol_version: str = LATEST_PROTOCOL_VERSION + """The protocol version this request is being served at. + + Always set. Resolved per request from the handshake-committed value, the + request's `_meta`, or the transport's per-message hint (the + `MCP-Protocol-Version` header on streamable HTTP). Prefer this over + `ctx.session.protocol_version`, which is the handshake result only and is + `None` on stateless connections. + """ request_id: RequestId | None = None meta: RequestParamsMeta | None = None request: RequestT | None = None diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index e65b2e68ca..ec7b533b15 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -34,7 +34,7 @@ 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 ServerMessageMetadata +from mcp.shared.message import MessageMetadata, ServerMessageMetadata from mcp.shared.transport_context import TransportContext from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS from mcp.types import ( @@ -42,6 +42,7 @@ INVALID_PARAMS, LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, ErrorData, Implementation, InitializeRequestParams, @@ -79,6 +80,37 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None: return None +def _resolve_protocol_version( + negotiated: str | None, + params: Mapping[str, Any] | None, + md: MessageMetadata, +) -> str: + """Resolve the protocol version that applies to this inbound message. + + A handshake-committed value governs the whole connection when present. + Otherwise the per-request signals apply: `_meta` (2026-07-28+), then the + transport's hint (the validated `MCP-Protocol-Version` header on + streamable HTTP). Values outside `SUPPORTED_PROTOCOL_VERSIONS` are + skipped so an unrecognized declaration falls through rather than poisons + surface validation. The literal terminal default is the last + handshake-based revision and is fixed regardless of LATEST bumps. + """ + if negotiated is not None: + return negotiated + match params: + case {"_meta": {**meta}}: + v = meta.get(PROTOCOL_VERSION_META_KEY) + if isinstance(v, str) and v in SUPPORTED_PROTOCOL_VERSIONS: + return v + case _: + pass + if isinstance(md, ServerMessageMetadata): + hint = md.protocol_version + if hint is not None and hint in SUPPORTED_PROTOCOL_VERSIONS: + return hint + return "2025-11-25" + + def otel_middleware(next_on_request: OnRequest) -> OnRequest: """Dispatch-tier middleware that wraps each request in an OpenTelemetry span. @@ -218,11 +250,8 @@ async def _on_request( method: str, params: Mapping[str, Any] | None, ) -> dict[str, Any]: - ctx = self._make_context(dctx, _extract_meta(params)) - # Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize - # handshake (which only exists at <=2025) and stateless until the header - # is plumbed; its meaning is fixed regardless of LATEST bumps. - version = self.connection.protocol_version or "2025-11-25" + version = _resolve_protocol_version(self.connection.protocol_version, params, dctx.message_metadata) + ctx = self._make_context(dctx, _extract_meta(params), version) is_spec_method = method in _methods.SPEC_CLIENT_METHODS async def _inner() -> HandlerResult: @@ -289,9 +318,8 @@ async def _on_notify( method: str, params: Mapping[str, Any] | None, ) -> None: - ctx = self._make_context(dctx, _extract_meta(params)) - # Same fallback as `_on_request`: covers pre-handshake and stateless. - version = self.connection.protocol_version or "2025-11-25" + version = _resolve_protocol_version(self.connection.protocol_version, params, dctx.message_metadata) + ctx = self._make_context(dctx, _extract_meta(params), version) async def _inner() -> None: if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS: @@ -349,7 +377,7 @@ def _compose_server_middleware( return call def _make_context( - self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None + self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None, protocol_version: str ) -> ServerRequestContext[LifespanT, Any]: # TODO(maxisbey): remove for Context rework. Reads the SHTTP per-request # data off the raw `dctx.message_metadata` carrier; replace with the @@ -366,6 +394,7 @@ def _make_context( lifespan_context=self.lifespan_state, request_id=dctx.request_id, meta=meta, + protocol_version=protocol_version, request=request, close_sse_stream=close_sse_stream, close_standalone_sse_stream=close_standalone_sse_stream, diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index 2dba81abec..bf0fefe7a4 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -56,9 +56,10 @@ def protocol_version(self) -> str | None: """The protocol version negotiated during `initialize`. `None` before initialization completes. Stateless connections don't - require the handshake, so this is normally `None` there (on streamable - HTTP the per-request version is the `MCP-Protocol-Version` header, - available via `ctx.request.headers`). + require the handshake, so this is normally `None` there. For the + version that applies to the current request - which is always set, + including on stateless connections - read `ctx.protocol_version` + instead. """ return self._connection.protocol_version diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 93904d6cc1..9103996a52 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -248,11 +248,12 @@ async def close_standalone_stream_callback() -> None: metadata = ServerMessageMetadata( request_context=request, + protocol_version=protocol_version, close_sse_stream=close_stream_callback, close_standalone_sse_stream=close_standalone_stream_callback, ) else: - metadata = ServerMessageMetadata(request_context=request) + metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version) return SessionMessage(message, metadata=metadata) @@ -506,7 +507,10 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re await response(scope, receive, send) # Process the message after sending the response - metadata = ServerMessageMetadata(request_context=request) + metadata = ServerMessageMetadata( + request_context=request, + protocol_version=request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION), + ) session_message = SessionMessage(message, metadata=metadata) await writer.send(session_message) @@ -529,7 +533,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re if self.is_json_response_enabled: # Process the message - metadata = ServerMessageMetadata(request_context=request) + metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version) session_message = SessionMessage(message, metadata=metadata) await writer.send(session_message) try: diff --git a/src/mcp/shared/message.py b/src/mcp/shared/message.py index 1858eeac31..27deb47ba1 100644 --- a/src/mcp/shared/message.py +++ b/src/mcp/shared/message.py @@ -35,6 +35,10 @@ class ServerMessageMetadata: # transports, None for stdio). Typed as Any because the server layer is # transport-agnostic. request_context: Any = None + # Protocol version the transport derived for this inbound message (the + # validated `MCP-Protocol-Version` header on streamable HTTP). `None` when + # the transport has no per-message version signal. + protocol_version: str | None = None # Callback to close SSE stream for the current request without terminating close_sse_stream: CloseSSEStreamCallback | None = None # Callback to close the standalone GET SSE stream (for unsolicited notifications) diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index bc298185cc..a82847ac11 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -18,11 +18,12 @@ 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 @@ INVALID_PARAMS, LATEST_PROTOCOL_VERSION, METHOD_NOT_FOUND, + PROTOCOL_VERSION_META_KEY, CallToolRequestParams, ClientCapabilities, ErrorData, @@ -218,6 +220,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 +653,85 @@ async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None: ] +def test_resolve_protocol_version_handshake_committed_value_wins(): + """A handshake-committed version governs the whole connection; per-request + `_meta` and the transport hint are ignored.""" + md = ServerMessageMetadata(protocol_version="2025-03-26") + params = {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-03-26"}} + assert _resolve_protocol_version("2025-06-18", params, md) == "2025-06-18" + + +def test_resolve_protocol_version_reads_per_request_meta_when_no_handshake(): + """With no handshake, a supported `_meta` value is the answer (even with a + transport hint also present).""" + md = ServerMessageMetadata(protocol_version="2025-03-26") + params = {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-06-18"}} + assert _resolve_protocol_version(None, params, md) == "2025-06-18" + + +def test_resolve_protocol_version_skips_unsupported_meta_value(): + """A `_meta` value the SDK does not serve falls through to the transport + hint rather than poisoning surface validation.""" + md = ServerMessageMetadata(protocol_version="2025-03-26") + params = {"_meta": {PROTOCOL_VERSION_META_KEY: "1900-01-01"}} + assert _resolve_protocol_version(None, params, md) == "2025-03-26" + + +def test_resolve_protocol_version_skips_non_string_meta_value(): + md = ServerMessageMetadata(protocol_version="2025-03-26") + params: dict[str, Any] = {"_meta": {PROTOCOL_VERSION_META_KEY: 42}} + assert _resolve_protocol_version(None, params, md) == "2025-03-26" + + +def test_resolve_protocol_version_reads_transport_hint_when_no_handshake_or_meta(): + """The streamable-HTTP header path: stateless connection, no `_meta`.""" + md = ServerMessageMetadata(protocol_version="2025-06-18") + assert _resolve_protocol_version(None, {"name": "x"}, md) == "2025-06-18" + assert _resolve_protocol_version(None, None, md) == "2025-06-18" + + +def test_resolve_protocol_version_skips_unsupported_transport_hint(): + """The transport may pass through an unvalidated value (the raw `initialize` + params version on streamable HTTP); a value the SDK does not serve falls + through to the terminal default.""" + 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(): + """stdio and in-memory transports attach no metadata; before the handshake + only `ping` and `initialize` reach the runner, and both exist at the last + handshake-based revision.""" + assert _resolve_protocol_version(None, None, None) == "2025-11-25" + assert _resolve_protocol_version(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" + + +def test_resolve_protocol_version_ignores_non_mapping_meta(): + assert _resolve_protocol_version(None, {"_meta": "oops"}, None) == "2025-11-25" + + +@pytest.mark.anyio +async def test_runner_ctx_protocol_version_is_terminal_default_on_stateless_in_memory(server: SrvT): + """In-memory transport attaches no per-message metadata, so on a stateless + connection `ctx.protocol_version` is the resolver's terminal default while + the handshake-only `ctx.session.protocol_version` stays `None`.""" + 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.""" diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 02976656e8..61116dff6d 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -49,6 +49,7 @@ 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 @@ # 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,54 @@ 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: + """A stateless server has no handshake-committed version; the validated + `MCP-Protocol-Version` header reaches the handler as `ctx.protocol_version` + (with the spec's `2025-03-26` default when absent) while the handshake-only + `ctx.session.protocol_version` stays `None`.""" + 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.""" From 96a71b972d427ee7ea1a3f909e7ddd665e01ae4b Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:02:07 +0000 Subject: [PATCH 2/4] Make ServerRequestContext.protocol_version required (no default) LATEST_PROTOCOL_VERSION as a default would have been wrong once it bumps to a modern-era revision; the field is always set by the runner so forcing direct constructors to supply it is the honest contract. --- src/mcp/server/context.py | 12 ++---------- tests/issues/test_176_progress_token.py | 1 + tests/server/mcpserver/test_server.py | 1 + 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/src/mcp/server/context.py b/src/mcp/server/context.py index 14990445c9..b7effb70f3 100644 --- a/src/mcp/server/context.py +++ b/src/mcp/server/context.py @@ -12,7 +12,7 @@ from mcp.shared.message import CloseSSEStreamCallback from mcp.shared.peer import Meta from mcp.shared.transport_context import TransportContext -from mcp.types import LATEST_PROTOCOL_VERSION, LoggingLevel, RequestId, RequestParamsMeta +from mcp.types import LoggingLevel, RequestId, RequestParamsMeta # Invariant: parameterizes a mutable dataclass field; dict default matches the default lifespan. LifespanContextT = TypeVar("LifespanContextT", default=dict[str, Any]) @@ -31,15 +31,7 @@ class ServerRequestContext(Generic[LifespanContextT, RequestT]): session: ServerSession lifespan_context: LifespanContextT - protocol_version: str = LATEST_PROTOCOL_VERSION - """The protocol version this request is being served at. - - Always set. Resolved per request from the handshake-committed value, the - request's `_meta`, or the transport's per-message hint (the - `MCP-Protocol-Version` header on streamable HTTP). Prefer this over - `ctx.session.protocol_version`, which is the handshake result only and is - `None` on stateless connections. - """ + protocol_version: str request_id: RequestId | None = None meta: RequestParamsMeta | None = None request: RequestT | None = None diff --git a/tests/issues/test_176_progress_token.py b/tests/issues/test_176_progress_token.py index bef44928ac..ddd9c67c1d 100644 --- a/tests/issues/test_176_progress_token.py +++ b/tests/issues/test_176_progress_token.py @@ -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 diff --git a/tests/server/mcpserver/test_server.py b/tests/server/mcpserver/test_server.py index 60d30342c4..6ec060d20b 100644 --- a/tests/server/mcpserver/test_server.py +++ b/tests/server/mcpserver/test_server.py @@ -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()) From 0ae934a04c68233ab13ae236fed42ef63c4262bc Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:05:45 +0000 Subject: [PATCH 3/4] Drop ctx.protocol_version usage note from migration.md (additive, not a migration step) --- docs/migration.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/migration.md b/docs/migration.md index c9f585f4c7..d13b164aa1 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -676,8 +676,6 @@ server_ctx: ServerRequestContext[LifespanContextT, RequestT] `ServerRequestContext` is now a standalone dataclass — it no longer subclasses `RequestContext[ServerSession]`. It carries the same fields (`session`, `request_id`, `meta`, `lifespan_context`, `request`, `close_sse_stream`, `close_standalone_sse_stream`) plus a new `protocol_version: str` field, so handler code is unaffected, but `isinstance(ctx, RequestContext)` checks and `RequestContext[ServerSession]` annotations need updating to `ServerRequestContext`. -`ctx.protocol_version` is the protocol version this request is being served at. It is always set, including on stateless connections, so prefer it over `ctx.session.protocol_version` (which is the `initialize`-negotiated value only and is `None` when no handshake has run). - The high-level `Context` class (injected into `@mcp.tool()` etc.) similarly dropped its `ServerSessionT` parameter: `Context[ServerSessionT, LifespanContextT, RequestT]` → `Context[LifespanContextT, RequestT]`. Both remaining parameters have defaults, so bare `Context` is usually sufficient: **Before (v1):** From 61dd41b72c54ec2b5191de89f085f07e0dd4b13a Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:53:40 +0000 Subject: [PATCH 4/4] Resolver takes the extracted _meta directly; trim docstrings The match-on-params form tripped a 3.10-only branch-coverage quirk on the case-body fall-through arc. Reading the already-extracted RequestParamsMeta avoids the match, drops the per-message dict copy, and keeps a single _meta extraction per request. Docstrings/comments added in this PR trimmed to one-liners or dropped where the test/function name already says it. --- src/mcp/server/connection.py | 4 +-- src/mcp/server/runner.py | 37 +++++++++++-------------- src/mcp/server/session.py | 7 ++--- src/mcp/shared/message.py | 5 ++-- tests/server/test_runner.py | 41 ++++++++-------------------- tests/shared/test_streamable_http.py | 5 +--- 6 files changed, 33 insertions(+), 66 deletions(-) diff --git a/src/mcp/server/connection.py b/src/mcp/server/connection.py index dc22705df9..e0f406a200 100644 --- a/src/mcp/server/connection.py +++ b/src/mcp/server/connection.py @@ -87,9 +87,7 @@ class Connection: """The protocol version negotiated during `initialize`; `None` before initialization. Stateless connections don't require the handshake, so this normally stays `None` there (a client that sends `initialize` anyway still - commits it). For the version that applies to the current request - which is - always set, including on stateless connections - read - `ctx.protocol_version` instead.""" + commits it). For the per-request value, read `ctx.protocol_version`.""" initialized: anyio.Event """Set when `notifications/initialized` arrives (matches TS `oninitialized`); diff --git a/src/mcp/server/runner.py b/src/mcp/server/runner.py index ec7b533b15..8dd9a2fac0 100644 --- a/src/mcp/server/runner.py +++ b/src/mcp/server/runner.py @@ -82,28 +82,21 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None: def _resolve_protocol_version( negotiated: str | None, - params: Mapping[str, Any] | None, + meta: RequestParamsMeta | None, md: MessageMetadata, ) -> str: - """Resolve the protocol version that applies to this inbound message. - - A handshake-committed value governs the whole connection when present. - Otherwise the per-request signals apply: `_meta` (2026-07-28+), then the - transport's hint (the validated `MCP-Protocol-Version` header on - streamable HTTP). Values outside `SUPPORTED_PROTOCOL_VERSIONS` are - skipped so an unrecognized declaration falls through rather than poisons - surface validation. The literal terminal default is the last - handshake-based revision and is fixed regardless of LATEST bumps. + """Resolve the protocol version for this inbound message. + + Handshake-committed value wins; else per-request `_meta`, else the + transport hint. Unsupported values fall through so surface validation + never sees them. """ if negotiated is not None: return negotiated - match params: - case {"_meta": {**meta}}: - v = meta.get(PROTOCOL_VERSION_META_KEY) - if isinstance(v, str) and v in SUPPORTED_PROTOCOL_VERSIONS: - return v - case _: - pass + if meta is not None: + v = meta.get(PROTOCOL_VERSION_META_KEY) + if isinstance(v, str) and v in SUPPORTED_PROTOCOL_VERSIONS: + return v if isinstance(md, ServerMessageMetadata): hint = md.protocol_version if hint is not None and hint in SUPPORTED_PROTOCOL_VERSIONS: @@ -250,8 +243,9 @@ async def _on_request( method: str, params: Mapping[str, Any] | None, ) -> dict[str, Any]: - version = _resolve_protocol_version(self.connection.protocol_version, params, dctx.message_metadata) - ctx = self._make_context(dctx, _extract_meta(params), version) + meta = _extract_meta(params) + version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata) + ctx = self._make_context(dctx, meta, version) is_spec_method = method in _methods.SPEC_CLIENT_METHODS async def _inner() -> HandlerResult: @@ -318,8 +312,9 @@ async def _on_notify( method: str, params: Mapping[str, Any] | None, ) -> None: - version = _resolve_protocol_version(self.connection.protocol_version, params, dctx.message_metadata) - ctx = self._make_context(dctx, _extract_meta(params), version) + meta = _extract_meta(params) + version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata) + ctx = self._make_context(dctx, meta, version) async def _inner() -> None: if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS: diff --git a/src/mcp/server/session.py b/src/mcp/server/session.py index bf0fefe7a4..6254a01ee5 100644 --- a/src/mcp/server/session.py +++ b/src/mcp/server/session.py @@ -55,11 +55,8 @@ def client_params(self) -> types.InitializeRequestParams | None: def protocol_version(self) -> str | None: """The protocol version negotiated during `initialize`. - `None` before initialization completes. Stateless connections don't - require the handshake, so this is normally `None` there. For the - version that applies to the current request - which is always set, - including on stateless connections - read `ctx.protocol_version` - instead. + `None` before initialization, and normally `None` on stateless + connections. For the per-request value, read `ctx.protocol_version`. """ return self._connection.protocol_version diff --git a/src/mcp/shared/message.py b/src/mcp/shared/message.py index 27deb47ba1..dba263ad5a 100644 --- a/src/mcp/shared/message.py +++ b/src/mcp/shared/message.py @@ -35,9 +35,8 @@ class ServerMessageMetadata: # transports, None for stdio). Typed as Any because the server layer is # transport-agnostic. request_context: Any = None - # Protocol version the transport derived for this inbound message (the - # validated `MCP-Protocol-Version` header on streamable HTTP). `None` when - # the transport has no per-message version signal. + # Per-message protocol version observed by the transport (e.g. the + # validated MCP-Protocol-Version header). protocol_version: str | None = None # Callback to close SSE stream for the current request without terminating close_sse_stream: CloseSSEStreamCallback | None = None diff --git a/tests/server/test_runner.py b/tests/server/test_runner.py index a82847ac11..5d61a676a8 100644 --- a/tests/server/test_runner.py +++ b/tests/server/test_runner.py @@ -43,6 +43,7 @@ PaginatedRequestParams, ProgressNotificationParams, RequestParams, + RequestParamsMeta, SetLevelRequestParams, Tool, ) @@ -654,69 +655,49 @@ async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None: def test_resolve_protocol_version_handshake_committed_value_wins(): - """A handshake-committed version governs the whole connection; per-request - `_meta` and the transport hint are ignored.""" md = ServerMessageMetadata(protocol_version="2025-03-26") - params = {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-03-26"}} - assert _resolve_protocol_version("2025-06-18", params, md) == "2025-06-18" + 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(): - """With no handshake, a supported `_meta` value is the answer (even with a - transport hint also present).""" md = ServerMessageMetadata(protocol_version="2025-03-26") - params = {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-06-18"}} - assert _resolve_protocol_version(None, params, md) == "2025-06-18" + 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(): - """A `_meta` value the SDK does not serve falls through to the transport - hint rather than poisoning surface validation.""" md = ServerMessageMetadata(protocol_version="2025-03-26") - params = {"_meta": {PROTOCOL_VERSION_META_KEY: "1900-01-01"}} - assert _resolve_protocol_version(None, params, md) == "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") - params: dict[str, Any] = {"_meta": {PROTOCOL_VERSION_META_KEY: 42}} - assert _resolve_protocol_version(None, params, md) == "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(): - """The streamable-HTTP header path: stateless connection, no `_meta`.""" md = ServerMessageMetadata(protocol_version="2025-06-18") - assert _resolve_protocol_version(None, {"name": "x"}, md) == "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 transport may pass through an unvalidated value (the raw `initialize` - params version on streamable HTTP); a value the SDK does not serve falls - through to the terminal default.""" + """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(): - """stdio and in-memory transports attach no metadata; before the handshake - only `ping` and `initialize` reach the runner, and both exist at the last - handshake-based revision.""" assert _resolve_protocol_version(None, None, None) == "2025-11-25" - assert _resolve_protocol_version(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" -def test_resolve_protocol_version_ignores_non_mapping_meta(): - assert _resolve_protocol_version(None, {"_meta": "oops"}, None) == "2025-11-25" - - @pytest.mark.anyio async def test_runner_ctx_protocol_version_is_terminal_default_on_stateless_in_memory(server: SrvT): - """In-memory transport attaches no per-message metadata, so on a stateless - connection `ctx.protocol_version` is the resolver's terminal default while - the handshake-only `ctx.session.protocol_version` stays `None`.""" async with connected_runner(server, initialized=False, stateless=True) as (client, runner): await client.send_raw_request("tools/list", None) ctx = _seen_ctx[0] diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 61116dff6d..6aadf6ff88 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1371,10 +1371,7 @@ async def stateless_context_app() -> AsyncIterator[Starlette]: async def test_streamablehttp_stateless_ctx_protocol_version_tracks_the_header( stateless_context_app: Starlette, header_value: str | None, expected: str ) -> None: - """A stateless server has no handshake-committed version; the validated - `MCP-Protocol-Version` header reaches the handler as `ctx.protocol_version` - (with the spec's `2025-03-26` default when absent) while the handshake-only - `ctx.session.protocol_version` stays `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,