Skip to content

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

Draft
maxisbey wants to merge 4 commits into
mainfrom
maxisbey/per-request-protocol-version
Draft

Resolve protocol version per request and expose it as ctx.protocol_version#2886
maxisbey wants to merge 4 commits into
mainfrom
maxisbey/per-request-protocol-version

Conversation

@maxisbey

@maxisbey maxisbey commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

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 is Connection.protocol_version, which is set by the initialize handshake — so on stateless streamable-HTTP connections it stays None for 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 the MCP-Protocol-Version header 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 literal 2025-11-25 terminal 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 None is "this connection ran a handshake."

Connection.protocol_version and ServerSession.protocol_version keep their existing meaning ("the version negotiated by initialize, None if no handshake ran"); their docstrings now point at ctx.protocol_version for the per-request value.

How Has This Been Tested?

  • Unit tests for _resolve_protocol_version covering negotiated-wins, _meta, transport-hint, unsupported-value fall-through, and the terminal default.
  • connected_runner tests for ctx.protocol_version on stateful (negotiated) and stateless in-memory (terminal default, with ctx.session.protocol_version still None).
  • End-to-end streamable-HTTP tests: a stateless server's handler observes ctx.protocol_version matching the request's MCP-Protocol-Version header (parametrized over two values), and the spec's 2025-03-26 default when the header is absent.
  • Full suite at 100% coverage; pyright/ruff clean.

Breaking Changes

ServerRequestContext gains a required protocol_version: str field. The runner always sets it; the only direct constructors were two tests, updated here. ServerMessageMetadata gains an optional protocol_version field (defaults to None).

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • Values not in SUPPORTED_PROTOCOL_VERSIONS are skipped by the resolver so an unrecognized declaration falls through rather than poisoning surface validation. Explicit UnsupportedProtocolVersionError rejection for _meta (and the header/_meta MUST-match check) belong in the transport and arrive with the 2026-07-28 negotiation work.
  • The 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 with NoBackChannelError anyway.

maxisbey added 4 commits June 16, 2026 19:53
…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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant