Skip to content

Commit d5a52bb

Browse files
feat(client,server): initialize never negotiates draft protocol versions
Draft protocol versions are designed for per-request negotiation, which arrives with a later protocol release; the initialize handshake only ever settles on released versions: - Add an isDraftProtocolVersion() helper to core (internal barrel). - Client: request the first released (non-draft) entry of supportedProtocolVersions at initialize; if the list contains only draft versions, connect() rejects. - Server: treat a requested draft version as unsupported and skip draft versions when picking the fallback. A draft-only list still falls back to LATEST_PROTOCOL_VERSION for now (TODO: respond with -32004 in a follow-up). Covered by unit tests for the three guard points and an e2e requirement (lifecycle:version:initialize-never-negotiates-draft) that wire-taps the handshake of a draft-first client/server pair and asserts the draft wire identifier never appears.
1 parent a228b47 commit d5a52bb

11 files changed

Lines changed: 207 additions & 6 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modelcontextprotocol/core': patch
3+
'@modelcontextprotocol/client': patch
4+
'@modelcontextprotocol/server': patch
5+
---
6+
7+
Add `DRAFT_PROTOCOL_VERSION_2026` / `DRAFT_PROTOCOL_VERSIONS` constants. `initialize` never negotiates draft protocol versions: clients request and servers accept/fall back to released versions only.

docs/client.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boole
111111
console.log(systemPrompt);
112112
```
113113

114+
### Protocol versions
115+
116+
During initialization the client requests the first released (non-draft) entry of its supported version list and accepts whichever entry of that list the server responds with — by default the released versions in {@linkcode @modelcontextprotocol/client!index.SUPPORTED_PROTOCOL_VERSIONS | SUPPORTED_PROTOCOL_VERSIONS}. Pass `supportedProtocolVersions` in the client options to restrict or reorder that list.
117+
118+
Draft (unreleased) protocol revisions, listed in {@linkcode @modelcontextprotocol/client!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, never appear in the default set and are never negotiated via the initialize handshake — the draft revision is designed for per-request version negotiation, which arrives with a later release.
119+
114120
## Authentication
115121

116122
MCP servers can require authentication before accepting client connections (see [Authorization](https://modelcontextprotocol.io/specification/latest/basic/authorization) in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls `token()` before every request and `onUnauthorized()` (if provided) on 401, then retries once.

docs/server.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ const transport = new StdioServerTransport();
6262
await server.connect(transport);
6363
```
6464

