feat(core,client,server): stateful protocol version constants; initialize negotiates stateful versions only#2235
Conversation
🦋 Changeset detectedLatest commit: 57aa16c The changes in this PR will be included in the next version bump. This PR includes changesets to release 3 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@modelcontextprotocol/client
@modelcontextprotocol/codemod
@modelcontextprotocol/server
@modelcontextprotocol/server-legacy
@modelcontextprotocol/express
@modelcontextprotocol/fastify
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
d5a52bb to
9306136
Compare
Add STATEFUL_PROTOCOL_VERSIONS — the closed list of protocol versions
negotiated via the initialize handshake; every revision after 2025-11-25
is stateless and negotiates per-request — with an
isStatefulProtocolVersion() predicate on the internal barrel, plus
DRAFT_PROTOCOL_VERSION_2026 ('DRAFT-2026-v1', mirroring the draft
specification schema) and DRAFT_PROTOCOL_VERSIONS.
SUPPORTED_PROTOCOL_VERSIONS is unchanged. Unit tests pin the draft wire
literal and its stateless classification against the generated draft
spec schema.
…itialize Protocol revisions after 2025-11-25 are stateless and never negotiated via the initialize handshake. Both sides now run the unchanged handshake logic against the stateful subset of supportedProtocolVersions: - Client: requests the first stateful supported version, rejects in connect() when the subset is empty, and rejects an initialize result whose version is outside the subset. - Server: accepts the requested version only when it is in its own subset, otherwise falls back to the subset's first entry. Covered by unit tests on both sides and an e2e requirement (lifecycle:version:initialize-stateful-versions-only) that wire-taps a handshake where both sides list the draft revision and asserts no post-2025-11-25 version string appears.
9306136 to
57aa16c
Compare
| const statefulVersions = this._supportedProtocolVersions.filter(version => isStatefulProtocolVersion(version)); | ||
| // TODO: respond with -32004 (unsupported protocol version) when statefulVersions is empty. | ||
| const protocolVersion = statefulVersions.includes(requestedVersion) | ||
| ? requestedVersion | ||
| : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); | ||
| : (statefulVersions[0] ?? LATEST_PROTOCOL_VERSION); |
There was a problem hiding this comment.
🔴 Filtering supportedProtocolVersions through the hardcoded STATEFUL_PROTOCOL_VERSIONS set is a behavior change to an existing public option: a client whose list contains only a custom/unknown version (e.g. ['2026-01-01']) now throws on connect(), and a server with a custom-first list silently changes its fallback — yet the PR states "Breaking Changes: None" and examples/server/src/customProtocolVersion.ts still documents the old escape hatch ("how to support protocol versions not yet in the SDK", "First version in the list is used as fallback"), which is now contradicted. Please flag/document the semantics change (migration.md / migration-SKILL.md) and update or remove the contradicted example.
Extended reasoning...
What changed. Before this PR, supportedProtocolVersions was honored verbatim during the initialize handshake: Client.connect() requested this._supportedProtocolVersions[0] (whatever string it was) and accepted any entry in the list, and Server._oninitialize() accepted any requested version present in the list and fell back to this._supportedProtocolVersions[0]. After this PR, both sides filter the user-supplied list through the hardcoded STATEFUL_PROTOCOL_VERSIONS membership check (packages/client/src/client/client.ts:495-499, packages/server/src/server/server.ts:434-438), so any entry not in that closed list is silently dropped before negotiation.
Concrete behavior changes for existing users.
- A client constructed with
supportedProtocolVersions: ['2026-01-01'](or any list with no stateful entry) previously connected by requesting'2026-01-01'; it now throws atconnect(): "initialize cannot negotiate protocol versions newer than 2025-11-25...". - A client with a mixed list like
['2026-01-01', '2025-11-25']previously requested'2026-01-01'first; it now silently requests'2025-11-25', negotiating a different version than before with no warning. - A server with a custom-first list (e.g.
['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]) previously echoed'2026-01-01'when requested and used it as the fallback for unsupported requests; it now never negotiates it and falls back to'2025-11-25'.
Step-by-step proof using the repo's own shipped example. examples/server/src/customProtocolVersion.ts (untouched by this PR) states its purpose explicitly: "This demonstrates how to support protocol versions not yet in the SDK. First version in the list is used as fallback when client requests an unsupported version." It builds CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS] and passes it as supportedProtocolVersions. Walk the new server path in _oninitialize: (1) a client requests '2026-01-01'; (2) statefulVersions = CUSTOM_VERSIONS.filter(isStatefulProtocolVersion) drops '2026-01-01' because it is not in the hardcoded STATEFUL_PROTOCOL_VERSIONS; (3) statefulVersions.includes('2026-01-01') is false, so the server falls back to statefulVersions[0] = '2025-11-25' instead of echoing the custom version; (4) likewise, when an unsupported version is requested, the fallback is now '2025-11-25', not the example's documented "first version in the list". Both claims in the example's header comment are now false, and the old ProtocolOptions JSDoc ("First version is preferred") described the same now-removed escape hatch.
Why nothing in the PR covers this. The new prose in docs/client.md / docs/server.md and the updated ProtocolOptions JSDoc describe the new behavior, but the PR description claims "Breaking Changes: None", the changeset is a patch bump, neither docs/migration.md nor docs/migration-SKILL.md gets an entry, and the contradicted example is not updated or removed. The repo's own conventions (CLAUDE.md: document breaking changes in both migration files; review checklist: "Backwards compat: public-interface changes, default changes ... flagged and justified" and "assess whether existing examples/ still ... demonstrate the current API") are exactly what this finding is about.
Impact. Anyone who used the documented escape hatch — including anyone who literally followed the shipped example — gets either a hard connect() failure (client side) or a silent change in the negotiated/fallback version (server side) on upgrade, with no migration note pointing them at the new model.
Not a request to revert. The guard itself is intentional design (the 2026 revision negotiates per-request, never via initialize), and all four verifiers agreed on that. The actionable items are: (a) state the backwards-compat impact in the PR description and add entries to docs/migration.md / docs/migration-SKILL.md describing the new supportedProtocolVersions semantics; (b) update examples/server/src/customProtocolVersion.ts to demonstrate behavior the SDK still supports (or remove it) so the example no longer contradicts the implementation; (c) consider whether the changeset should be more than a patch given the semantics change to a public option.
Adds
STATEFUL_PROTOCOL_VERSIONS(andDRAFT_PROTOCOL_VERSIONS) and guards soinitializeonly negotiates stateful protocol versions (2025-11-25 and older).Motivation and Context
The 2026 revision negotiates versions per-request, never via
initialize— permanently. Without these guards, a post-2025-11-25 version listed insupportedProtocolVersionsgets requested and accepted through the handshake.How Has This Been Tested?
Unit tests for all four guard corners (client request selection, draft-only rejection, server accept, server fallback, draft in the initialize result). e2e wire tap on all five transports proves the draft string never appears in the handshake. Full matrix 0 unexpected failures.
Breaking Changes
None.
Types of changes
Checklist
Additional context
Draft-only supported lists keep the existing
?? LATEST_PROTOCOL_VERSIONfallback for now; the-32004answer ships with the version-error PR. Part of #2184.