Ignore pre-2026 protocol_version pins at the StreamableHTTP transport#2923
Conversation
A stateful (pre-2026) protocol_version pin is a session-layer concern: it controls the protocolVersion field in the InitializeRequest body. #2910 threaded it into the transport unconditionally, which (a) stamped MCP-Protocol-Version on the initialize POST itself and (b) kept stamping the pinned value after the server negotiated a different version. The transport now only stores 2026-07-28+ pins (which never send initialize). For earlier versions it falls back to learning the negotiated version from the InitializeResult, restoring pre-#2910 wire behaviour for stateful sessions. Also adds a docstring caveat to Client.protocol_version noting the in-memory transport does not yet have a modern entry point.
There was a problem hiding this comment.
LGTM — small, well-scoped follow-up that restores the pre-#2910 header behaviour for stateful pins, with direct test coverage of both the omitted-on-initialize and negotiated-version cases.
Extended reasoning...
Overview
The PR touches three files: a one-conditional change in StreamableHTTPTransport.__init__ (only retain a constructor protocol_version pin when it is in MODERN_PROTOCOL_VERSIONS), accompanying docstring/comment updates in streamable_http.py and client.py, and unit tests. No control flow elsewhere changes; the existing _maybe_extract_protocol_version_from_message snoop simply becomes the source of the header for pre-2026 sessions again, which matches the behaviour before #2910.
Security risks
None identified. The change only affects which value populates the outgoing MCP-Protocol-Version header on the client side; it does not touch auth, parsing of untrusted input, or server-side validation. If anything it aligns the wire header with the spec (header reflects the negotiated version, and is not sent on the initialize request itself).
Level of scrutiny
This is client transport logic, not security-sensitive code, and the effective diff is a single guarded assignment plus documentation. The semantics (a stateful pin is a session-layer concern carried in the InitializeRequest body, while the transport header follows negotiation) are clearly argued in the PR description and are consistent with how Client threads protocol_version into ClientSession separately. The new test exercises both behaviours directly (MCP-ProtOCOL-Version absent before negotiation, negotiated value stamped after), and the renamed modern-pin test still covers the 2026 path.
Other factors
The bug-hunting pass found no issues, the PR is a follow-up addressing post-merge review feedback from #2910/#2917, and the author reports the 2025-pinned interaction suite passes unchanged. The deliberately-unaddressed review threads are explicitly called out as out of scope. Overall risk is low and intent is clear.
Follow-up to #2910 / #2917 addressing post-merge review feedback.
Motivation and Context
#2910 threaded the new
protocol_versionpin intoStreamableHTTPTransportunconditionally and made the constructor pin win over theInitializeResultsnoop. That's correct for a2026-07-28pin (no initialize is ever sent), but for a pre-2026 stateful pin it meant:MCP-Protocol-Versionheader was stamped on the initialize POST itself (the spec only defines it for subsequent requests; a strict server that doesn't support the pinned version may 400 the handshake), andsession.protocol_versionand the wire header disagreed.A stateful pin is a session-layer concern (it picks the
protocolVersionfield in theInitializeRequestbody); the transport doesn't need it. This change drops pre-2026 pins at the transport constructor so the header is populated from the negotiatedInitializeResultexactly as before #2910, while modern pins continue to be honoured from the first request.Also adds a one-line caveat to the
Client.protocol_versiondocstring noting that a2026-07-28pin currently requires a URL/HTTP transport (the in-memoryServer/MCPServerpath doesn't have a modern entry yet).Review threads not addressed here
_streamable_http_modern.pyreturning-32700for a valid-JSON non-request body — the body-parse block is replaced by the inbound classifier in the upcomingserver-statelesswork, which returnsINVALID_REQUESTfor that case. (At 2026-07-28 there are no client→server notifications over Streamable HTTP, so 202 is not the target shape — a deliberate rejection is.)_related_request_id# type: ignore[call-arg]inserver/session.py— already tracked; falls out of theServerRunnerdriver split.How Has This Been Tested?
test_stateful_constructor_pin_is_ignored_and_the_negotiated_version_winscovers both the header-omitted-on-initialize and negotiated-version-wins behaviours.test_modern_constructor_pin_is_not_overwritten_by_an_initialize_result(renamed for clarity) still pins the 2026 case.tests/interaction -k 'streamable-http and 2025'(230 tests, which pass a stateful2025-11-25pin on every connection) passes unchanged.Breaking Changes
None.
Types of changes
Checklist
Additional context
The early-return guard in
_maybe_extract_protocol_version_from_messageis now reachable only via direct invocation in the unit test (a modern-pinned transport never sends initialize in production). Left as-is to keep this PR scoped; the single-owner refactor for the client pin will sweep it.AI Disclaimer