Skip to content

Commit 734746a

Browse files
authored
Resolve protocol version per request and expose it as ctx.protocol_version (#2886)
1 parent c85ccf1 commit 734746a

11 files changed

Lines changed: 175 additions & 28 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -674,7 +674,7 @@ ctx: ClientRequestContext
674674
server_ctx: ServerRequestContext[LifespanContextT, RequestT]
675675
```
676676

677-
`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`.
677+
`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`.
678678

679679
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:
680680

src/mcp/server/connection.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ class Connection:
8787
"""The protocol version negotiated during `initialize`; `None` before
8888
initialization. Stateless connections don't require the handshake, so this
8989
normally stays `None` there (a client that sends `initialize` anyway still
90-
commits it). Handlers read this as `ServerSession.protocol_version`."""
90+
commits it). For the per-request value, read `ctx.protocol_version`."""
9191

9292
initialized: anyio.Event
9393
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);

src/mcp/server/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class ServerRequestContext(Generic[LifespanContextT, RequestT]):
3131

3232
session: ServerSession
3333
lifespan_context: LifespanContextT
34+
protocol_version: str
3435
request_id: RequestId | None = None
3536
meta: RequestParamsMeta | None = None
3637
request: RequestT | None = None

src/mcp/server/runner.py

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@
3434
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
3535
from mcp.shared.exceptions import MCPError
3636
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
37-
from mcp.shared.message import ServerMessageMetadata
37+
from mcp.shared.message import MessageMetadata, ServerMessageMetadata
3838
from mcp.shared.transport_context import TransportContext
3939
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
4040
from mcp.types import (
4141
INTERNAL_ERROR,
4242
INVALID_PARAMS,
4343
LATEST_PROTOCOL_VERSION,
4444
METHOD_NOT_FOUND,
45+
PROTOCOL_VERSION_META_KEY,
4546
ErrorData,
4647
Implementation,
4748
InitializeRequestParams,
@@ -79,6 +80,30 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
7980
return None
8081

8182

83+
def _resolve_protocol_version(
84+
negotiated: str | None,
85+
meta: RequestParamsMeta | None,
86+
md: MessageMetadata,
87+
) -> str:
88+
"""Resolve the protocol version for this inbound message.
89+
90+
Handshake-committed value wins; else per-request `_meta`, else the
91+
transport hint. Unsupported values fall through so surface validation
92+
never sees them.
93+
"""
94+
if negotiated is not None:
95+
return negotiated
96+
if meta is not None:
97+
v = meta.get(PROTOCOL_VERSION_META_KEY)
98+
if isinstance(v, str) and v in SUPPORTED_PROTOCOL_VERSIONS:
99+
return v
100+
if isinstance(md, ServerMessageMetadata):
101+
hint = md.protocol_version
102+
if hint is not None and hint in SUPPORTED_PROTOCOL_VERSIONS:
103+
return hint
104+
return "2025-11-25"
105+
106+
82107
def otel_middleware(next_on_request: OnRequest) -> OnRequest:
83108
"""Dispatch-tier middleware that wraps each request in an OpenTelemetry span.
84109
@@ -218,11 +243,9 @@ async def _on_request(
218243
method: str,
219244
params: Mapping[str, Any] | None,
220245
) -> dict[str, Any]:
221-
ctx = self._make_context(dctx, _extract_meta(params))
222-
# Literal, not LATEST_PROTOCOL_VERSION: the fallback covers the initialize
223-
# handshake (which only exists at <=2025) and stateless until the header
224-
# is plumbed; its meaning is fixed regardless of LATEST bumps.
225-
version = self.connection.protocol_version or "2025-11-25"
246+
meta = _extract_meta(params)
247+
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
248+
ctx = self._make_context(dctx, meta, version)
226249
is_spec_method = method in _methods.SPEC_CLIENT_METHODS
227250

228251
async def _inner() -> HandlerResult:
@@ -289,9 +312,9 @@ async def _on_notify(
289312
method: str,
290313
params: Mapping[str, Any] | None,
291314
) -> None:
292-
ctx = self._make_context(dctx, _extract_meta(params))
293-
# Same fallback as `_on_request`: covers pre-handshake and stateless.
294-
version = self.connection.protocol_version or "2025-11-25"
315+
meta = _extract_meta(params)
316+
version = _resolve_protocol_version(self.connection.protocol_version, meta, dctx.message_metadata)
317+
ctx = self._make_context(dctx, meta, version)
295318

296319
async def _inner() -> None:
297320
if method in _methods.SPEC_CLIENT_NOTIFICATION_METHODS:
@@ -349,7 +372,7 @@ def _compose_server_middleware(
349372
return call
350373

351374
def _make_context(
352-
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None
375+
self, dctx: DispatchContext[TransportContext], meta: RequestParamsMeta | None, protocol_version: str
353376
) -> ServerRequestContext[LifespanT, Any]:
354377
# TODO(maxisbey): remove for Context rework. Reads the SHTTP per-request
355378
# data off the raw `dctx.message_metadata` carrier; replace with the
@@ -366,6 +389,7 @@ def _make_context(
366389
lifespan_context=self.lifespan_state,
367390
request_id=dctx.request_id,
368391
meta=meta,
392+
protocol_version=protocol_version,
369393
request=request,
370394
close_sse_stream=close_sse_stream,
371395
close_standalone_sse_stream=close_standalone_sse_stream,

src/mcp/server/session.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,8 @@ def client_params(self) -> types.InitializeRequestParams | None:
5555
def protocol_version(self) -> str | None:
5656
"""The protocol version negotiated during `initialize`.
5757
58-
`None` before initialization completes. Stateless connections don't
59-
require the handshake, so this is normally `None` there (on streamable
60-
HTTP the per-request version is the `MCP-Protocol-Version` header,
61-
available via `ctx.request.headers`).
58+
`None` before initialization, and normally `None` on stateless
59+
connections. For the per-request value, read `ctx.protocol_version`.
6260
"""
6361
return self._connection.protocol_version
6462

src/mcp/server/streamable_http.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,11 +248,12 @@ async def close_standalone_stream_callback() -> None:
248248

249249
metadata = ServerMessageMetadata(
250250
request_context=request,
251+
protocol_version=protocol_version,
251252
close_sse_stream=close_stream_callback,
252253
close_standalone_sse_stream=close_standalone_stream_callback,
253254
)
254255
else:
255-
metadata = ServerMessageMetadata(request_context=request)
256+
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
256257

257258
return SessionMessage(message, metadata=metadata)
258259

@@ -506,7 +507,10 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
506507
await response(scope, receive, send)
507508

508509
# Process the message after sending the response
509-
metadata = ServerMessageMetadata(request_context=request)
510+
metadata = ServerMessageMetadata(
511+
request_context=request,
512+
protocol_version=request.headers.get(MCP_PROTOCOL_VERSION_HEADER, DEFAULT_NEGOTIATED_VERSION),
513+
)
510514
session_message = SessionMessage(message, metadata=metadata)
511515
await writer.send(session_message)
512516

@@ -529,7 +533,7 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
529533

530534
if self.is_json_response_enabled:
531535
# Process the message
532-
metadata = ServerMessageMetadata(request_context=request)
536+
metadata = ServerMessageMetadata(request_context=request, protocol_version=protocol_version)
533537
session_message = SessionMessage(message, metadata=metadata)
534538
await writer.send(session_message)
535539
try:

src/mcp/shared/message.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ class ServerMessageMetadata:
3535
# transports, None for stdio). Typed as Any because the server layer is
3636
# transport-agnostic.
3737
request_context: Any = None
38+
# Per-message protocol version observed by the transport (e.g. the
39+
# validated MCP-Protocol-Version header).
40+
protocol_version: str | None = None
3841
# Callback to close SSE stream for the current request without terminating
3942
close_sse_stream: CloseSSEStreamCallback | None = None
4043
# Callback to close the standalone GET SSE stream (for unsolicited notifications)

tests/issues/test_176_progress_token.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ async def test_progress_token_zero_first_call():
2121
session=mock_session,
2222
meta={"progress_token": 0},
2323
lifespan_context=None,
24+
protocol_version="2025-11-25",
2425
)
2526

2627
# Create context with our mocks

tests/server/mcpserver/test_server.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,6 +1501,7 @@ async def test_report_progress_passes_related_request_id():
15011501
session=mock_session,
15021502
meta={"progress_token": "tok-1"},
15031503
lifespan_context=None,
1504+
protocol_version="2025-11-25",
15041505
)
15051506

15061507
ctx = Context(request_context=request_context, mcp_server=MagicMock())

tests/server/test_runner.py

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818
from mcp.server.context import ServerRequestContext
1919
from mcp.server.lowlevel.server import NotificationOptions, Server
2020
from mcp.server.models import InitializationOptions
21-
from mcp.server.runner import ServerRunner, _extract_meta, otel_middleware
21+
from mcp.server.runner import ServerRunner, _extract_meta, _resolve_protocol_version, otel_middleware
2222
from mcp.server.session import ServerSession
2323
from mcp.shared.dispatcher import DispatchContext, DispatchMiddleware, OnRequest
2424
from mcp.shared.exceptions import MCPError
2525
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
26+
from mcp.shared.message import ClientMessageMetadata, ServerMessageMetadata
2627
from mcp.shared.peer import dump_params
2728
from mcp.shared.transport_context import TransportContext
2829
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
@@ -31,6 +32,7 @@
3132
INVALID_PARAMS,
3233
LATEST_PROTOCOL_VERSION,
3334
METHOD_NOT_FOUND,
35+
PROTOCOL_VERSION_META_KEY,
3436
CallToolRequestParams,
3537
ClientCapabilities,
3638
ErrorData,
@@ -41,6 +43,7 @@
4143
PaginatedRequestParams,
4244
ProgressNotificationParams,
4345
RequestParams,
46+
RequestParamsMeta,
4447
SetLevelRequestParams,
4548
Tool,
4649
)
@@ -218,6 +221,7 @@ async def test_runner_routes_to_handler_and_builds_context(server: SrvT):
218221
assert isinstance(ctx.session, ServerSession)
219222
assert ctx.session is runner.session
220223
assert ctx.request_id is not None
224+
assert ctx.protocol_version == LATEST_PROTOCOL_VERSION
221225

222226

223227
@pytest.mark.anyio
@@ -650,6 +654,65 @@ async def on_roots(ctx: Ctx, params: NotificationParams | None) -> None:
650654
]
651655

652656

657+
def test_resolve_protocol_version_handshake_committed_value_wins():
658+
md = ServerMessageMetadata(protocol_version="2025-03-26")
659+
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-03-26"}
660+
assert _resolve_protocol_version("2025-06-18", meta, md) == "2025-06-18"
661+
662+
663+
def test_resolve_protocol_version_reads_per_request_meta_when_no_handshake():
664+
md = ServerMessageMetadata(protocol_version="2025-03-26")
665+
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "2025-06-18"}
666+
assert _resolve_protocol_version(None, meta, md) == "2025-06-18"
667+
668+
669+
def test_resolve_protocol_version_skips_unsupported_meta_value():
670+
md = ServerMessageMetadata(protocol_version="2025-03-26")
671+
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: "1900-01-01"}
672+
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
673+
674+
675+
def test_resolve_protocol_version_skips_non_string_meta_value():
676+
md = ServerMessageMetadata(protocol_version="2025-03-26")
677+
meta: RequestParamsMeta = {PROTOCOL_VERSION_META_KEY: 42}
678+
assert _resolve_protocol_version(None, meta, md) == "2025-03-26"
679+
680+
681+
def test_resolve_protocol_version_reads_transport_hint_when_no_handshake_or_meta():
682+
md = ServerMessageMetadata(protocol_version="2025-06-18")
683+
assert _resolve_protocol_version(None, None, md) == "2025-06-18"
684+
assert _resolve_protocol_version(None, {}, md) == "2025-06-18"
685+
686+
687+
def test_resolve_protocol_version_skips_unsupported_transport_hint():
688+
"""The `initialize` params version reaches the metadata unvalidated; surface validation must never see it."""
689+
md = ServerMessageMetadata(protocol_version="1900-01-01")
690+
assert _resolve_protocol_version(None, None, md) == "2025-11-25"
691+
692+
693+
def test_resolve_protocol_version_terminal_default_with_no_signals():
694+
assert _resolve_protocol_version(None, None, None) == "2025-11-25"
695+
assert _resolve_protocol_version(None, None, ServerMessageMetadata()) == "2025-11-25"
696+
assert _resolve_protocol_version(None, None, ClientMessageMetadata()) == "2025-11-25"
697+
698+
699+
@pytest.mark.anyio
700+
async def test_runner_ctx_protocol_version_is_terminal_default_on_stateless_in_memory(server: SrvT):
701+
async with connected_runner(server, initialized=False, stateless=True) as (client, runner):
702+
await client.send_raw_request("tools/list", None)
703+
ctx = _seen_ctx[0]
704+
assert ctx.protocol_version == "2025-11-25"
705+
assert ctx.session.protocol_version is None
706+
assert runner.connection.protocol_version is None
707+
708+
709+
@pytest.mark.anyio
710+
async def test_runner_ctx_protocol_version_tracks_per_request_meta_on_stateless(server: SrvT):
711+
async with connected_runner(server, initialized=False, stateless=True) as (client, _):
712+
await client.send_raw_request("tools/list", {"_meta": {PROTOCOL_VERSION_META_KEY: "2025-06-18"}})
713+
assert _seen_ctx[0].protocol_version == "2025-06-18"
714+
715+
653716
def test_extract_meta_returns_none_for_absent_or_malformed():
654717
"""Context construction is independent of `_meta` validity; the params
655718
validation inside `call_next()` is what surfaces the error."""

0 commit comments

Comments
 (0)