diff --git a/.changeset/draft-protocol-version-constants.md b/.changeset/draft-protocol-version-constants.md new file mode 100644 index 000000000..28d6b74b6 --- /dev/null +++ b/.changeset/draft-protocol-version-constants.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Add `STATEFUL_PROTOCOL_VERSIONS` (the closed list of protocol versions negotiated via the `initialize` handshake) and `DRAFT_PROTOCOL_VERSION_2026` / `DRAFT_PROTOCOL_VERSIONS` constants. Protocol revisions after 2025-11-25 are never negotiated via `initialize`: clients request +and servers accept/fall back to stateful versions only. Behavior change: `supportedProtocolVersions` entries outside the stateful list (custom or future strings) no longer participate in the handshake — see migration.md. diff --git a/docs/client.md b/docs/client.md index 56c9e43d1..e0a8f269c 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 stateful entry of its supported version list and accepts a response within that stateful subset — by default the versions in {@linkcode @modelcontextprotocol/client!index.SUPPORTED_PROTOCOL_VERSIONS | SUPPORTED_PROTOCOL_VERSIONS}. Pass `supportedProtocolVersions` in the client options to restrict or reorder that list. + +Only the stateful protocol versions in {@linkcode @modelcontextprotocol/client!index.STATEFUL_PROTOCOL_VERSIONS | STATEFUL_PROTOCOL_VERSIONS} negotiate via the initialize handshake. Every revision after 2025-11-25 — including the draft revision in {@linkcode @modelcontextprotocol/client!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS} — is stateless and negotiates per-request, which arrives with a later release. + ## 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 0323c29b0..43ba7bb48 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -47,25 +47,25 @@ Replace all `@modelcontextprotocol/sdk/...` imports using this table. ### Server imports -| v1 import path | v2 package | -| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@modelcontextprotocol/sdk/server/mcp.js` | `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/server/index.js` | `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/server/stdio.js` | `@modelcontextprotocol/server/stdio` | -| `@modelcontextprotocol/sdk/server/streamableHttp.js` | `@modelcontextprotocol/node` (class renamed to `NodeStreamableHTTPServerTransport`) OR `@modelcontextprotocol/server` (web-standard `WebStandardStreamableHTTPServerTransport` for Cloudflare Workers, Deno, etc.) | -| `@modelcontextprotocol/sdk/server/sse.js` | REMOVED (migrate to Streamable HTTP); legacy bridge: `@modelcontextprotocol/server-legacy/sse` | +| v1 import path | v2 package | +| ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/sdk/server/mcp.js` | `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/server/index.js` | `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/server/stdio.js` | `@modelcontextprotocol/server/stdio` | +| `@modelcontextprotocol/sdk/server/streamableHttp.js` | `@modelcontextprotocol/node` (class renamed to `NodeStreamableHTTPServerTransport`) OR `@modelcontextprotocol/server` (web-standard `WebStandardStreamableHTTPServerTransport` for Cloudflare Workers, Deno, etc.) | +| `@modelcontextprotocol/sdk/server/sse.js` | REMOVED (migrate to Streamable HTTP); legacy bridge: `@modelcontextprotocol/server-legacy/sse` | | `@modelcontextprotocol/sdk/server/auth/*` | RS helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`; AS helpers (`mcpAuthRouter`, `OAuthServerProvider`, etc.) → `@modelcontextprotocol/server-legacy/auth` (deprecated); migrate AS to an external IdP/OAuth library | -| `@modelcontextprotocol/sdk/server/middleware.js` | `@modelcontextprotocol/express` (signature changed, see section 8) | +| `@modelcontextprotocol/sdk/server/middleware.js` | `@modelcontextprotocol/express` (signature changed, see section 8) | ### Types / shared imports -| v1 import path | v2 package | -| ------------------------------------------------- | ---------------------------------------------------------------- | -| `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/shared/uriTemplate.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | -| `@modelcontextprotocol/sdk/shared/auth.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| v1 import path | v2 package | +| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/sdk/types.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/shared/protocol.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/shared/transport.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/shared/uriTemplate.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | +| `@modelcontextprotocol/sdk/shared/auth.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` | | `@modelcontextprotocol/sdk/shared/stdio.js` | `@modelcontextprotocol/client` or `@modelcontextprotocol/server` (`ReadBuffer`, `serializeMessage`, `deserializeMessage` are in the root barrel; the `./stdio` subpath only has the transport class) | Notes: @@ -109,20 +109,20 @@ Three error classes now exist: - **`SdkError`** (new): Local SDK errors that never cross the wire - **`SdkHttpError`** (extends `SdkError`): HTTP transport errors with typed `.status` and `.statusText` accessors -| Error scenario | v1 type | v2 type | -| --------------------------------- | -------------------------------------------- | ----------------------------------------------------------------- | -| Request timeout | `McpError` with `ErrorCode.RequestTimeout` | `SdkError` with `SdkErrorCode.RequestTimeout` | -| Connection closed | `McpError` with `ErrorCode.ConnectionClosed` | `SdkError` with `SdkErrorCode.ConnectionClosed` | -| Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` | -| Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` | -| Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` | -| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | -| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` | -| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` | -| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` | -| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` | +| Error scenario | v1 type | v2 type | +| --------------------------------- | -------------------------------------------- | --------------------------------------------------------------------- | +| Request timeout | `McpError` with `ErrorCode.RequestTimeout` | `SdkError` with `SdkErrorCode.RequestTimeout` | +| Connection closed | `McpError` with `ErrorCode.ConnectionClosed` | `SdkError` with `SdkErrorCode.ConnectionClosed` | +| Capability not supported | `new Error(...)` | `SdkError` with `SdkErrorCode.CapabilityNotSupported` | +| Not connected | `new Error('Not connected')` | `SdkError` with `SdkErrorCode.NotConnected` | +| Invalid params (server response) | `McpError` with `ErrorCode.InvalidParams` | `ProtocolError` with `ProtocolErrorCode.InvalidParams` | +| HTTP transport error | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttp*` | +| Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToOpenStream` | +| 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpAuthentication` | +| 403 after upscoping | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpForbidden` | +| Unexpected content type | `StreamableHTTPError` | `SdkError` with `SdkErrorCode.ClientHttpUnexpectedContent` | | Session termination failed | `StreamableHTTPError` | `SdkHttpError` with `SdkErrorCode.ClientHttpFailedToTerminateSession` | -| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` | +| Response result fails schema | `ZodError` (raw) | `SdkError` with `SdkErrorCode.InvalidResult` | New `SdkErrorCode` enum values: @@ -164,11 +164,11 @@ if (error instanceof StreamableHTTPError) { // v2 import { SdkHttpError, SdkErrorCode } from '@modelcontextprotocol/client'; if (error instanceof SdkHttpError) { - console.log('HTTP status:', error.status); // number — typed accessor + console.log('HTTP status:', error.status); // number — typed accessor console.log('Status text:', error.statusText); // string | undefined switch (error.code) { - case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth - case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping + case SdkErrorCode.ClientHttpAuthentication: // 401 after re-auth + case SdkErrorCode.ClientHttpForbidden: // 403 after upscoping case SdkErrorCode.ClientHttpFailedToOpenStream: case SdkErrorCode.ClientHttpNotImplemented: break; @@ -328,11 +328,13 @@ new URL(ctx.http?.req?.url).searchParams.get('debug') ### SSE server transport -`SSEServerTransport` removed entirely. Migrate to `NodeStreamableHTTPServerTransport` (from `@modelcontextprotocol/node`). Client-side `SSEClientTransport` still available for connecting to legacy servers. Legacy bridge: `import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'` (deprecated, frozen v1 copy). +`SSEServerTransport` removed entirely. Migrate to `NodeStreamableHTTPServerTransport` (from `@modelcontextprotocol/node`). Client-side `SSEClientTransport` still available for connecting to legacy servers. Legacy bridge: +`import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'` (deprecated, frozen v1 copy). ### Server-side auth -Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) are available from `@modelcontextprotocol/server-legacy/auth` (deprecated, frozen v1 copy). Migrate AS to an external IdP/OAuth library for production use. See `examples/server/src/` for demos. +Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, +`ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) are available from `@modelcontextprotocol/server-legacy/auth` (deprecated, frozen v1 copy). Migrate AS to an external IdP/OAuth library for production use. See `examples/server/src/` for demos. ### Host header validation (Express) @@ -473,12 +475,12 @@ Remove unused schema imports: `CallToolResultSchema`, `CompatibilityCallToolResu If a `*Schema` constant was used for **runtime validation** (not just as a `request()` argument), replace with `isSpecType` / `specTypeSchemas`: -| v1 pattern | v2 replacement | -| -------------------------------------------------- | -------------------------------------------------------------------------------------- | -| `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` | -| `Schema.safeParse(value).success` | `isSpecType.(value)` | +| v1 pattern | v2 replacement | +| -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `CallToolResultSchema.safeParse(value).success` | `isSpecType.CallToolResult(value)` | +| `Schema.safeParse(value).success` | `isSpecType.(value)` | | `Schema.parse(value)` | `specTypeSchemas.['~standard'].validate(value)` (returns a `Result` synchronously, not the value) | -| Passing `Schema` as a validator argument | `specTypeSchemas.` (a `StandardSchemaV1Sync`) | +| Passing `Schema` as a validator argument | `specTypeSchemas.` (a `StandardSchemaV1Sync`) | `isCallToolResult(value)` still works, but `isSpecType` covers every spec type by name. @@ -505,6 +507,9 @@ Type changes in handler context: `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. +- `initialize` version negotiation is restricted to `STATEFUL_PROTOCOL_VERSIONS` (released versions ≤ 2025-11-25). Unknown/future strings in `supportedProtocolVersions` are ignored by the handshake; a list with none throws on `connect()`. Server fallback picks the first stateful + entry. + ## 14. Runtime-Specific JSON Schema Validators (Enhancement) The SDK now auto-selects the appropriate JSON Schema validator based on runtime: @@ -531,7 +536,8 @@ Validator behavior: - Do not add validator imports for normal migrations. - Do not install `ajv`, `ajv-formats`, or `@cfworker/json-schema` for the default path; client/server bundle the runtime-selected defaults and the root entry point does not pull either dep in. -- To customize the built-in backend (e.g. register custom AJV formats, change `@cfworker/json-schema` draft), import the named class from the package subpath: `@modelcontextprotocol/{client,server}/validators/ajv` for `AjvJsonSchemaValidator`, `@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`. +- To customize the built-in backend (e.g. register custom AJV formats, change `@cfworker/json-schema` draft), import the named class from the package subpath: `@modelcontextprotocol/{client,server}/validators/ajv` for `AjvJsonSchemaValidator`, + `@modelcontextprotocol/{client,server}/validators/cf-worker` for `CfWorkerJsonSchemaValidator`. Importing from a subpath means the corresponding peer dep must be in your `package.json`. - To replace validation entirely, pass `jsonSchemaValidator: myCustomValidator` with your own implementation of the `jsonSchemaValidator` interface. ## 15. Migration Steps (apply in this order) diff --git a/docs/migration.md b/docs/migration.md index 5e7df2c2a..b57e67163 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -145,7 +145,8 @@ const transport = new StreamableHTTPClientTransport(new URL('http://localhost:30 Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) are first-class in `@modelcontextprotocol/express`. -Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. See the [examples](../examples/server/src/) for a working demo with `better-auth`. +Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `authenticateClient`, `allowedMethods`, etc.) have been removed from the core SDK; new code should use a dedicated IdP/OAuth library. See the [examples](../examples/server/src/) for +a working demo with `better-auth`. Note: `AuthInfo` has moved from `server/auth/types.ts` to the core types and is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. @@ -387,7 +388,11 @@ const AcmeSearch = z.object({ params: z.object({ query: z.string(), limit: z.number().int() }) }); server.setRequestHandler(AcmeSearch, async request => { - return { items: [/* ... */] }; + return { + items: [ + /* ... */ + ] + }; }); ``` @@ -398,7 +403,11 @@ const SearchParams = z.object({ query: z.string(), limit: z.number().int() }); const SearchResult = z.object({ items: z.array(z.string()) }); server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { - return { items: [/* ... */] }; + return { + items: [ + /* ... */ + ] + }; }); ``` @@ -437,8 +446,8 @@ Common method string replacements: ### `Protocol.request()`, `ctx.mcpReq.send()`, and `Client.callTool()` no longer require a schema parameter for spec methods -For **spec** methods, the public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer require a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to import result schemas -like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests. +For **spec** methods, the public `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` methods no longer require a Zod result schema argument. The SDK now resolves the correct result schema internally based on the method name. This means you no longer need to +import result schemas like `CallToolResultSchema` or `ElicitResultSchema` when making spec-method requests. **`client.request()` — Before (v1):** @@ -518,7 +527,8 @@ import { specTypeSchemas } from '@modelcontextprotocol/client'; const result = specTypeSchemas.CallToolResult['~standard'].validate(value); ``` -`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync` — `validate()` returns the result synchronously, so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works. +`isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync` — `validate()` returns the result synchronously, +so you can access `.issues` / `.value` without `await`. It composes with any Standard-Schema-aware library. The pre-existing `isCallToolResult(value)` guard still works. ### Client list methods return empty results for missing capabilities @@ -718,22 +728,22 @@ try { The new `SdkErrorCode` enum contains string-valued codes for local SDK errors: -| Code | Description | -| ------------------------------------------------- | ------------------------------------------- | -| `SdkErrorCode.NotConnected` | Transport is not connected | -| `SdkErrorCode.AlreadyConnected` | Transport is already connected | -| `SdkErrorCode.NotInitialized` | Protocol is not initialized | -| `SdkErrorCode.CapabilityNotSupported` | Required capability is not supported | -| `SdkErrorCode.RequestTimeout` | Request timed out waiting for response | -| `SdkErrorCode.ConnectionClosed` | Connection was closed | -| `SdkErrorCode.SendFailed` | Failed to send message | +| Code | Description | +| ------------------------------------------------- | ---------------------------------------------- | +| `SdkErrorCode.NotConnected` | Transport is not connected | +| `SdkErrorCode.AlreadyConnected` | Transport is already connected | +| `SdkErrorCode.NotInitialized` | Protocol is not initialized | +| `SdkErrorCode.CapabilityNotSupported` | Required capability is not supported | +| `SdkErrorCode.RequestTimeout` | Request timed out waiting for response | +| `SdkErrorCode.ConnectionClosed` | Connection was closed | +| `SdkErrorCode.SendFailed` | Failed to send message | | `SdkErrorCode.InvalidResult` | Response result failed local schema validation | -| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed | -| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication | -| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping | -| `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | -| `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream | -| `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session | +| `SdkErrorCode.ClientHttpNotImplemented` | HTTP POST request failed | +| `SdkErrorCode.ClientHttpAuthentication` | Server returned 401 after re-authentication | +| `SdkErrorCode.ClientHttpForbidden` | Server returned 403 after trying upscoping | +| `SdkErrorCode.ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | +| `SdkErrorCode.ClientHttpFailedToOpenStream` | Failed to open SSE stream | +| `SdkErrorCode.ClientHttpFailedToTerminateSession` | Failed to terminate session | #### `StreamableHTTPError` removed @@ -762,7 +772,7 @@ try { await transport.send(message); } catch (error) { if (error instanceof SdkHttpError) { - console.log('HTTP status:', error.status); // number — no cast needed + console.log('HTTP status:', error.status); // number — no cast needed console.log('Status text:', error.statusText); // string | undefined switch (error.code) { case SdkErrorCode.ClientHttpAuthentication: @@ -914,6 +924,11 @@ server.setRequestHandler('tools/call', async (request, ctx) => { > **Note:** These task APIs are marked `@experimental` and may change without notice. +### `initialize` negotiates only known stateful protocol versions + +`supportedProtocolVersions` entries outside `STATEFUL_PROTOCOL_VERSIONS` (the released versions up to 2025-11-25) no longer participate in the `initialize` handshake: clients request, and servers accept and fall back to, the first known stateful version in the list. A list +containing no stateful version makes `connect()` throw. Custom or future version strings were previously sent as-is; revisions after 2025-11-25 negotiate per-request instead (arriving in a later release), and newly released stateful versions require an SDK update. + ## Enhancements ### Automatic JSON Schema validator selection by runtime @@ -952,7 +967,8 @@ const server = new McpServer( ); ``` -You do not need to install or import validator packages for the default behavior. The client and server packages bundle the validator backend selected by the runtime shim, so a normal `import { McpServer } from '@modelcontextprotocol/server'` does not pull `ajv` or `@cfworker/json-schema` into your bundle until you choose to customize. +You do not need to install or import validator packages for the default behavior. The client and server packages bundle the validator backend selected by the runtime shim, so a normal `import { McpServer } from '@modelcontextprotocol/server'` does not pull `ajv` or +`@cfworker/json-schema` into your bundle until you choose to customize. If you want to customize the **built-in** backend (for example, pre-register schemas by `$id`, register custom AJV formats, or change the `@cfworker/json-schema` draft), import the named class from the explicit subpath and pass an instance through `jsonSchemaValidator`: @@ -987,7 +1003,8 @@ const server = new McpServer( (both subpaths are also available on `@modelcontextprotocol/client/validators/...`) -If you import from one of these subpaths in your own code, the corresponding peer dep (`ajv` + `ajv-formats`, or `@cfworker/json-schema`) needs to be installed in your `package.json`. The runtime shim continues to vendor a copy for the default code path, so you can use the subpath in some files and rely on the default in others. +If you import from one of these subpaths in your own code, the corresponding peer dep (`ajv` + `ajv-formats`, or `@cfworker/json-schema`) needs to be installed in your `package.json`. The runtime shim continues to vendor a copy for the default code path, so you can use the +subpath in some files and rely on the default in others. To replace validation wholesale rather than customizing the built-in classes, implement the `jsonSchemaValidator` interface and pass your own implementation through the option above. diff --git a/docs/server.md b/docs/server.md index 996bf353a..fa2896742 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 the stateful subset of its supported list — by default the versions in {@linkcode @modelcontextprotocol/server!index.SUPPORTED_PROTOCOL_VERSIONS | SUPPORTED_PROTOCOL_VERSIONS}. Pass `supportedProtocolVersions` in the server options to restrict or reorder that list. + +Only the stateful protocol versions in {@linkcode @modelcontextprotocol/server!index.STATEFUL_PROTOCOL_VERSIONS | STATEFUL_PROTOCOL_VERSIONS} negotiate via the initialize handshake — the server neither accepts a newer revision nor falls back to one. Every revision after 2025-11-25, including the draft revision in {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSIONS | DRAFT_PROTOCOL_VERSIONS}, is stateless and negotiates per-request, which arrives with a later release. + ## 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 c580432e4..ca517f23b 100644 --- a/examples/server/src/customProtocolVersion.ts +++ b/examples/server/src/customProtocolVersion.ts @@ -1,9 +1,12 @@ /** - * Example: Custom Protocol Version Support + * Example: Restricting Protocol Versions * - * 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. + * Demonstrates pinning `supportedProtocolVersions` to a subset of the SDK's + * stateful versions (e.g. for compatibility testing against older clients). + * + * Only versions in STATEFUL_PROTOCOL_VERSIONS negotiate via the `initialize` + * handshake; revisions after 2025-11-25 negotiate per-request and are ignored + * by the handshake. * * Run with: pnpm tsx src/customProtocolVersion.ts */ @@ -13,15 +16,15 @@ 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 { McpServer, STATEFUL_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]; +// Pin to the two most recent stateful versions (newest first is preferred). +const PINNED_VERSIONS = STATEFUL_PROTOCOL_VERSIONS.slice(0, 2); const server = new McpServer( - { name: 'custom-protocol-server', version: '1.0.0' }, + { name: 'pinned-protocol-server', version: '1.0.0' }, { - supportedProtocolVersions: CUSTOM_VERSIONS, + supportedProtocolVersions: PINNED_VERSIONS, capabilities: { tools: {} } } ); @@ -37,7 +40,7 @@ server.registerTool( content: [ { type: 'text', - text: JSON.stringify({ supportedVersions: CUSTOM_VERSIONS }, null, 2) + text: JSON.stringify({ supportedVersions: PINNED_VERSIONS }, null, 2) } ] }) @@ -60,6 +63,6 @@ createServer(async (req, res) => { res.writeHead(404).end('Not Found'); } }).listen(PORT, () => { - console.log(`MCP server with custom protocol versions on port ${PORT}`); - console.log(`Supported versions: ${CUSTOM_VERSIONS.join(', ')}`); + console.log(`MCP server with pinned protocol versions on port ${PORT}`); + console.log(`Supported versions: ${PINNED_VERSIONS.join(', ')}`); }); diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 673e8f764..6843f78d0 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -49,7 +49,7 @@ import { extractTaskManagerOptions, GetPromptResultSchema, InitializeResultSchema, - LATEST_PROTOCOL_VERSION, + isStatefulProtocolVersion, ListChangedOptionsBaseSchema, ListPromptsResultSchema, ListResourcesResultSchema, @@ -492,11 +492,19 @@ export class Client extends Protocol { return; } try { + const statefulVersions = this._supportedProtocolVersions.filter(version => isStatefulProtocolVersion(version)); + const requestedProtocolVersion = statefulVersions[0]; + if (requestedProtocolVersion === undefined) { + throw new Error( + 'initialize cannot negotiate protocol versions newer than 2025-11-25. Include at least one version from STATEFUL_PROTOCOL_VERSIONS in supportedProtocolVersions.' + ); + } + const result = await this._requestWithSchema( { method: 'initialize', params: { - protocolVersion: this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION, + protocolVersion: requestedProtocolVersion, capabilities: this._capabilities, clientInfo: this._clientInfo } @@ -509,7 +517,7 @@ export class Client extends Protocol { throw new Error(`Server sent invalid initialize result: ${result}`); } - if (!this._supportedProtocolVersions.includes(result.protocolVersion)) { + if (!statefulVersions.includes(result.protocolVersion)) { throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); } diff --git a/packages/client/test/client/client.test.ts b/packages/client/test/client/client.test.ts new file mode 100644 index 000000000..e61477f97 --- /dev/null +++ b/packages/client/test/client/client.test.ts @@ -0,0 +1,70 @@ +import { DRAFT_PROTOCOL_VERSION_2026, InMemoryTransport, isJSONRPCRequest, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; + +import { Client } from '../../src/client/client.js'; + +/** In-memory server stub: records each initialize request's protocolVersion, replies with `respondWithVersion` (default: echo). */ +function fakeInitializeServer(respondWithVersion?: string): { clientTransport: InMemoryTransport; requestedVersions: string[] } { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const requestedVersions: string[] = []; + serverTransport.onmessage = message => { + if (isJSONRPCRequest(message) && message.method === 'initialize') { + const params = message.params as { protocolVersion: string }; + requestedVersions.push(params.protocolVersion); + void serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: respondWithVersion ?? params.protocolVersion, + capabilities: {}, + serverInfo: { name: 'fake-server', version: '0.0.0' } + } + }); + } + }; + return { clientTransport, requestedVersions }; +} + +describe('Client', () => { + describe('initialize negotiates stateful protocol versions only', () => { + it('requests the first stateful supported version regardless of list order', async () => { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026, LATEST_PROTOCOL_VERSION] } + ); + const { clientTransport, requestedVersions } = fakeInitializeServer(); + + await client.connect(clientTransport); + + expect(requestedVersions).toEqual([LATEST_PROTOCOL_VERSION]); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + await client.close(); + }); + + it('connect() rejects without touching the wire when no supported version is stateful', async () => { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026] } + ); + const { clientTransport, requestedVersions } = fakeInitializeServer(); + + await expect(client.connect(clientTransport)).rejects.toThrow( + 'initialize cannot negotiate protocol versions newer than 2025-11-25' + ); + expect(requestedVersions).toEqual([]); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + + it('rejects an initialize result carrying a stateless version, even one listed as supported', async () => { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026, LATEST_PROTOCOL_VERSION] } + ); + const { clientTransport } = fakeInitializeServer(DRAFT_PROTOCOL_VERSION_2026); + + await expect(client.connect(clientTransport)).rejects.toThrow( + `Server's protocol version is not supported: ${DRAFT_PROTOCOL_VERSION_2026}` + ); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 913b948ac..2467bf1ce 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, @@ -94,6 +96,7 @@ export { METHOD_NOT_FOUND, PARSE_ERROR, RELATED_TASK_META_KEY, + STATEFUL_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS } from '../../types/constants.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index 1cac50a7b..d3a639782 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -62,8 +62,10 @@ export type ProgressCallback = (progress: Progress) => void; */ 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()}. + * Protocol versions supported. The first stateful entry (see + * {@linkcode STATEFUL_PROTOCOL_VERSIONS}) is preferred: sent by the client at initialize, + * used as the server's fallback. Revisions newer than 2025-11-25 are never negotiated via + * the initialize handshake. Passed to transport during {@linkcode Protocol.connect | connect()}. * * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 878d5111c..098ba50a0 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -2,6 +2,30 @@ 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']; +/** + * 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 = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07']; + +/** + * Returns `true` when `version` negotiates via the initialize handshake — one of + * {@linkcode STATEFUL_PROTOCOL_VERSIONS}. + */ +export function isStatefulProtocolVersion(version: string): boolean { + return STATEFUL_PROTOCOL_VERSIONS.includes(version); +} + +/** + * Wire identifier of the draft (unreleased) protocol revision, mirroring `LATEST_PROTOCOL_VERSION` + * in the [draft specification schema](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts). + */ +export const DRAFT_PROTOCOL_VERSION_2026 = 'DRAFT-2026-v1'; + +/** Draft (unreleased) protocol revisions known to this SDK. Stateless: never negotiated via initialize. */ +export const DRAFT_PROTOCOL_VERSIONS = [DRAFT_PROTOCOL_VERSION_2026]; + export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; /* JSON-RPC types */ diff --git a/packages/core/test/types/constants.test.ts b/packages/core/test/types/constants.test.ts new file mode 100644 index 000000000..fc0d1da0e --- /dev/null +++ b/packages/core/test/types/constants.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; + +import { DRAFT_PROTOCOL_VERSION_2026, isStatefulProtocolVersion } from '../../src/types/constants.js'; +import { LATEST_PROTOCOL_VERSION as DRAFT_SPEC_LATEST_PROTOCOL_VERSION } from '../../src/types/spec.types.js'; + +describe('protocol version constants', () => { + it('pins the draft wire literal to the draft specification schema', () => { + expect(DRAFT_PROTOCOL_VERSION_2026).toBe(DRAFT_SPEC_LATEST_PROTOCOL_VERSION); + }); + + it('classifies the draft specification revision as stateless', () => { + expect(isStatefulProtocolVersion(DRAFT_SPEC_LATEST_PROTOCOL_VERSION)).toBe(false); + }); +}); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 50081358e..c12e10929 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -43,6 +43,7 @@ import { ElicitResultSchema, EmptyResultSchema, extractTaskManagerOptions, + isStatefulProtocolVersion, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, LoggingLevelSchema, @@ -430,9 +431,11 @@ export class Server extends Protocol { this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) + 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); this._negotiatedProtocolVersion = protocolVersion; this.transport?.setProtocolVersion?.(protocolVersion); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 36016a093..d4408f888 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,42 @@ describe('Server', () => { }); }); + describe('initialize negotiates stateful protocol versions only', () => { + it('treats a requested stateless version as unsupported and responds with its first stateful version', async () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { + capabilities: {}, + supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026] + } + ); + + const respondedVersion = await initializeServer(server, DRAFT_PROTOCOL_VERSION_2026); + + expect(respondedVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await server.close(); + }); + + it('falls back to its first stateful supported version regardless of list order', async () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { + capabilities: {}, + supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026, LATEST_PROTOCOL_VERSION] + } + ); + + const respondedVersion = await initializeServer(server, UNSUPPORTED_VERSION); + + expect(respondedVersion).toBe(LATEST_PROTOCOL_VERSION); + expect(server.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + + await server.close(); + }); + }); + 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 fe1d111f4..1d9cffa93 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2605,6 +2605,12 @@ 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:initialize-stateful-versions-only': { + source: 'sdk', + behavior: + 'The initialize handshake negotiates only stateful protocol versions (2025-11-25 and older): a client and server whose supportedProtocolVersions lists contain newer revisions in any position complete the handshake on the newest mutually-supported stateful version, and no revision newer than 2025-11-25 appears anywhere in the handshake.', + note: 'Protocol revisions after 2025-11-25 are stateless and negotiate per-request, arriving with a later release.' + }, '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 2fba4a84e..49c62bd98 100644 --- a/test/e2e/scenarios/lifecycle.test.ts +++ b/test/e2e/scenarios/lifecycle.test.ts @@ -19,6 +19,7 @@ import type { ServerCapabilities } from '@modelcontextprotocol/server'; import { + DRAFT_PROTOCOL_VERSION_2026, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, @@ -544,6 +545,42 @@ verifies('lifecycle:version:no-overlap-rejects', async ({ transport }: TestArgs) expect(client.getServerVersion()).toBeUndefined(); }); +verifies('lifecycle:version:initialize-stateful-versions-only', async ({ transport }: TestArgs) => { + // The draft revision sits at a different position on each side: the outcome is order-agnostic. + const makeServer = () => + new McpServer( + { name: 'stateful-only-server', version: '0.0.0' }, + { supportedProtocolVersions: [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION_2026] } + ); + const client = new Client( + { name: 'stateful-only-client', version: '0.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION_2026, LATEST_PROTOCOL_VERSION] } + ); + const log = tapHandshake(client); + + await using _ = await wire(transport, makeServer, client); + + const initRequest = log.find( + e => e.direction === 'client-to-server' && isJSONRPCRequest(e.message) && e.message.method === 'initialize' + ); + if (!initRequest || !isJSONRPCRequest(initRequest.message)) throw new Error('expected an initialize request on the wire'); + expect(initRequest.message.params?.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + const initRequestId = initRequest.message.id; + + const initResponse = log.find( + e => e.direction === 'server-to-client' && isJSONRPCResultResponse(e.message) && e.message.id === initRequestId + ); + if (!initResponse || !isJSONRPCResultResponse(initResponse.message)) throw new Error('expected a result for the initialize request'); + expect(initResponse.message.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION); + + for (const entry of log) { + expect(JSON.stringify(entry.message)).not.toContain(DRAFT_PROTOCOL_VERSION_2026); + } + + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + expect(client.getServerVersion()).toEqual({ name: 'stateful-only-server', version: '0.0.0' }); +}); + 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();