From 3c7fcb3c2743d45195a14c99724337788dc56b07 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 21:51:03 +0000 Subject: [PATCH 1/2] feat(core): add stateful and draft protocol version constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/core/src/exports/public/index.ts | 3 +++ packages/core/src/types/constants.ts | 24 ++++++++++++++++++++++ packages/core/test/types/constants.test.ts | 14 +++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 packages/core/test/types/constants.test.ts 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/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); + }); +}); From 38f2f093764ca330c4398cb2b64f0d203bbb4a98 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 1 Jun 2026 22:09:31 +0000 Subject: [PATCH 2/2] feat(client,server): negotiate only stateful protocol versions via initialize 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. --- .../draft-protocol-version-constants.md | 8 ++ docs/client.md | 6 ++ docs/migration-SKILL.md | 84 ++++++++++--------- docs/migration.md | 65 ++++++++------ docs/server.md | 6 ++ examples/server/src/customProtocolVersion.ts | 27 +++--- packages/client/src/client/client.ts | 14 +++- packages/client/test/client/client.test.ts | 70 ++++++++++++++++ packages/core/src/shared/protocol.ts | 6 +- packages/server/src/server/server.ts | 7 +- packages/server/test/server/server.test.ts | 37 ++++++++ test/e2e/requirements.ts | 6 ++ test/e2e/scenarios/lifecycle.test.ts | 37 ++++++++ 13 files changed, 291 insertions(+), 82 deletions(-) create mode 100644 .changeset/draft-protocol-version-constants.md create mode 100644 packages/client/test/client/client.test.ts 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/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/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();