diff --git a/.changeset/draft-protocol-version-opt-in.md b/.changeset/draft-protocol-version-opt-in.md new file mode 100644 index 0000000000..6c24cbf239 --- /dev/null +++ b/.changeset/draft-protocol-version-opt-in.md @@ -0,0 +1,9 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Adds the `DRAFT_PROTOCOL_VERSION_2026` / `DRAFT_PROTOCOL_VERSIONS` constants and the `allowDraftVersions` option. Draft protocol versions can now be listed in `supportedProtocolVersions` when explicitly allowed; they are never negotiable by default and never appear in the default supported set. + +`supportedProtocolVersions` entries are now validated at construction: every entry must be a released protocol version (`SUPPORTED_PROTOCOL_VERSIONS`) or a known draft version (`DRAFT_PROTOCOL_VERSIONS`), and listing a draft version without `allowDraftVersions: true` throws. diff --git a/docs/client.md b/docs/client.md index 56c9e43d10..ee2b22d0f0 100644 --- a/docs/client.md +++ b/docs/client.md @@ -111,6 +111,12 @@ const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boole console.log(systemPrompt); ``` +### Protocol versions + +During initialization the client requests the first 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; every entry must be a released version or a known draft version, otherwise the constructor throws. + +Draft (unreleased) protocol revisions, listed in {@linkcode @modelcontextprotocol/client!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, never appear in the default set and require a two-key opt-in: list the draft version in `supportedProtocolVersions` **and** set `allowDraftVersions: true`. With only one of the two keys, construction throws. The opt-in only makes the configuration constructible — the SDK does not yet negotiate draft protocol versions, so an opted-in client still connects with the released protocol. + ## Authentication 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. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 0323c29b02..e73341a390 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -501,10 +501,17 @@ Type changes in handler context: > These task APIs are `@experimental` and may change without notice. -## 13. Client Behavioral Changes +## 13. Client / Server Behavioral Changes `Client.listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` now return empty results when the server lacks the corresponding capability (instead of sending the request). Set `enforceStrictCapabilities: true` in `ClientOptions` to throw an error instead. +`supportedProtocolVersions` (in `ClientOptions`/`ServerOptions`) is validated at construction. Every entry must be in `SUPPORTED_PROTOCOL_VERSIONS` (released versions) or `DRAFT_PROTOCOL_VERSIONS` (draft versions); unknown strings throw. Listing a draft version additionally requires `allowDraftVersions: true`. + +| v1 | v2 | +| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `supportedProtocolVersions: ['']` (accepted) | Released or known draft versions only; unknown strings throw at construction | +| — (draft versions did not exist as a concept) | `supportedProtocolVersions: [..., DRAFT_PROTOCOL_VERSION_2026], allowDraftVersions: true` (two-key opt-in) | + ## 14. Runtime-Specific JSON Schema Validators (Enhancement) The SDK now auto-selects the appropriate JSON Schema validator based on runtime: diff --git a/docs/migration.md b/docs/migration.md index 5e7df2c2a6..71cfe6b24a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -535,6 +535,33 @@ const client = new Client( ); ``` +### `supportedProtocolVersions` is validated at construction + +In v1, the `supportedProtocolVersions` option (on `ClientOptions`/`ServerOptions`) accepted arbitrary version strings. In v2, the constructor validates the list: every entry must be a released protocol version (one of `SUPPORTED_PROTOCOL_VERSIONS`) or a known draft version (one of `DRAFT_PROTOCOL_VERSIONS`). Unknown strings throw. Draft versions additionally +require the explicit `allowDraftVersions: true` opt-in — and even then they are never negotiated by default; the opt-in only makes the configuration constructible. + +**Before (v1):** + +```typescript +// Any version string was accepted +const server = new Server({ name: 'my-server', version: '1.0.0' }, { supportedProtocolVersions: ['2099-01-01'] }); +``` + +**After (v2):** + +```typescript +import { DRAFT_PROTOCOL_VERSION_2026, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; + +// Released versions work as before; draft versions require the two-key opt-in +const server = new Server( + { name: 'my-server', version: '1.0.0' }, + { + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION_2026], + allowDraftVersions: true + } +); +``` + ### `InMemoryTransport` moved `InMemoryTransport` is now exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server` (both re-export it). It is still intended for in-process client-server connections and testing. diff --git a/docs/server.md b/docs/server.md index 996bf353ad..1a569f7b51 100644 --- a/docs/server.md +++ b/docs/server.md @@ -62,6 +62,12 @@ const transport = new StdioServerTransport(); await server.connect(transport); ``` +### Protocol versions + +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; every entry must be a released version or a known draft version, otherwise the constructor throws. + +Draft (unreleased) protocol revisions, listed in {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, never appear in the default set and require a two-key opt-in: list the draft version in `supportedProtocolVersions` **and** set `allowDraftVersions: true`. With only one of the two keys, construction throws. The opt-in only makes the configuration constructible — the SDK does not yet negotiate or serve draft protocol versions, so an opted-in server still serves released-protocol traffic. + ## Server instructions 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. diff --git a/examples/server/src/customProtocolVersion.ts b/examples/server/src/customProtocolVersion.ts index c580432e4b..6805701658 100644 --- a/examples/server/src/customProtocolVersion.ts +++ b/examples/server/src/customProtocolVersion.ts @@ -1,8 +1,12 @@ /** * Example: Custom Protocol Version Support * - * This demonstrates how to support protocol versions not yet in the SDK. - * First version in the list is used as fallback when client requests + * This demonstrates how to customize the protocol versions a server negotiates. + * The supported list may contain released protocol versions (SUPPORTED_PROTOCOL_VERSIONS) + * and — with the explicit allowDraftVersions opt-in — draft versions (DRAFT_PROTOCOL_VERSIONS). + * Unknown version strings are rejected at construction. + * + * First version in the list is used as fallback when a client requests * an unsupported version. * * Run with: pnpm tsx src/customProtocolVersion.ts @@ -13,15 +17,21 @@ import { createServer } from 'node:http'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult } from '@modelcontextprotocol/server'; -import { McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; +import { DRAFT_PROTOCOL_VERSION_2026, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; -// Add support for a newer protocol version (first in list is fallback) -const CUSTOM_VERSIONS = ['2026-01-01', ...SUPPORTED_PROTOCOL_VERSIONS]; +// Opt in to the draft protocol revision in addition to all released versions. +// Two keys are required: the draft version must be listed explicitly AND +// allowDraftVersions must be true — otherwise construction throws. +// Note: the opt-in only makes this configuration constructible; the SDK does not +// yet negotiate or serve draft protocol versions, so this server still serves +// released-protocol traffic. +const CUSTOM_VERSIONS = [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION_2026]; const server = new McpServer( { name: 'custom-protocol-server', version: '1.0.0' }, { supportedProtocolVersions: CUSTOM_VERSIONS, + allowDraftVersions: true, capabilities: { tools: {} } } ); diff --git a/packages/client/test/client/client.test.ts b/packages/client/test/client/client.test.ts new file mode 100644 index 0000000000..5e7d1d94bf --- /dev/null +++ b/packages/client/test/client/client.test.ts @@ -0,0 +1,29 @@ +import { DRAFT_PROTOCOL_VERSION_2026, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; + +import { Client } from '../../src/client/client.js'; + +describe('Client', () => { + // The validation itself lives in the shared Protocol constructor (covered in depth by the core + // package's protocol.test.ts); these are smoke tests that ClientOptions passes both keys through. + describe('draft protocol version opt-in (allowDraftVersions)', () => { + it('throws at construction when a draft version is listed without allowDraftVersions', () => { + const construct = () => + new Client({ name: 'test-client', version: '1.0.0' }, { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026] }); + + expect(construct).toThrow(DRAFT_PROTOCOL_VERSION_2026); + expect(construct).toThrow('allowDraftVersions'); + }); + + it('constructs when a draft version is listed and allowDraftVersions is true', () => { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { + supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026], + allowDraftVersions: true + } + ); + + expect(client).toBeInstanceOf(Client); + }); + }); +}); diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 913b948ac8..3b7ab2c349 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -86,6 +86,8 @@ export * from '../../types/types.js'; // Constants export { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + DRAFT_PROTOCOL_VERSION_2026, + DRAFT_PROTOCOL_VERSIONS, INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 1cac50a7b6..fc47b5c07f 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -35,6 +35,7 @@ import type { } from '../types/index.js'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + DRAFT_PROTOCOL_VERSIONS, getNotificationSchema, getRequestSchema, getResultSchema, @@ -65,10 +66,31 @@ export type ProtocolOptions = { * Protocol versions supported. First version is preferred (sent by client, * used as fallback by server). Passed to transport during {@linkcode Protocol.connect | connect()}. * + * Every listed version must be either a released protocol version (one of + * {@linkcode SUPPORTED_PROTOCOL_VERSIONS}) or a known draft version (one of + * {@linkcode DRAFT_PROTOCOL_VERSIONS}); an unknown version string makes the constructor throw. + * Listing a draft version additionally requires {@linkcode ProtocolOptions.allowDraftVersions | allowDraftVersions}. + * * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ supportedProtocolVersions?: string[]; + /** + * Opt-in for draft (unreleased) protocol versions. + * + * Draft versions use a two-key model: a draft version (one of {@linkcode DRAFT_PROTOCOL_VERSIONS}) + * must be listed explicitly in {@linkcode ProtocolOptions.supportedProtocolVersions | supportedProtocolVersions} + * AND this flag must be `true`. Listing a draft version without this flag makes the constructor + * throw; setting this flag without listing a draft version has no effect. + * + * Opting in only makes the configuration constructible. Draft versions are never part of the + * default supported set, and the SDK does not yet negotiate or serve draft protocol versions — + * an opted-in client or server still speaks the released protocol. + * + * @default false + */ + allowDraftVersions?: boolean; + /** * Whether to restrict emitted requests to only those that the remote side has indicated that they can handle, through their advertised capabilities. * @@ -341,6 +363,29 @@ type TimeoutInfo = { onTimeout: () => void; }; +/** + * Validates a user-supplied `supportedProtocolVersions` list: every entry must be a released + * protocol version or a known draft version, and draft versions require the `allowDraftVersions` + * opt-in (the two-key model — see {@linkcode ProtocolOptions.allowDraftVersions}). + */ +function validateSupportedProtocolVersions(versions: string[], allowDraftVersions: boolean): void { + for (const version of versions) { + if (DRAFT_PROTOCOL_VERSIONS.includes(version)) { + if (!allowDraftVersions) { + throw new Error( + `Protocol version '${version}' is a draft version: listing it in supportedProtocolVersions additionally requires the allowDraftVersions option to be true` + ); + } + } else if (!SUPPORTED_PROTOCOL_VERSIONS.includes(version)) { + throw new Error( + `Unknown protocol version '${version}' in supportedProtocolVersions: it is neither a released protocol version (${SUPPORTED_PROTOCOL_VERSIONS.join( + ', ' + )}) nor a known draft version (${DRAFT_PROTOCOL_VERSIONS.join(', ')})` + ); + } + } +} + /** * Implements MCP protocol framing on top of a pluggable transport, including * features like request/response linking, notifications, and progress. @@ -395,6 +440,9 @@ export abstract class Protocol { fallbackNotificationHandler?: (notification: Notification) => Promise; constructor(private _options?: ProtocolOptions) { + if (_options?.supportedProtocolVersions) { + validateSupportedProtocolVersions(_options.supportedProtocolVersions, _options.allowDraftVersions === true); + } this._supportedProtocolVersions = _options?.supportedProtocolVersions ?? SUPPORTED_PROTOCOL_VERSIONS; // Create TaskManager from protocol options diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 878d5111cf..751b4a9c04 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -2,6 +2,28 @@ export const LATEST_PROTOCOL_VERSION = '2025-11-25'; export const DEFAULT_NEGOTIATED_PROTOCOL_VERSION = '2025-03-26'; export const SUPPORTED_PROTOCOL_VERSIONS = [LATEST_PROTOCOL_VERSION, '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; +/** + * The wire identifier of the draft (unreleased) protocol revision. + * + * The literal mirrors `LATEST_PROTOCOL_VERSION` in the draft specification schema + * (https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts), + * which is the source of truth for this value. + * + * Draft protocol versions are not negotiable and require explicit opt-in: they never appear in + * {@linkcode SUPPORTED_PROTOCOL_VERSIONS}, and listing one in `supportedProtocolVersions` + * additionally requires the `allowDraftVersions` option to be `true` (otherwise construction throws). + */ +export const DRAFT_PROTOCOL_VERSION_2026 = 'DRAFT-2026-v1'; + +/** + * All draft (unreleased) protocol revisions known to this SDK. + * + * Draft versions are kept separate from {@linkcode SUPPORTED_PROTOCOL_VERSIONS}: they are never + * negotiated or served by default, and may only be listed in `supportedProtocolVersions` together + * with the explicit `allowDraftVersions` opt-in. + */ +export const DRAFT_PROTOCOL_VERSIONS: readonly string[] = [DRAFT_PROTOCOL_VERSION_2026]; + export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; /* JSON-RPC types */ diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index d58456a70f..5d0be767fe 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -34,7 +34,16 @@ import type { Task, TaskCreationParams } from '../../src/types/index.js'; -import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, ProtocolError, ProtocolErrorCode, RELATED_TASK_META_KEY } from '../../src/types/index.js'; +import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + DRAFT_PROTOCOL_VERSION_2026, + DRAFT_PROTOCOL_VERSIONS, + LATEST_PROTOCOL_VERSION, + ProtocolError, + ProtocolErrorCode, + RELATED_TASK_META_KEY, + SUPPORTED_PROTOCOL_VERSIONS +} from '../../src/types/index.js'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; // Test Protocol subclass for testing @@ -5729,3 +5738,80 @@ describe('ctx.mcpReq.protocolVersion population', () => { expect(await captured).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); }); }); + +describe('draft protocol version opt-in (allowDraftVersions)', () => { + /** Reads the protected supported-versions list a Protocol instance settled on. */ + function supportedVersionsOf(p: Protocol): string[] { + return (p as unknown as { _supportedProtocolVersions: string[] })._supportedProtocolVersions; + } + + test('draft versions never appear in the default supported set', () => { + for (const draft of DRAFT_PROTOCOL_VERSIONS) { + expect(SUPPORTED_PROTOCOL_VERSIONS).not.toContain(draft); + } + expect(DRAFT_PROTOCOL_VERSIONS).toContain(DRAFT_PROTOCOL_VERSION_2026); + }); + + test('default construction is unchanged: the supported set is the released versions', () => { + const p = new TestProtocolImpl(); + expect(supportedVersionsOf(p)).toEqual(SUPPORTED_PROTOCOL_VERSIONS); + }); + + test('construction succeeds when a draft version is listed and allowDraftVersions is true', () => { + const p = new TestProtocolImpl({ + supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026], + allowDraftVersions: true + }); + expect(supportedVersionsOf(p)).toEqual([LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026]); + }); + + test('construction succeeds for a draft-only list when allowDraftVersions is true', () => { + const p = new TestProtocolImpl({ + supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026], + allowDraftVersions: true + }); + expect(supportedVersionsOf(p)).toEqual([DRAFT_PROTOCOL_VERSION_2026]); + }); + + test('construction throws when a draft version is listed without the flag, naming the version and the flag', () => { + const construct = () => new TestProtocolImpl({ supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026] }); + expect(construct).toThrow(DRAFT_PROTOCOL_VERSION_2026); + expect(construct).toThrow('allowDraftVersions'); + }); + + test('allowDraftVersions: false behaves exactly like omitting the flag', () => { + expect( + () => + new TestProtocolImpl({ + supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026], + allowDraftVersions: false + }) + ).toThrow('allowDraftVersions'); + }); + + test('construction throws on unknown protocol version strings', () => { + const construct = () => new TestProtocolImpl({ supportedProtocolVersions: ['2099-12-31'] }); + expect(construct).toThrow("Unknown protocol version '2099-12-31'"); + }); + + test('the flag does not bypass unknown-version validation', () => { + expect(() => new TestProtocolImpl({ supportedProtocolVersions: ['2099-12-31'], allowDraftVersions: true })).toThrow( + "Unknown protocol version '2099-12-31'" + ); + }); + + test('the flag alone (no supportedProtocolVersions) is a no-op', () => { + const p = new TestProtocolImpl({ allowDraftVersions: true }); + expect(supportedVersionsOf(p)).toEqual(SUPPORTED_PROTOCOL_VERSIONS); + }); + + test('the flag alone (released versions listed, no draft) is a no-op', () => { + const p = new TestProtocolImpl({ supportedProtocolVersions: [LATEST_PROTOCOL_VERSION], allowDraftVersions: true }); + expect(supportedVersionsOf(p)).toEqual([LATEST_PROTOCOL_VERSION]); + }); + + test('released versions continue to be accepted without the flag', () => { + const p = new TestProtocolImpl({ supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS] }); + expect(supportedVersionsOf(p)).toEqual(SUPPORTED_PROTOCOL_VERSIONS); + }); +}); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 36016a0939..a80b9cfc57 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -2,6 +2,7 @@ import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core' import type { ClientCapabilities, Implementation, ServerContext } from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + DRAFT_PROTOCOL_VERSION_2026, InitializeResultSchema, InMemoryTransport, isJSONRPCResultResponse, @@ -133,6 +134,34 @@ describe('Server', () => { }); }); + // The validation itself lives in the shared Protocol constructor (covered in depth by the core + // package's protocol.test.ts); these are smoke tests that ServerOptions passes both keys through. + describe('draft protocol version opt-in (allowDraftVersions)', () => { + it('throws at construction when a draft version is listed without allowDraftVersions', () => { + const construct = () => + new Server( + { name: 'test', version: '1.0.0' }, + { capabilities: {}, supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026] } + ); + + expect(construct).toThrow(DRAFT_PROTOCOL_VERSION_2026); + expect(construct).toThrow('allowDraftVersions'); + }); + + it('constructs when a draft version is listed and allowDraftVersions is true', () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { + capabilities: {}, + supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026], + allowDraftVersions: true + } + ); + + expect(server).toBeInstanceOf(Server); + }); + }); + describe('ctx.client / ctx.mcpReq.protocolVersion on the handler context', () => { /** * Connects the server, registers a ping handler that captures its ServerContext, drives the diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index fe1d111f4d..ebbf725ae0 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2605,6 +2605,13 @@ export const REQUIREMENTS: Record = { behavior: "When the server's negotiated protocol version is not in the client's supportedProtocolVersions list, client.connect() rejects and the connection is not established." }, + 'lifecycle:version:draft-pin-requires-opt-in': { + source: 'sdk', + behavior: + 'Constructing a client or server whose supportedProtocolVersions includes a draft protocol version requires allowDraftVersions: true; construction throws otherwise. Draft versions never appear in the default supported set.', + transports: ['inMemory'], + note: 'Construction-time validation is transport-independent, so it runs as a single inMemory-labelled cell to avoid duplicate runs. This entry covers only the two-key construction opt-in; refusing to *negotiate* a version outside the configured list is the canonical requirement lifecycle:version:2026-pin-per-instance (not yet in this manifest — it lands with the unsupported-version error surface).' + }, 'lifecycle:capability:list-empty-when-not-advertised': { source: 'sdk', behavior: diff --git a/test/e2e/scenarios/lifecycle.test.ts b/test/e2e/scenarios/lifecycle.test.ts index 2fba4a84e8..00582b82ce 100644 --- a/test/e2e/scenarios/lifecycle.test.ts +++ b/test/e2e/scenarios/lifecycle.test.ts @@ -19,6 +19,8 @@ import type { ServerCapabilities } from '@modelcontextprotocol/server'; import { + DRAFT_PROTOCOL_VERSION_2026, + DRAFT_PROTOCOL_VERSIONS, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, @@ -544,6 +546,42 @@ verifies('lifecycle:version:no-overlap-rejects', async ({ transport }: TestArgs) expect(client.getServerVersion()).toBeUndefined(); }); +verifies('lifecycle:version:draft-pin-requires-opt-in', async ({ transport }: TestArgs) => { + // Draft versions are a separate set and never appear in the default supported versions. + for (const draft of DRAFT_PROTOCOL_VERSIONS) { + expect(SUPPORTED_PROTOCOL_VERSIONS).not.toContain(draft); + } + + // One key is not enough: listing a draft version without allowDraftVersions throws at construction, for both roles. + const constructClient = () => + new Client({ name: 'draft-pin-client', version: '0.0.0' }, { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026] }); + const constructServer = () => + new McpServer({ name: 'draft-pin-server', version: '0.0.0' }, { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026] }); + expect(constructClient).toThrow(DRAFT_PROTOCOL_VERSION_2026); + expect(constructClient).toThrow('allowDraftVersions'); + expect(constructServer).toThrow(DRAFT_PROTOCOL_VERSION_2026); + expect(constructServer).toThrow('allowDraftVersions'); + + // The flag alone (no draft version listed) is a no-op: construction succeeds with the default supported set. + expect(() => new Client({ name: 'flag-only-client', version: '0.0.0' }, { allowDraftVersions: true })).not.toThrow(); + + // Both keys turned: construction succeeds for both roles, and the connection still negotiates the + // released version — opting in does not make the draft version negotiable. + const makeServer = () => + new McpServer( + { name: 'draft-optin-server', version: '0.0.0' }, + { supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026], allowDraftVersions: true } + ); + const client = new Client( + { name: 'draft-optin-client', version: '0.0.0' }, + { supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026], allowDraftVersions: true } + ); + + await using _ = await wire(transport, makeServer, client); + + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); +}); + verifies('lifecycle:capability:list-empty-when-not-advertised', async ({ transport }: TestArgs) => { // Bare McpServer with no registrations advertises no tools/prompts/resources capabilities. const client = minimalClient();