65+
### Protocol versions
66+
67+
A server negotiates the protocol version per connection from its supported list — by default the released versions in {@linkcode @modelcontextprotocol/server!index.SUPPORTED_PROTOCOL_VERSIONS | SUPPORTED_PROTOCOL_VERSIONS}. Pass `supportedProtocolVersions` in the server options to restrict or reorder that list.
68+
69+
Draft (unreleased) protocol revisions, listed in {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, never appear in the default set and are never negotiated via the initialize handshake — the server neither accepts a requested draft nor falls back to one. The draft revision is designed for per-request version negotiation, which arrives with a later release.
70+
6571
## Server instructions
6672

6773
Instructions describe how to use the server and its features — cross-tool relationships, workflow patterns, and constraints (see [Instructions](https://modelcontextprotocol.io/specification/latest/basic/lifecycle#instructions) in the MCP specification). Clients may add them to the system prompt. Instructions should not duplicate information already in tool descriptions.

packages/client/src/client/client.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
extractTaskManagerOptions,
5050
GetPromptResultSchema,
5151
InitializeResultSchema,
52+
isDraftProtocolVersion,
5253
LATEST_PROTOCOL_VERSION,
5354
ListChangedOptionsBaseSchema,
5455
ListPromptsResultSchema,
@@ -492,11 +493,21 @@ export class Client extends Protocol<ClientContext> {
492493
return;
493494
}
494495
try {
496+
// Draft protocol versions are never negotiable via initialize (per-request negotiation
497+
// of draft versions arrives with a later release), so the handshake requests the first
498+
// released (non-draft) version in the supported list.
499+
const requestedProtocolVersion = this._supportedProtocolVersions.find(version => !isDraftProtocolVersion(version));
500+
if (requestedProtocolVersion === undefined && this._supportedProtocolVersions.length > 0) {
501+
throw new Error(
502+
'initialize cannot negotiate draft protocol versions; draft versions are negotiated per-request in a later protocol release. Include at least one released protocol version in supportedProtocolVersions.'
503+
);
504+
}
505+
495506
const result = await this._requestWithSchema(
496507
{
497508
method: 'initialize',
498509
params: {
499-
protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION,
510+
protocolVersion: requestedProtocolVersion ?? LATEST_PROTOCOL_VERSION,
500511
capabilities: this._capabilities,
501512
clientInfo: this._clientInfo
502513
}
@@ -509,7 +520,9 @@ export class Client extends Protocol<ClientContext> {
509520
throw new Error(`Server sent invalid initialize result: ${result}`);
510521
}
511522

512-
if (!this._supportedProtocolVersions.includes(result.protocolVersion)) {
523+
// Draft versions are rejected even when listed as supported: initialize never
524+
// negotiates a draft protocol version, in either direction.
525+
if (!this._supportedProtocolVersions.includes(result.protocolVersion) || isDraftProtocolVersion(result.protocolVersion)) {
513526
throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`);
514527
}
515528

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { DRAFT_PROTOCOL_VERSION_2026, InMemoryTransport, isJSONRPCRequest, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core';
2+
3+
import { Client } from '../../src/client/client.js';
4+
5+
/**
6+
* Links the client to a hand-rolled in-memory "server" that records the protocol version of each
7+
* initialize request crossing the wire and replies by echoing the requested version. Lets tests
8+
* observe exactly what the client put on the wire.
9+
*/
10+
function fakeInitializeServer(respondWithVersion?: string): { clientTransport: InMemoryTransport; requestedVersions: string[] } {
11+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
12+
const requestedVersions: string[] = [];
13+
serverTransport.onmessage = message => {
14+
if (isJSONRPCRequest(message) && message.method === 'initialize') {
15+
const params = message.params as { protocolVersion: string };
16+
requestedVersions.push(params.protocolVersion);
17+
void serverTransport.send({
18+
jsonrpc: '2.0',
19+
id: message.id,
20+
result: {
21+
protocolVersion: respondWithVersion ?? params.protocolVersion,
22+
capabilities: {},
23+
serverInfo: { name: 'fake-server', version: '0.0.0' }
24+
}
25+
});
26+
}
27+
// Notifications (e.g. notifications/initialized) need no reply.
28+
};
29+
return { clientTransport, requestedVersions };
30+
}
31+
32+
describe('Client', () => {
33+
describe('initialize never negotiates draft protocol versions', () => {
34+
it('connect() rejects when supportedProtocolVersions contains only draft versions', async () => {
35+
const client = new Client(
36+
{ name: 'test-client', version: '1.0.0' },
37+
{ supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026] }
38+
);
39+
const { clientTransport, requestedVersions } = fakeInitializeServer();
40+
41+
await expect(client.connect(clientTransport)).rejects.toThrow('initialize cannot negotiate draft protocol versions');
42+
// The handshake never started: nothing was put on the wire.
43+
expect(requestedVersions).toEqual([]);
44+
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
45+
});
46+
47+
it('rejects an initialize result carrying a draft version, even when the draft is listed as supported', async () => {
48+
const client = new Client(
49+
{ name: 'test-client', version: '1.0.0' },
50+
{ supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026, LATEST_PROTOCOL_VERSION] }
51+
);
52+
// Nonconforming server: answers the released-version request with the draft version.
53+
const { clientTransport } = fakeInitializeServer(DRAFT_PROTOCOL_VERSION_2026);
54+
55+
await expect(client.connect(clientTransport)).rejects.toThrow(
56+
`Server's protocol version is not supported: ${DRAFT_PROTOCOL_VERSION_2026}`
57+
);
58+
// Nothing was recorded: the draft version is not negotiated in either direction.
59+
expect(client.getNegotiatedProtocolVersion()).toBeUndefined();
60+
});
61+
});
62+
});

packages/core/src/shared/protocol.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,12 @@ export type ProgressCallback = (progress: Progress) => void;
6262
*/
6363
export type ProtocolOptions = {
6464
/**
65-
* Protocol versions supported. First version is preferred (sent by client,
66-
* used as fallback by server). Passed to transport during {@linkcode Protocol.connect | connect()}.
65+
* Protocol versions supported. The first released (non-draft) version is preferred: it is
66+
* sent by the client at initialize and used as the server's fallback. Passed to transport
67+
* during {@linkcode Protocol.connect | connect()}.
68+
*
69+
* Draft protocol versions (`DRAFT_PROTOCOL_VERSIONS`) are never negotiated via the
70+
* initialize handshake.
6771
*
6872
* @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS}
6973
*/

packages/core/src/types/constants.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ export const DRAFT_PROTOCOL_VERSION_2026 = 'DRAFT-2026-v1';
2424
*/
2525
export const DRAFT_PROTOCOL_VERSIONS = [DRAFT_PROTOCOL_VERSION_2026];
2626

27+
/**
28+
* Returns `true` when `version` is a draft (unreleased) protocol revision — one of
29+
* {@linkcode DRAFT_PROTOCOL_VERSIONS}.
30+
*
31+
* Draft versions are never negotiable via the initialize handshake: the client never requests one
32+
* at initialize, and the server neither accepts a requested draft nor falls back to one.
33+
* Per-request negotiation of draft versions arrives with a later release.
34+
*/
35+
export function isDraftProtocolVersion(version: string): boolean {
36+
return DRAFT_PROTOCOL_VERSIONS.includes(version);
37+
}
38+
2739
export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task';
2840

2941
/* JSON-RPC types */

packages/server/src/server/server.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
ElicitResultSchema,
4444
EmptyResultSchema,
4545
extractTaskManagerOptions,
46+
isDraftProtocolVersion,
4647
LATEST_PROTOCOL_VERSION,
4748
ListRootsResultSchema,
4849
LoggingLevelSchema,
@@ -430,9 +431,16 @@ export class Server extends Protocol<ServerContext> {
430431
this._clientCapabilities = request.params.capabilities;
431432
this._clientVersion = request.params.clientInfo;
432433

433-
const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion)
434+
// Draft protocol versions are never negotiable via initialize (per-request negotiation of
435+
// draft versions arrives with a later release): a requested draft version is treated as
436+
// unsupported, and draft versions are skipped when picking the fallback.
437+
const acceptsRequested = !isDraftProtocolVersion(requestedVersion) && this._supportedProtocolVersions.includes(requestedVersion);
438+
// TODO: a draft-only supportedProtocolVersions list has no released fallback; a follow-up
439+
// will respond with -32004 (unsupported protocol version) in that case. Until then, fall
440+
// back to the latest released version.
441+
const protocolVersion = acceptsRequested
434442
? requestedVersion
435-
: (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION);
443+
: (this._supportedProtocolVersions.find(version => !isDraftProtocolVersion(version)) ?? LATEST_PROTOCOL_VERSION);
436444

437445
this._negotiatedProtocolVersion = protocolVersion;
438446
this.transport?.setProtocolVersion?.(protocolVersion);

packages/server/test/server/server.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'
22
import type { ClientCapabilities, Implementation, ServerContext } from '@modelcontextprotocol/core';
33
import {
44
DEFAULT_NEGOTIATED_PROTOCOL_VERSION,
5+
DRAFT_PROTOCOL_VERSION_2026,
56
InitializeResultSchema,
67
InMemoryTransport,
78
isJSONRPCResultResponse,
@@ -133,6 +134,45 @@ describe('Server', () => {
133134
});
134135
});
135136

137+
// initialize never negotiates draft protocol versions: a requested draft is treated as
138+
// unsupported, and drafts are skipped when picking the fallback version.
139+
describe('initialize never negotiates draft protocol versions', () => {
140+
it('treats a requested draft version as unsupported and responds with the released version', async () => {
141+
const server = new Server(
142+
{ name: 'test', version: '1.0.0' },
143+
{
144+
capabilities: {},
145+
supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026]
146+
}
147+
);
148+
149+
const respondedVersion = await initializeServer(server, DRAFT_PROTOCOL_VERSION_2026);
150+
151+
expect(respondedVersion).toBe(LATEST_PROTOCOL_VERSION);
152+
expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION);
153+
154+
await server.close();
155+
});
156+
157+
it('skips draft versions when picking the fallback for an unsupported requested version', async () => {
158+
// Draft listed FIRST: the fallback must still be the released version, not the draft.
159+
const server = new Server(
160+
{ name: 'test', version: '1.0.0' },
161+
{
162+
capabilities: {},
163+
supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026, LATEST_PROTOCOL_VERSION]
164+
}
165+
);
166+
167+
const respondedVersion = await initializeServer(server, UNSUPPORTED_VERSION);
168+
169+
expect(respondedVersion).toBe(LATEST_PROTOCOL_VERSION);
170+
expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION);
171+
172+
await server.close();
173+
});
174+
});
175+
136176
describe('ctx.client / ctx.mcpReq.protocolVersion on the handler context', () => {
137177
/**
138178
* Connects the server, registers a ping handler that captures its ServerContext, drives the

test/e2e/requirements.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2605,6 +2605,12 @@ export const REQUIREMENTS: Record<string, Requirement> = {
26052605
behavior:
26062606
"When the server's negotiated protocol version is not in the client's supportedProtocolVersions list, client.connect() rejects and the connection is not established."
26072607
},
2608+
'lifecycle:version:initialize-never-negotiates-draft': {
2609+
source: 'sdk',
2610+
behavior:
2611+
'The initialize handshake never negotiates a draft protocol version: the client requests its first released (non-draft) supported version, the server neither accepts a requested draft nor falls back to one, and a client/server pair that both list the draft version first still negotiates the newest released version with the draft wire identifier never appearing in the handshake.',
2612+
note: 'The draft revision is per-request-only by design: per-request negotiation of draft versions arrives with a later release.'
2613+
},
26082614
'lifecycle:capability:list-empty-when-not-advertised': {
26092615
source: 'sdk',
26102616
behavior:

0 commit comments

Comments
 (0)