Skip to content

feat(core,client,server): stateful protocol version constants; initialize negotiates stateful versions only#2235

Open
felixweinberger wants to merge 2 commits into
fweinberger/m2-ctx-envelopefrom
fweinberger/m3-draft-version-guards
Open

feat(core,client,server): stateful protocol version constants; initialize negotiates stateful versions only#2235
felixweinberger wants to merge 2 commits into
fweinberger/m2-ctx-envelopefrom
fweinberger/m3-draft-version-guards

Conversation

@felixweinberger
Copy link
Copy Markdown
Contributor

@felixweinberger felixweinberger commented Jun 1, 2026

🗺️ Milestone tracker · M3: draft version constants + lifecycle guards · stacked on #2231

Adds STATEFUL_PROTOCOL_VERSIONS (and DRAFT_PROTOCOL_VERSIONS) and guards so initialize only 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 in supportedProtocolVersions gets 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

  • New feature (non-breaking change which adds functionality)

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

Draft-only supported lists keep the existing ?? LATEST_PROTOCOL_VERSION fallback for now; the -32004 answer ships with the version-error PR. Part of #2184.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Jun 1, 2026

🦋 Changeset detected

Latest commit: 57aa16c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@modelcontextprotocol/core Patch
@modelcontextprotocol/client Patch
@modelcontextprotocol/server Patch

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

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Jun 1, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2235

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2235

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2235

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2235

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2235

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2235

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2235

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2235

commit: 57aa16c

@felixweinberger felixweinberger force-pushed the fweinberger/m3-draft-version-guards branch from d5a52bb to 9306136 Compare June 1, 2026 22:53
@felixweinberger felixweinberger changed the title feat(core,client,server): draft protocol version constants; initialize never negotiates draft versions feat(core,client,server): stateful protocol version constants; initialize negotiates stateful versions only Jun 1, 2026
@felixweinberger felixweinberger marked this pull request as ready for review June 1, 2026 23:22
@felixweinberger felixweinberger requested a review from a team as a code owner June 1, 2026 23:22
Comment thread packages/core/src/types/constants.ts Outdated
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.
@felixweinberger felixweinberger force-pushed the fweinberger/m3-draft-version-guards branch from 9306136 to 57aa16c Compare June 2, 2026 08:22
Comment on lines +434 to +438
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

  1. 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 at connect(): "initialize cannot negotiate protocol versions newer than 2025-11-25...".
  2. 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.
  3. 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.

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