feat(core,client,server): stateful protocol version constants; initialize negotiates stateful versions only#2235
Conversation
🦋 Changeset detectedLatest commit: 9306136 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: |
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.
d5a52bb to
9306136
Compare
| /** | ||
| * Protocol versions that negotiate via the initialize handshake (the stateful model). Closed by | ||
| * design: every revision after 2025-11-25 is stateless and negotiates per-request, never via | ||
| * initialize. Hardcoded — do not derive from {@linkcode SUPPORTED_PROTOCOL_VERSIONS}. | ||
| */ | ||
| export const STATEFUL_PROTOCOL_VERSIONS = ['2024-10-07', '2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']; |
There was a problem hiding this comment.
🟡 STATEFUL_PROTOCOL_VERSIONS is ordered oldest-first while the sibling SUPPORTED_PROTOCOL_VERSIONS is newest-first, and order is semantically meaningful when these arrays are passed as supportedProtocolVersions (the client requests, and the server falls back to, the first stateful entry). Since the new client error message explicitly tells users to include versions from STATEFUL_PROTOCOL_VERSIONS in supportedProtocolVersions, a user passing this constant verbatim will silently negotiate 2024-10-07 instead of 2025-11-25 — consider reversing the array to match the newest-first convention, or documenting that it is a membership set, not a preference-ordered list.
Extended reasoning...
What the bug is. The new public constant STATEFUL_PROTOCOL_VERSIONS (packages/core/src/types/constants.ts:10) is hardcoded oldest-first: ['2024-10-07', '2024-11-05', '2025-03-26', '2025-06-18', '2025-11-25']. The sibling constant on the line above, SUPPORTED_PROTOCOL_VERSIONS, follows the opposite, newest-first convention: [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']. Internally the SDK only consumes the new constant via .includes() in isStatefulProtocolVersion(), so no shipped behavior is wrong — the issue is that the constant's order becomes load-bearing the moment a user passes it as supportedProtocolVersions, and this PR actively steers users toward doing exactly that.\n\nWhy order matters. This PR's own JSDoc on ProtocolOptions.supportedProtocolVersions (packages/core/src/shared/protocol.ts) says: "The first stateful entry … is preferred: sent by the client at initialize, used as the server's fallback." Concretely, Client.connect() computes statefulVersions = this._supportedProtocolVersions.filter(isStatefulProtocolVersion) and requests statefulVersions[0] (packages/client/src/client/client.ts:495-496), and Server._oninitialize() falls back to statefulVersions[0] when the requested version is unsupported (packages/server/src/server/server.ts:436-438). So the first element of whatever array the user supplies decides the negotiated version.\n\nThe code path that triggers it. The new client error message reads: "Include at least one version from STATEFUL_PROTOCOL_VERSIONS in supportedProtocolVersions." The natural reaction to that error — or simply the natural reading of the docs added in this PR, which discuss STATEFUL_PROTOCOL_VERSIONS directly in the context of supportedProtocolVersions — is new Client(info, { supportedProtocolVersions: STATEFUL_PROTOCOL_VERSIONS }). Nothing in the JSDoc on the constant warns that it is not preference-ordered.\n\nStep-by-step proof. 1) A user hits the new error (or reads the docs) and writes new Client({ name: 'c', version: '1.0.0' }, { supportedProtocolVersions: STATEFUL_PROTOCOL_VERSIONS }). 2) In connect(), every entry passes isStatefulProtocolVersion, so statefulVersions equals the constant verbatim and requestedProtocolVersion = statefulVersions[0] = '2024-10-07'. 3) The default SDK server has '2024-10-07' in its supported list, so statefulVersions.includes('2024-10-07') is true and it echoes '2024-10-07' back. 4) The client accepts it and getNegotiatedProtocolVersion() returns '2024-10-07' — the connection silently runs on the oldest protocol revision instead of 2025-11-25, with no error or warning anywhere. A server configured with supportedProtocolVersions: STATEFUL_PROTOCOL_VERSIONS likewise falls back to '2024-10-07' for any unsupported requested version.\n\nWhy nothing prevents it. The negotiation still produces a valid version, all guards added in this PR pass, and every existing test passes — the downgrade is invisible unless the user inspects the negotiated version. That is also why this is a nit rather than a blocking bug: no in-repo code passes the constant positionally, the SDK's own behavior is correct, and the misuse requires a particular (if well-advertised) user choice.\n\nHow to fix. Reverse the array to newest-first (['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']) so it matches the SUPPORTED_PROTOCOL_VERSIONS convention and is safe to pass verbatim — this is zero-risk since the SDK only uses it for membership checks. Alternatively (or additionally), add a JSDoc note that the constant is a membership set and not suitable as a preference-ordered supportedProtocolVersions value.
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.