Resolve protocol version per request and expose it as ctx.protocol_version by maxisbey · Pull Request #2886 · modelcontextprotocol/python-sdk · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/migration.md
2 changes: 1 addition & 1 deletion src/mcp/server/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +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). Handlers read this as `ServerSession.protocol_version`."""
commits it). For the per-request value, read `ctx.protocol_version`."""

initialized: anyio.Event
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
Expand Down
1 change: 1 addition & 0 deletions src/mcp/server/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class ServerRequestContext(Generic[LifespanContextT, RequestT]):

session: ServerSession
lifespan_context: LifespanContextT
protocol_version: str
request_id: RequestId | None = None
meta: RequestParamsMeta | None = None
request: RequestT | None = None
Expand Down
44 changes: 34 additions & 10 deletions src/mcp/server/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,15 @@
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 (
INTERNAL_ERROR,
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
ErrorData,
Implementation,
InitializeRequestParams,
Expand Down Expand Up @@ -79,6 +80,30 @@ def _extract_meta(params: Mapping[str, Any] | None) -> RequestParamsMeta | None:
return None


def _resolve_protocol_version(
negotiated: str | None,
meta: RequestParamsMeta | None,
md: MessageMetadata,
) -> str:
"""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
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:
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.

Expand Down Expand Up @@ -218,11 +243,9 @@ 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"
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:
Expand Down Expand Up @@ -289,9 +312,9 @@ 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"
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:
Expand Down Expand Up @@ -349,7 +372,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
Expand All @@ -366,6 +389,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,
Expand Down
6 changes: 2 additions & 4 deletions src/mcp/server/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +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 (on streamable
HTTP the per-request version is the `MCP-Protocol-Version` header,
available via `ctx.request.headers`).
`None` before initialization, and normally `None` on stateless
connections. For the per-request value, read `ctx.protocol_version`.
"""
return self._connection.protocol_version

Expand Down
10 changes: 7 additions & 3 deletions src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions src/mcp/shared/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ class ServerMessageMetadata:
# transports, None for stdio). Typed as Any because the server layer is
# transport-agnostic.
request_context: Any = None
# 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
# Callback to close the standalone GET SSE stream (for unsolicited notifications)
Expand Down
1 change: 1 addition & 0 deletions tests/issues/test_176_progress_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions tests/server/mcpserver/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
65 changes: 64 additions & 1 deletion tests/server/test_runner.py
Loading
Loading