Resolve protocol version per request and expose it as ctx.protocol_version#2886
Resolve protocol version per request and expose it as ctx.protocol_version#2886maxisbey wants to merge 4 commits into
Conversation
…rsion 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.
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.
… a migration step)
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.
There was a problem hiding this comment.
I didn't find any correctness issues — the resolution precedence and fall-through cases look sound and well-covered by tests — but this threads a new protocol-version policy through the core runner dispatch path and adds a required field to ServerRequestContext (a breaking change), so it's worth a maintainer's eyes on the design before merge.
Extended reasoning...
Overview
The PR adds ctx.protocol_version: str, resolved per request via a new _resolve_protocol_version helper in src/mcp/server/runner.py, and threads a per-message protocol_version hint from the streamable HTTP transport (streamable_http.py) through ServerMessageMetadata (shared/message.py) into the request/notification context built in the runner. ServerRequestContext (server/context.py) gains a required protocol_version field. The remaining changes are docstring updates (connection.py, session.py), a migration-doc note, and test additions.
Security risks
None apparent. This does not touch auth, crypto, or permissions. The resolver explicitly skips values outside SUPPORTED_PROTOCOL_VERSIONS, so an attacker-supplied _meta/header version can't poison surface validation — it falls through to the terminal default instead. The new field carries no PII or secret material.
Level of scrutiny
Moderate-to-high. While the diff is not large, it modifies the core server dispatch path (_on_request/_on_notify) that every inbound request flows through, and it encodes a deliberate protocol-version resolution policy (handshake-committed wins; else per-request _meta; else transport hint; else literal 2025-11-25) drawn from the spec's connection-fact-vs-request-fact framing. That precedence ordering and the choice of a hard-coded terminal default are design decisions a maintainer should confirm, especially as the 2026-07-28 negotiation work lands.
Other factors
I verified the constants line up: SUPPORTED_PROTOCOL_VERSIONS includes 2025-11-25 (the terminal default) and DEFAULT_NEGOTIATED_VERSION (2025-03-26), so both the resolver default and the transport's header fallback resolve to supported versions. Test coverage is comprehensive (negotiated-wins, _meta, non-string/unsupported _meta, transport hint, unsupported hint, terminal default, plus stateless in-memory and end-to-end HTTP header cases). The main reasons I'm deferring rather than approving are the breaking required-field addition and the design-policy nature of the change on a critical path; no repo CODEOWNERS file was found.
Adds
ctx.protocol_version: str— the protocol version a request is being served at — resolved per request and always set, including on stateless connections.Motivation and Context
Since #2849, the runner's inbound surface validation (
validate_client_request/validate_client_notification) and outbound result serialization are version-keyed, and they need a version on every request. Today the only source isConnection.protocol_version, which is set by theinitializehandshake — so on stateless streamable-HTTP connections it staysNonefor the connection's whole life and the runner falls back to a hard-coded"2025-11-25". The streamable HTTP transport already reads and validates theMCP-Protocol-Versionheader per request, but only uses it for transport-local SSE-priming/close-callback gating and then drops it.This threads that value through to the runner and exposes it to handlers as
ctx.protocol_version.Resolution rule. A handshake-committed value governs the whole connection when present; otherwise per-request signals apply (
_meta["io.modelcontextprotocol/protocolVersion"], then the transport's per-message hint), then the literal2025-11-25terminal default. This mirrors the spec's own framing in draft Versioning § Terminology: handshake-based (≤2025-11-25) versions are a connection fact; per-request-metadata (2026-07-28+) versions are a request fact. The resolver doesn't branch on a mode flag —connection.protocol_version is not Noneis "this connection ran a handshake."Connection.protocol_versionandServerSession.protocol_versionkeep their existing meaning ("the version negotiated byinitialize,Noneif no handshake ran"); their docstrings now point atctx.protocol_versionfor the per-request value.How Has This Been Tested?
_resolve_protocol_versioncovering negotiated-wins,_meta, transport-hint, unsupported-value fall-through, and the terminal default.connected_runnertests forctx.protocol_versionon stateful (negotiated) and stateless in-memory (terminal default, withctx.session.protocol_versionstillNone).ctx.protocol_versionmatching the request'sMCP-Protocol-Versionheader (parametrized over two values), and the spec's2025-03-26default when the header is absent.Breaking Changes
ServerRequestContextgains a requiredprotocol_version: strfield. The runner always sets it; the only direct constructors were two tests, updated here.ServerMessageMetadatagains an optionalprotocol_versionfield (defaults toNone).Types of changes
Checklist
Additional context
SUPPORTED_PROTOCOL_VERSIONSare skipped by the resolver so an unrecognized declaration falls through rather than poisoning surface validation. ExplicitUnsupportedProtocolVersionErrorrejection for_meta(and the header/_metaMUST-match check) belong in the transport and arrive with the 2026-07-28 negotiation work.or "2025-11-25"fallbacks on the outbound server-to-client paths (ServerSession.send_request,Connection.send_request) are unchanged; those are connection-scoped sends without access to per-request context, and on stateless connections they fail fast withNoBackChannelErroranyway.