Stop negotiating modern protocol versions through the initialize handshake - #516
Merged
koic merged 1 commit intoAug 15, 2026
Conversation
…ndshake ## Motivation and Context Follow-up to modelcontextprotocol#512 (comment) A comment on modelcontextprotocol#512 reported that results stay unstamped when a client negotiates `2026-07-28` through the legacy `initialize` handshake: the SEP-2322 `resultType` stamp and the SEP-2549 cache hints are both gated on the per-request `_meta` envelope, which such a connection never carries, so strictly validating clients reject every result as missing the REQUIRED `resultType`. The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish a session via `initialize` (`2025-11-25` and earlier), and modern versions carry their version on every request in `_meta` with no handshake at all - the 2026-07-28 schema defines no `initialize`, and a request without the envelope is malformed. The negotiation list of this server still contained `2026-07-28`, producing a hybrid state the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates from `SUPPORTED_PROTOCOL_VERSIONS`, which stops at `2025-11-25`, and counter-offers its first entry; the Python server negotiates from `HANDSHAKE_PROTOCOL_VERSIONS`, which stops there too, and counter-offers `LATEST_HANDSHAKE_VERSION`. `initialize` now negotiates from a new `SUPPORTED_HANDSHAKE_PROTOCOL_VERSIONS` list (`2025-11-25` and earlier): a request for `2026-07-28`, or for a version this server does not know, is counter-offered `2025-11-25`, exactly as both reference SDKs answer. A counter-offered client proceeds on `2025-11-25`, where `resultType` does not exist and its absence is the correct shape, resolving the reported rejection. `2026-07-28` stays reachable through `server/discover` and the per-request envelope, where the stamps already apply. The `envelope &&` stamp gates are intentionally unchanged: with the handshake capped, an envelope-less `2026-07-28` connection is no longer constructible, so the gates are correct by construction. The client side is symmetrized the same way, matching both reference SDKs: the legacy handshake offers `LATEST_HANDSHAKE_PROTOCOL_VERSION` by default, passing an explicitly modern `protocol_version` to the legacy connect raises `ArgumentError` (validated before the stdio transport spawns its child process), and a server answering `initialize` with a modern version is rejected like an unknown one. `:auto` forwards an explicitly modern version to the discovery probe and never downgrades it into the legacy fallback: the probe's failure is the real answer and propagates, instead of the fallback dying on the handshake guard. The handshake rule itself lives in one place (`Configuration.reject_modern_handshake_version!`), and the handshake list is derived from the stable and modern lists so the era partition cannot drift when the next revision lands. Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land on a deprecating version and the call site the warnings used to sit on had become unreachable. On the server the answer is removal: `Configuration#protocol_version` scopes the handshake, so it now accepts handshake versions only and rejects a modern pin with `ArgumentError` at construction. Accepting one would configure nothing (a modern version has no handshake to pin), and neither reference SDK carries a config value that is accepted but inert: Python's negotiation has no knob at all, and every entry of the TypeScript server's `supportedProtocolVersions` is consumed by its own era's mechanism. With no path left onto a deprecating revision, the server-side deprecation warning plumbing became unreachable and is removed along with it. Keying those warnings on a modern-era session instead was tried and rejected, because `notify_log_message` is the SEP-2575 sanctioned delivery path on that wire and every log line would then carry a deprecation warning for doing exactly what the revision prescribes (a single test file produced over a thousand of them). A session-bound request may still stamp `MCP-Protocol-Version: 2026-07-28` after being counter-offered, and such a request continues to be served at the negotiated revision rather than rejected. Sending the negotiated version is a SHOULD on the client, and the spec's `400 Bad Request` rule covers a version the server does not support, which is not the case here: this server does serve `2026-07-28`, through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with its consequence, which is that no `resultType` appears on those results. Two details follow from the pin being handshake-scoped. Reading it back when nothing is set now answers `LATEST_HANDSHAKE_PROTOCOL_VERSION` rather than the newest version of any era, so the getter no longer hands out a value its own writer rejects and round-tripping the setting keeps working; only `Server#validate!` reads that default, through three comparisons that answer the same for either value, so nothing else moves. And the rejection names its reason instead of listing the accepted versions, because this is the error an upgrade from a release that accepted the value lands on, which makes it the migration note a bare list would not be. `MCP::ProtocolDeprecations` says in its own documentation that only the client emits these warnings now, and that `LOGGING_MESSAGE` has no caller left. It stays public and callable: removing it would be a breaking change for no gain. The handshake examples in `docs/building-clients.md` and the shared `initialize_params` test helper now use the handshake version as well, and the README section on `protocol_version` describes the pin as handshake-scoped: handshake versions only, `ArgumentError` for a modern one, the handshake version as the default, and `2026-07-28` reached through `server/discover` with no configuration needed. ## How Has This Been Tested? New regression tests pin the reported scenario end to end: an `initialize` requesting `2026-07-28` is counter-offered `2025-11-25`, locks the legacy era, and subsequent results carry no `resultType` (the correct shape at that revision). Client-side tests cover the default `2025-11-25` offer, the `ArgumentError` for an explicitly modern version on the legacy connect (asserting no child process is spawned on the stdio transport), the rejection of a modern `InitializeResult` on both transports, the `:auto` probe failure propagating for an explicitly modern version instead of falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities. Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version, the answer is an older one, and the connection speaks the answer afterwards (over HTTP the next request's `MCP-Protocol-Version` header carries it). The existing tests could not tell adoption from an echo, because every one of them had the server answer with the version the client had just offered. Both new tests were confirmed to fail when the client is made to report its own offer instead. Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked: the server tests asserting `2026-07-28` echoes and fallbacks now assert the counter-offer, and the deprecation warning tests keyed on a legacy-negotiated `2026-07-28` now assert no warning fires. The server-side deprecation tests that reached their state by calling `mark_initialized!` with a modern version, or by pinning one in `Configuration`, were replaced by tests asserting no warning fires on those paths, since no code path can produce the warning state any more; `Configuration` rejecting a modern pin has its own tests on the constructor and the setter, asserting the reason in the message rather than the list of accepted values. The default-pin test now also round-trips what it reads back into a fresh `Configuration`, which is what the old default could not do. The default move was checked for neutrality outside the suite as well, by building a server that uses every feature `Server#validate!` gates on that value (`description`, `title`, `website_url`, `instructions`, a `$ref` input schema, and tool annotations) and confirming all of them are still accepted. ## Breaking Changes Servers no longer echo `2026-07-28` from `initialize`: clients that negotiated it through the handshake are counter-offered `2025-11-25` and are served at that revision, matching the TypeScript and Python servers. `Configuration.new(protocol_version: "2026-07-28")` now raises `ArgumentError` (the pin scopes the handshake, which cannot land there); the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway. Reading `Configuration#protocol_version` with no pin set answers `2025-11-25` instead of `2026-07-28`; nothing in this SDK behaves differently for either value, but code comparing that reader against a literal will see the change. The client no longer offers modern versions on the legacy handshake and rejects a modern `InitializeResult`. Server-side deprecation warnings for SEP-2577 features no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision; the client-side warning on a modern connect replaces them.
9 tasks
koic
added a commit
that referenced
this pull request
Aug 15, 2026
## Motivation and Context The roadmap predates the 2026-07-28 implementation work and has drifted from the released SDK. The 2026-08-15 SEP-1730 tier audit flagged the drift while evaluating the roadmap requirement: - "Current Status" claims only 2025-06-18 and 2025-11-25 support, but the SDK now completes the 2026-07-28 revision and passes all scored server and client conformance scenarios for each revision's requirement set. - The SEP-2575 stateless lifecycle rewrite was described as reserved for 2.0, but it shipped during 1.x with both lifecycle eras served side by side; the few incompatible adjustments shipped in a minor release under the spec-conformance and security exceptions described in VERSIONING.md, as recorded in the 1.2.0 CHANGELOG entry. - Deprecation warnings for Roots/Sampling/Logging (SEP-2577) were described as future work but have shipped (#406, #516). - The documentation section undersold the coverage: all implemented non-experimental features are documented with examples (#472, #515). The refreshed text records the implemented spec revisions and how the 2026-07-28 revision actually shipped, and keeps the tasks extension (SEP-2663, #391) and the removal of legacy-era support as the remaining tracked items. Gem versions are deliberately not named: the roadmap describes the current state of the SDK, and the version-to-revision mapping already lives in CHANGELOG.md. ## How Has This Been Tested? Documentation-only change; no code paths are affected. Statements were cross-checked against the v1.2.0 CHANGELOG entry and the 2026-08-15 tier audit results (server 67/67 and client 50/50 scored conformance across the 2025-11-25 and 2026-07-28 requirement sets). ## Breaking Changes None. This only updates ROADMAP.md.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation and Context
Follow-up to #512 (comment)
A comment on #512 reported that results stay unstamped when a client negotiates
2026-07-28through the legacyinitializehandshake: the SEP-2322resultTypestamp and the SEP-2549 cache hints are both gated on the per-request_metaenvelope, which such a connection never carries, so strictly validating clients reject every result as missing the REQUIREDresultType.The root cause sits upstream of the stamp. Per the SEP-2575 era model, an era is a property of the protocol version itself: legacy versions establish a session via
initialize(2025-11-25and earlier), and modern versions carry their version on every request in_metawith no handshake at all - the 2026-07-28 schema defines noinitialize, and a request without the envelope is malformed. The negotiation list of this server still contained2026-07-28, producing a hybrid state the spec does not define and the reference SDKs refuse to construct: the TypeScript server negotiates fromSUPPORTED_PROTOCOL_VERSIONS, which stops at2025-11-25, and counter-offers its first entry; the Python server negotiates fromHANDSHAKE_PROTOCOL_VERSIONS, which stops there too, and counter-offersLATEST_HANDSHAKE_VERSION.initializenow negotiates from a newSUPPORTED_HANDSHAKE_PROTOCOL_VERSIONSlist (2025-11-25and earlier): a request for2026-07-28, or for a version this server does not know, is counter-offered2025-11-25, exactly as both reference SDKs answer. A counter-offered client proceeds on2025-11-25, whereresultTypedoes not exist and its absence is the correct shape, resolving the reported rejection.2026-07-28stays reachable throughserver/discoverand the per-request envelope, where the stamps already apply. Theenvelope &&stamp gates are intentionally unchanged: with the handshake capped, an envelope-less2026-07-28connection is no longer constructible, so the gates are correct by construction.The client side is symmetrized the same way, matching both reference SDKs: the legacy handshake offers
LATEST_HANDSHAKE_PROTOCOL_VERSIONby default, passing an explicitly modernprotocol_versionto the legacy connect raisesArgumentError(validated before the stdio transport spawns its child process), and a server answeringinitializewith a modern version is rejected like an unknown one.:autoforwards an explicitly modern version to the discovery probe and never downgrades it into the legacy fallback: the probe's failure is the real answer and propagates, instead of the fallback dying on the handshake guard. The handshake rule itself lives in one place (Configuration.reject_modern_handshake_version!), and the handshake list is derived from the stable and modern lists so the era partition cannot drift when the next revision lands.Two consequences are worth naming. The roots/sampling deprecation warnings move to the modern connect on the client, the revision where SEP-2577 actually deprecates them, since the handshake can no longer land on a deprecating version and the call site the warnings used to sit on had become unreachable. On the server the answer is removal:
Configuration#protocol_versionscopes the handshake, so it now accepts handshake versions only and rejects a modern pin withArgumentErrorat construction. Accepting one would configure nothing (a modern version has no handshake to pin), and neither reference SDK carries a config value that is accepted but inert: Python's negotiation has no knob at all, and every entry of the TypeScript server'ssupportedProtocolVersionsis consumed by its own era's mechanism. With no path left onto a deprecating revision, the server-side deprecation warning plumbing became unreachable and is removed along with it. Keying those warnings on a modern-era session instead was tried and rejected, becausenotify_log_messageis the SEP-2575 sanctioned delivery path on that wire and every log line would then carry a deprecation warning for doing exactly what the revision prescribes (a single test file produced over a thousand of them).A session-bound request may still stamp
MCP-Protocol-Version: 2026-07-28after being counter-offered, and such a request continues to be served at the negotiated revision rather than rejected. Sending the negotiated version is a SHOULD on the client, and the spec's400 Bad Requestrule covers a version the server does not support, which is not the case here: this server does serve2026-07-28, through the other lifecycle. The session it is bound to decides, and a test now pins that reading along with its consequence, which is that noresultTypeappears on those results.Two details follow from the pin being handshake-scoped. Reading it back when nothing is set now answers
LATEST_HANDSHAKE_PROTOCOL_VERSIONrather than the newest version of any era, so the getter no longer hands out a value its own writer rejects and round-tripping the setting keeps working; onlyServer#validate!reads that default, through three comparisons that answer the same for either value, so nothing else moves. And the rejection names its reason instead of listing the accepted versions, because this is the error an upgrade from a release that accepted the value lands on, which makes it the migration note a bare list would not be.MCP::ProtocolDeprecationssays in its own documentation that only the client emits these warnings now, and thatLOGGING_MESSAGEhas no caller left. It stays public and callable: removing it would be a breaking change for no gain.The handshake examples in
docs/building-clients.mdand the sharedinitialize_paramstest helper now use the handshake version as well, and the README section onprotocol_versiondescribes the pin as handshake-scoped: handshake versions only,ArgumentErrorfor a modern one, the handshake version as the default, and2026-07-28reached throughserver/discoverwith no configuration needed.How Has This Been Tested?
New regression tests pin the reported scenario end to end: an
initializerequesting2026-07-28is counter-offered2025-11-25, locks the legacy era, and subsequent results carry noresultType(the correct shape at that revision). Client-side tests cover the default2025-11-25offer, theArgumentErrorfor an explicitly modern version on the legacy connect (asserting no child process is spawned on the stdio transport), the rejection of a modernInitializeResulton both transports, the:autoprobe failure propagating for an explicitly modern version instead of falling back, and the deprecation warning firing on modern connects declaring roots/sampling capabilities.Counter-offer adoption gets its own test on both transports, since the server-side fix only resolves anything for clients that honor a counter-offer, and this client is one of them: the offer is the latest handshake version, the answer is an older one, and the connection speaks the answer afterwards (over HTTP the next request's
MCP-Protocol-Versionheader carries it). The existing tests could not tell adoption from an echo, because every one of them had the server answer with the version the client had just offered. Both new tests were confirmed to fail when the client is made to report its own offer instead.Tests that pinned the previous dual-lifecycle negotiation were inverted or reworked: the server tests asserting
2026-07-28echoes and fallbacks now assert the counter-offer, and the deprecation warning tests keyed on a legacy-negotiated2026-07-28now assert no warning fires. The server-side deprecation tests that reached their state by callingmark_initialized!with a modern version, or by pinning one inConfiguration, were replaced by tests asserting no warning fires on those paths, since no code path can produce the warning state any more;Configurationrejecting a modern pin has its own tests on the constructor and the setter, asserting the reason in the message rather than the list of accepted values.The default-pin test now also round-trips what it reads back into a fresh
Configuration, which is what the old default could not do. The default move was checked for neutrality outside the suite as well, by building a server that uses every featureServer#validate!gates on that value (description,title,website_url,instructions, a$refinput schema, and tool annotations) and confirming all of them are still accepted.Breaking Changes
Servers no longer echo
2026-07-28frominitialize: clients that negotiated it through the handshake are counter-offered2025-11-25and are served at that revision, matching the TypeScript and Python servers.Configuration.new(protocol_version: "2026-07-28")now raisesArgumentError(the pin scopes the handshake, which cannot land there); the value was only accepted since v1.1.0, whose handshake behavior this change replaces anyway. ReadingConfiguration#protocol_versionwith no pin set answers2025-11-25instead of2026-07-28; nothing in this SDK behaves differently for either value, but code comparing that reader against a literal will see the change. The client no longer offers modern versions on the legacy handshake and rejects a modernInitializeResult. Server-side deprecation warnings for SEP-2577 features no longer fire at all, because no negotiation or configuration path can put a connection on a deprecating revision; the client-side warning on a modern connect replaces them.Types of changes
Checklist