diff --git a/.changeset/stateless-sessionless-client-server.md b/.changeset/stateless-sessionless-client-server.md new file mode 100644 index 0000000000..fee26222ab --- /dev/null +++ b/.changeset/stateless-sessionless-client-server.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/server': minor +--- + +Add stateless protocol (SEP-2575) and sessionless transport (SEP-2567) support for client→server requests on the draft 2026-07-28 revision: the per-request `_meta` envelope, `server/discover`, stateless dispatch on Streamable HTTP and stdio, discovery-based dual-era client connect, per-request logging, and the sessionless transport invariants. Opt-in by listing a non-stateful protocol version in `supportedProtocolVersions`; configurations that do not are unaffected. diff --git a/.github/workflows/update-spec-types.yml b/.github/workflows/update-spec-types.yml index 6f7bde8e43..8acc5f8094 100644 --- a/.github/workflows/update-spec-types.yml +++ b/.github/workflows/update-spec-types.yml @@ -34,16 +34,16 @@ jobs: run: pnpm install - name: Fetch latest spec types - run: pnpm run fetch:spec-types + run: pnpm run fetch:spec-types draft - name: Check for changes id: check_changes run: | - if git diff --quiet packages/core/src/types/spec.types.ts; then + if git diff --quiet packages/core/src/types/spec.types.draft.ts; then echo "has_changes=false" >> $GITHUB_OUTPUT else echo "has_changes=true" >> $GITHUB_OUTPUT - LATEST_SHA=$(grep "Last updated from commit:" packages/core/src/types/spec.types.ts | cut -d: -f2 | tr -d ' ') + LATEST_SHA=$(grep "Last updated from commit:" packages/core/src/types/spec.types.draft.ts | cut -d: -f2 | tr -d ' ') echo "sha=$LATEST_SHA" >> $GITHUB_OUTPUT fi @@ -59,12 +59,12 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -B update-spec-types - git add packages/core/src/types/spec.types.ts - git commit -m "chore: update spec.types.ts from upstream" + git add packages/core/src/types/spec.types.draft.ts + git commit -m "chore: update spec.types.draft.ts from upstream" git push -f --no-verify origin update-spec-types # Create PR if it doesn't exist, or update if it does - PR_BODY="This PR updates \`packages/core/src/types/spec.types.ts\` from the Model Context Protocol specification. + PR_BODY="This PR updates \`packages/core/src/types/spec.types.draft.ts\` from the Model Context Protocol specification. Source file: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/${{ steps.check_changes.outputs.sha }}/schema/draft/schema.ts @@ -77,7 +77,7 @@ jobs: gh pr edit "$EXISTING_PR" --body "$PR_BODY" else gh pr create \ - --title "chore: update spec.types.ts from upstream" \ + --title "chore: update spec.types.draft.ts from upstream" \ --body "$PR_BODY" \ --base main \ --head update-spec-types diff --git a/.prettierignore b/.prettierignore index 6877ccc5aa..71f7e26d4e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -10,7 +10,8 @@ node_modules pnpm-lock.yaml # Ignore generated files -src/spec.types.ts +**/src/types/spec.types.2025-11-25.ts +**/src/types/spec.types.draft.ts # Batch test cloned repos and results packages/codemod/batch-test/repos diff --git a/REVIEW.md b/REVIEW.md index ad726c762f..d210004600 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -75,7 +75,7 @@ When verifying spec compliance, consult the spec directly rather than relying on ### Schema Compliance -- When editing Zod protocol schemas in `schemas.ts`, verify unknown-key handling matches the spec `schema.ts`: if the spec type has no `additionalProperties: false`, the SDK schema must use `z.looseObject()` / `.catchall(z.unknown())` rather than implicit strict — over-strict Zod (incl. `z.literal('object')` on `type`) rejects spec-valid payloads from other SDKs. Also confirm `spec.types.test.ts` still passes bidirectionally. (#1768, #1849, #1169) +- When editing Zod protocol schemas in `schemas.ts`, verify unknown-key handling matches the spec `schema.ts`: if the spec type has no `additionalProperties: false`, the SDK schema must use `z.looseObject()` / `.catchall(z.unknown())` rather than implicit strict — over-strict Zod (incl. `z.literal('object')` on `type`) rejects spec-valid payloads from other SDKs. Also confirm the `spec.types.*.test.ts` comparisons still pass bidirectionally. (#1768, #1849, #1169) ### Async / Lifecycle diff --git a/common/eslint-config/eslint.config.mjs b/common/eslint-config/eslint.config.mjs index 32aad92752..e1afc285d7 100644 --- a/common/eslint-config/eslint.config.mjs +++ b/common/eslint-config/eslint.config.mjs @@ -96,7 +96,7 @@ export default defineConfig( }, { // Ignore generated protocol types everywhere - ignores: ['**/spec.types.ts'] + ignores: ['**/spec.types.2025-11-25.ts', '**/spec.types.draft.ts'] }, { files: ['packages/client/**/*.ts', 'packages/server/**/*.ts'], diff --git a/docs/client.md b/docs/client.md index 0c852f4e11..8bc3c1a56c 100644 --- a/docs/client.md +++ b/docs/client.md @@ -21,12 +21,14 @@ import { createMiddleware, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, + DRAFT_PROTOCOL_VERSION, PrivateKeyJwtProvider, ProtocolError, SdkError, SdkErrorCode, SSEClientTransport, - StreamableHTTPClientTransport + StreamableHTTPClientTransport, + SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; ``` @@ -111,6 +113,34 @@ 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 {@linkcode @modelcontextprotocol/client!index.DRAFT_PROTOCOL_VERSION | DRAFT_PROTOCOL_VERSION} — is stateless and negotiates per-request. + +#### Per-request protocol revisions (draft opt-in) + +Listing a per-request revision such as {@linkcode @modelcontextprotocol/client!index.DRAFT_PROTOCOL_VERSION | DRAFT_PROTOCOL_VERSION} in `supportedProtocolVersions` opts the client in to per-request negotiation: + +```ts source="../examples/client/src/clientGuide.examples.ts#protocolVersions_perRequestOptIn" +const client = new Client( + { name: 'my-client', version: '1.0.0' }, + // Listing the draft revision is the opt-in. Keeping the default versions in the + // list lets connect() fall back to the initialize handshake for servers that do + // not speak a per-request revision. + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS] } +); + +await client.connect(new StreamableHTTPClientTransport(new URL(url))); + +console.log(client.getNegotiatedProtocolVersion()); +``` + +An opted-in `connect()` probes with `server/discover` instead of `initialize`. When the server shares a per-request revision, the connection is established without any handshake: the discover result supplies the server's capabilities, info, and instructions, and every subsequent request carries its own `_meta` envelope (protocol version, client info, client capabilities) plus, on HTTP, the matching `MCP-Protocol-Version` header. If the server rejects the probed version with error `-32004`, the client retries once with a mutually supported version from the error's `supported` list. + +When the server does not speak a per-request revision — it answers the probe with `-32601`, rejects it at the HTTP layer, or reports only initialize-era versions — the client falls back to the regular initialize handshake, so an opted-in client still connects to today's servers. Per-request negotiation applies to the Streamable HTTP and stdio transports; the legacy SSE transport always negotiates via initialize. + ## 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. @@ -487,6 +517,26 @@ client.setRequestHandler('roots/list', async () => { When the available roots change, notify the server with {@linkcode @modelcontextprotocol/client!client/client.Client#sendRootsListChanged | client.sendRootsListChanged()}. +> [!NOTE] +> The `notifications/roots/list_changed` notification exists only on initialize-era (2025-11-25 and earlier) connections. The draft per-request revision removes it: servers there receive the client's current context with each request instead, so there is no standing roots state to invalidate. A server speaking a per-request revision ignores the notification. + +### Request context + +Handlers receive the request context (`ctx`) as their second argument. `ctx.mcpReq.protocolVersion` (from {@linkcode @modelcontextprotocol/client!index.BaseContext | BaseContext}) is the protocol version governing the request: + +```ts source="../examples/client/src/clientGuide.examples.ts#requestContext_handler" +client.setRequestHandler('sampling/createMessage', async (request, ctx) => { + console.log(`Sampling request under MCP ${ctx.mcpReq.protocolVersion}:`, request.params.messages.at(-1)); + + // In production, send messages to your LLM here + return { + model: 'my-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response from the model' } + }; +}); +``` + ## Error handling ### Tool errors vs protocol errors diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index b849da8b3d..d1d026656e 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -441,6 +441,14 @@ Request/notification params remain fully typed. Remove unused schema imports aft | `ctx.mcpReq.elicitInput(params, options?)` | Elicit user input (form or URL) | `server.elicitInput(...)` from within handler | | `ctx.mcpReq.requestSampling(params, options?)` | Request LLM sampling from client | `server.createMessage(...)` from within handler | +Per-request facts on the context (new in v2): + +| Field | Description | v1 near-equivalent | +| ---------------------------- | ------------------------------------------------------------- | --------------------------------------------------------- | +| `ctx.mcpReq.protocolVersion` | Protocol version governing the request (both roles) | `server.getNegotiatedProtocolVersion()` from within handler | +| `ctx.client.capabilities` | Calling client's declared capabilities (only `ServerContext`) | `server.getClientCapabilities()` from within handler | +| `ctx.client.info` | Calling client's implementation info (only `ServerContext`) | `server.getClientVersion()` from within handler | + ## 11. Schema parameter removed from `request()`, `send()`, and `callTool()` (spec methods) For **spec** methods, `Protocol.request()`, `BaseContext.mcpReq.send()`, and `Client.callTool()` no longer require a Zod result schema argument. The SDK resolves the schema internally from the method name. @@ -507,6 +515,8 @@ NOT removed (wire surface, kept for 2025-11-25 interop): task Zod schemas + infe `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 containing none makes `connect()` throw, and the server's fallback picks its first stateful entry. Revisions after 2025-11-25 negotiate per-request instead (arriving with a later release). + ## 14. Runtime-Specific JSON Schema Validators (Enhancement) The SDK now auto-selects the appropriate JSON Schema validator based on runtime: diff --git a/docs/migration.md b/docs/migration.md index bf88cf2b76..0881176050 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -632,11 +632,12 @@ server.setRequestHandler('tools/call', async (request, ctx) => { }); ``` -Context fields are organized into 3 groups: +Context fields are organized into groups: -- **`mcpReq`** — request-level concerns: `id`, `method`, `_meta`, `signal`, `send()`, `notify()`, plus server-only `log()`, `elicitInput()`, and `requestSampling()` +- **`mcpReq`** — request-level concerns: `id`, `method`, `protocolVersion`, `_meta`, `signal`, `send()`, `notify()`, plus server-only `log()`, `elicitInput()`, and `requestSampling()` - **`http?`** — HTTP transport concerns (undefined for stdio): `authInfo`, plus server-only `req`, `closeSSE`, `closeStandaloneSSE` - **`sessionId?`** — transport session identifier (top-level) +- **`client`** — server-only: the calling client's declared `capabilities` and implementation `info` `BaseContext` is the common base type shared by both `ServerContext` and `ClientContext`. `ServerContext` extends each group with server-specific additions via type intersection. @@ -898,6 +899,11 @@ The 2025-11 experimental tasks side-channel woven through `Protocol` has been re There is no migration path for the removed surface; it was always `@experimental`. Task support is planned to return as an opt-in extension plugin per SEP-2663. +### `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 diff --git a/docs/server.md b/docs/server.md index b16c24fc4d..851beb4ea5 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 {@linkcode @modelcontextprotocol/server!index.DRAFT_PROTOCOL_VERSION | DRAFT_PROTOCOL_VERSION}, is stateless and negotiates per-request. Listing such a revision in `supportedProtocolVersions` is the opt-in: on Streamable HTTP, sessionless requests claiming it are served on a stateless dispatch path where each request's `_meta` envelope supplies the protocol version, client info, and client capabilities; a stdio server that lists such a revision routes the same way, keyed on the request's `io.modelcontextprotocol/protocolVersion` `_meta` claim. Requests carrying a session ID and all stateful-version traffic behave exactly as before. + ## 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. @@ -76,6 +82,23 @@ const server = new McpServer( ); ``` +## Discovery + +Every server answers `server/discover` automatically — there is nothing to register or configure. The built-in handler reports, straight from the server's configuration: + +| Field | Content | +| ------------------- | ------------------------------------------------------------------------------------------------------------ | +| `supportedVersions` | The protocol versions the server is configured to support (`supportedProtocolVersions`, or the SDK default) | +| `capabilities` | The server's advertised capabilities (see the note below) | +| `serverInfo` | The server's name and version | +| `instructions` | The configured instructions string, when present | + +Discovery is connection-less: like `ping`, it is answered both before and after the initialize handshake, and on the stateless dispatch path, so clients can probe a server before deciding how to connect (see [Discovery](https://modelcontextprotocol.io/specification/draft/server/discover) in the draft specification). + +The advertised capabilities omit the subscription-delivery flags (`prompts.listChanged`, `resources.listChanged`, `resources.subscribe`, `tools.listChanged`) until `subscriptions/listen` is implemented — discovery never advertises notification delivery no RPC can honor. The initialize result still carries the declared flags, where the stateful-era notification flow delivers them. + +To customize the response, register your own handler for `server/discover` on the low-level `Server` — an explicit registration replaces the built-in one. + ## Tools Tools let clients invoke actions on your server — they are usually the main way LLMs call into your application (see [Tools](https://modelcontextprotocol.io/docs/learn/server-concepts#tools) in the MCP overview). @@ -339,6 +362,8 @@ server.registerTool( ); ``` +How messages are filtered depends on the protocol revision governing the request. Under initialize-based revisions (2025-11-25 and earlier), the client sets a session-wide threshold via the `logging/setLevel` request. Under per-request revisions (2026-07-28), `logging/setLevel` is removed: the client opts in per request by putting `io.modelcontextprotocol/logLevel` in the request's `_meta`, and `ctx.mcpReq.log()` delivers only messages at or above that level, on the response stream of that request. A request without the claim produces no log notifications at all — the claim governs exactly its own request and is never carried over. + ## Progress Progress notifications let a tool report incremental status updates during long-running operations (see [Progress](https://modelcontextprotocol.io/specification/latest/basic/utilities/progress) in the MCP specification). @@ -495,6 +520,51 @@ server.registerTool( ); ``` +## Reading request context + +Every handler receives the request context (`ctx`) as its second argument. Beyond the helpers shown above, it carries per-request facts about the caller: + +- `ctx.mcpReq.protocolVersion` (from {@linkcode @modelcontextprotocol/server!index.BaseContext | BaseContext}) — the protocol version governing the request. +- `ctx.client.capabilities` and `ctx.client.info` (from {@linkcode @modelcontextprotocol/server!index.ServerContext | ServerContext}) — the calling client's declared capabilities and implementation info. + +Check `ctx.client.capabilities` before sending a [server-initiated request](#server-initiated-requests) so you never ask a client to do something it cannot — for example, only [elicit input](#elicitation) when the client declared the `elicitation` capability: + +```ts source="../examples/server/src/serverGuide.examples.ts#registerTool_requestContext" +server.registerTool( + 'delete-records', + { + description: 'Delete records, asking for confirmation when the client supports it', + inputSchema: z.object({ table: z.string() }) + }, + async ({ table }, ctx): Promise => { + // Per-request facts: the calling client and the protocol version governing this request + const caller = `${ctx.client.info?.name ?? 'unknown client'} (MCP ${ctx.mcpReq.protocolVersion})`; + + // Only ask for confirmation if the calling client declared the elicitation capability + if (ctx.client.capabilities.elicitation) { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Delete all records in ${table}?`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Confirm' } }, + required: ['confirm'] + } + }); + if (result.action !== 'accept' || result.content?.confirm !== true) { + return { content: [{ type: 'text', text: 'Deletion cancelled.' }] }; + } + } + + // ... delete records, attributing the request to `caller` ... + return { content: [{ type: 'text', text: `Deleted all records in ${table} (requested by ${caller})` }] }; + } +); +``` + +> [!IMPORTANT] +> Capabilities are declarations, not authorization. Never use them to gate access to tools, resources, or data — that is the authorization layer's job. + ## Shutdown For stateful multi-session HTTP servers, capture the `http.Server` from `app.listen()` so you can stop accepting connections, then close each session transport: diff --git a/examples/client/src/clientGuide.examples.ts b/examples/client/src/clientGuide.examples.ts index 57853821ec..a9124a2e0a 100644 --- a/examples/client/src/clientGuide.examples.ts +++ b/examples/client/src/clientGuide.examples.ts @@ -16,12 +16,14 @@ import { createMiddleware, CrossAppAccessProvider, discoverAndRequestJwtAuthGrant, + DRAFT_PROTOCOL_VERSION, PrivateKeyJwtProvider, ProtocolError, SdkError, SdkErrorCode, SSEClientTransport, - StreamableHTTPClientTransport + StreamableHTTPClientTransport, + SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; //#endregion imports @@ -88,6 +90,27 @@ async function disconnect_streamableHttp(client: Client, transport: StreamableHT //#endregion disconnect_streamableHttp } +// --------------------------------------------------------------------------- +// Protocol versions +// --------------------------------------------------------------------------- + +/** Example: opt in to the per-request (draft) protocol revision. */ +async function protocolVersions_perRequestOptIn(url: string) { + //#region protocolVersions_perRequestOptIn + const client = new Client( + { name: 'my-client', version: '1.0.0' }, + // Listing the draft revision is the opt-in. Keeping the default versions in the + // list lets connect() fall back to the initialize handshake for servers that do + // not speak a per-request revision. + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS] } + ); + + await client.connect(new StreamableHTTPClientTransport(new URL(url))); + + console.log(client.getNegotiatedProtocolVersion()); + //#endregion protocolVersions_perRequestOptIn +} + // --------------------------------------------------------------------------- // Server instructions // --------------------------------------------------------------------------- @@ -437,6 +460,22 @@ function roots_handler(client: Client) { //#endregion roots_handler } +/** Example: Read the governing protocol version from the handler context. */ +function requestContext_handler(client: Client) { + //#region requestContext_handler + client.setRequestHandler('sampling/createMessage', async (request, ctx) => { + console.log(`Sampling request under MCP ${ctx.mcpReq.protocolVersion}:`, request.params.messages.at(-1)); + + // In production, send messages to your LLM here + return { + model: 'my-model', + role: 'assistant' as const, + content: { type: 'text' as const, text: 'Response from the model' } + }; + }); + //#endregion requestContext_handler +} + // --------------------------------------------------------------------------- // Error handling // --------------------------------------------------------------------------- @@ -549,6 +588,7 @@ void connect_streamableHttp; void connect_stdio; void connect_sseFallback; void disconnect_streamableHttp; +void protocolVersions_perRequestOptIn; void serverInstructions_basic; void auth_tokenProvider; void auth_clientCredentials; @@ -568,6 +608,7 @@ void capabilities_declaration; void sampling_handler; void elicitation_handler; void roots_handler; +void requestContext_handler; void errorHandling_toolErrors; void errorHandling_lifecycle; void errorHandling_timeout; diff --git a/examples/server/src/customProtocolVersion.ts b/examples/server/src/customProtocolVersion.ts index c580432e4b..ca517f23b0 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/examples/server/src/serverGuide.examples.ts b/examples/server/src/serverGuide.examples.ts index 5a4712f830..2a746d1a27 100644 --- a/examples/server/src/serverGuide.examples.ts +++ b/examples/server/src/serverGuide.examples.ts @@ -419,6 +419,46 @@ function registerTool_roots(server: McpServer) { //#endregion registerTool_roots } +// --------------------------------------------------------------------------- +// Reading request context +// --------------------------------------------------------------------------- + +/** Example: Tool that reads per-request facts (protocol version, client capabilities) from the handler context. */ +function registerTool_requestContext(server: McpServer) { + //#region registerTool_requestContext + server.registerTool( + 'delete-records', + { + description: 'Delete records, asking for confirmation when the client supports it', + inputSchema: z.object({ table: z.string() }) + }, + async ({ table }, ctx): Promise => { + // Per-request facts: the calling client and the protocol version governing this request + const caller = `${ctx.client.info?.name ?? 'unknown client'} (MCP ${ctx.mcpReq.protocolVersion})`; + + // Only ask for confirmation if the calling client declared the elicitation capability + if (ctx.client.capabilities.elicitation) { + const result = await ctx.mcpReq.elicitInput({ + mode: 'form', + message: `Delete all records in ${table}?`, + requestedSchema: { + type: 'object', + properties: { confirm: { type: 'boolean', title: 'Confirm' } }, + required: ['confirm'] + } + }); + if (result.action !== 'accept' || result.content?.confirm !== true) { + return { content: [{ type: 'text', text: 'Deletion cancelled.' }] }; + } + } + + // ... delete records, attributing the request to `caller` ... + return { content: [{ type: 'text', text: `Deleted all records in ${table} (requested by ${caller})` }] }; + } + ); + //#endregion registerTool_requestContext +} + // --------------------------------------------------------------------------- // Transports // --------------------------------------------------------------------------- @@ -546,6 +586,7 @@ void registerTool_progress; void registerTool_sampling; void registerTool_elicitation; void registerTool_roots; +void registerTool_requestContext; void registerResource_static; void registerResource_template; void registerPrompt_basic; diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 36a98521cd..c2e97a4252 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -7,6 +7,8 @@ import type { ClientNotification, ClientRequest, CompleteRequest, + DiscoverResult, + EmptyResult, GetPromptRequest, Implementation, JSONRPCRequest, @@ -31,33 +33,40 @@ import type { SubscribeRequest, Tool, Transport, - UnsubscribeRequest + UnsubscribeRequest, + UnsupportedProtocolVersionErrorData } from '@modelcontextprotocol/core'; import { CallToolResultSchema, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, CompleteResultSchema, CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, + DiscoverResultSchema, ElicitRequestSchema, ElicitResultSchema, EmptyResultSchema, GetPromptResultSchema, InitializeResultSchema, - LATEST_PROTOCOL_VERSION, + isStatefulProtocolVersion, ListChangedOptionsBaseSchema, ListPromptsResultSchema, ListResourcesResultSchema, ListResourceTemplatesResultSchema, ListToolsResultSchema, + LOG_LEVEL_META_KEY, mergeCapabilities, parseSchema, Protocol, + PROTOCOL_VERSION_META_KEY, ProtocolError, ProtocolErrorCode, ReadResourceResultSchema, SdkError, - SdkErrorCode + SdkErrorCode, + SdkHttpError } from '@modelcontextprotocol/core'; /** @@ -210,7 +219,6 @@ export type ClientOptions = ProtocolOptions & { export class Client extends Protocol { private _serverCapabilities?: ServerCapabilities; private _serverVersion?: Implementation; - private _negotiatedProtocolVersion?: string; private _capabilities: ClientCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -380,7 +388,15 @@ export class Client extends Protocol { } /** - * Connects to a server via the given transport and performs the MCP initialization handshake. + * Connects to a server via the given transport and negotiates the protocol version. + * + * By default the client performs the MCP initialize handshake. When `supportedProtocolVersions` + * lists a per-request (non-stateful) protocol revision such as `DRAFT_PROTOCOL_VERSION` — + * listing one is the opt-in — the client probes with `server/discover` first and, when the + * server shares a per-request version, connects without any initialize handshake: every + * subsequent request carries the `_meta` envelope (protocol version, client info, client + * capabilities) instead. When the server does not share a per-request version, the client + * falls back to the initialize handshake. * * @example Basic usage (stdio) * ```ts source="./client.examples.ts#Client_connect_stdio" @@ -420,11 +436,28 @@ export class Client extends Protocol { return; } try { + // Per-request (non-stateful) protocol revisions the client is configured for, in + // preference order. Listing one is the opt-in: connect() then negotiates via + // server/discover first, and only initializes when the server does not share a + // per-request revision. + const perRequestVersions = this._supportedProtocolVersions.filter(version => !isStatefulProtocolVersion(version)); + if (perRequestVersions.length > 0 && (await this._connectPerRequestEra(transport, perRequestVersions, options))) { + return; + } + + 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 } @@ -437,7 +470,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}`); } @@ -467,6 +500,147 @@ export class Client extends Protocol { } } + /** + * Discovery-based connect for per-request protocol revisions (no initialize handshake). + * + * Sends `server/discover` claiming the preferred per-request version. Returns `true` when a + * mutual per-request version was selected: the connection is established, the discover result + * populates the server facts, and every subsequent request is stamped with the `_meta` + * envelope. Returns `false` when the server does not speak a mutual per-request version and + * the initialize handshake should be attempted instead (the draft spec's back-compat + * detection): the server answered `-32601` (no discovery — a legacy server), rejected the + * probe with an HTTP 400 that carries no correlatable JSON-RPC error (a legacy HTTP server's + * header or session validation), or answered discovery with versions that do not intersect + * the client's per-request list. + */ + private async _connectPerRequestEra(transport: Transport, perRequestVersions: string[], options?: RequestOptions): Promise { + this._stampRequestEnvelope(transport, perRequestVersions[0]!); + let result: DiscoverResult; + try { + result = await this._discoverWithVersionRetry(transport, perRequestVersions, options); + } catch (error) { + if (this._isLegacyServerSignal(error)) { + this._clearRequestEnvelope(transport); + return false; + } + // A `-32004` resolves to the handshake under the same rule as a discover + // result: when its `supported` list shares no per-request version (the + // single retry would have used one) but does share a stateful version + // the client also supports, fall back to initialize instead of failing. + const supported = this._supportedVersionsFromError(error); + if ( + supported !== undefined && + supported.some(version => isStatefulProtocolVersion(version) && this._supportedProtocolVersions.includes(version)) + ) { + this._clearRequestEnvelope(transport); + return false; + } + throw error; + } + + const selected = perRequestVersions.find(version => result.supportedVersions.includes(version)); + if (selected === undefined) { + this._clearRequestEnvelope(transport); + if (result.supportedVersions.some(version => isStatefulProtocolVersion(version))) { + // The server answers discovery but shares no per-request version (e.g. an + // initialize-era server reporting its stateful versions): negotiate via the + // handshake instead. + return false; + } + throw new Error(`No mutually supported protocol version (server supports: ${result.supportedVersions.join(', ') || 'none'})`); + } + + this._stampRequestEnvelope(transport, selected); + this._negotiatedProtocolVersion = selected; + this._serverCapabilities = result.capabilities; + this._serverVersion = result.serverInfo; + this._instructions = result.instructions; + + // Set up list changed handlers now that we know server capabilities + if (this._pendingListChangedConfig) { + this._setupListChangedHandlers(this._pendingListChangedConfig); + this._pendingListChangedConfig = undefined; + } + return true; + } + + /** + * Sends the discovery probe, retrying exactly once with a mutually supported per-request + * version when the server answers `-32004` (UnsupportedProtocolVersionError) listing one. + */ + private async _discoverWithVersionRetry( + transport: Transport, + perRequestVersions: string[], + options?: RequestOptions + ): Promise { + try { + return await this._requestWithSchema({ method: 'server/discover' }, DiscoverResultSchema, options); + } catch (error) { + const supported = this._supportedVersionsFromError(error); + const mutual = supported === undefined ? undefined : perRequestVersions.find(version => supported.includes(version)); + if (mutual === undefined) { + throw error; + } + this._stampRequestEnvelope(transport, mutual); + return await this._requestWithSchema({ method: 'server/discover' }, DiscoverResultSchema, options); + } + } + + /** + * The `supported` version list carried by a `-32004` (UnsupportedProtocolVersionError) + * response, or `undefined` for any other error. + */ + private _supportedVersionsFromError(error: unknown): string[] | undefined { + if (!(error instanceof ProtocolError) || error.code !== ProtocolErrorCode.UnsupportedProtocolVersion) { + return undefined; + } + const supported = (error.data as Partial | undefined)?.supported; + return Array.isArray(supported) && supported.every(version => typeof version === 'string') ? supported : undefined; + } + + /** + * Whether a failed discovery probe indicates a server that predates per-request protocol + * revisions, so connect() should fall back to the initialize handshake (the draft spec's + * back-compat detection). A `-32004` is NOT such a signal: that server speaks a per-request + * revision — the version retry handles it, and falling back to initialize would be wrong. + */ + private _isLegacyServerSignal(error: unknown): boolean { + // The server does not implement server/discover. + if (error instanceof ProtocolError && error.code === ProtocolErrorCode.MethodNotFound) { + return true; + } + // An HTTP 400 whose body carried no correlatable JSON-RPC error (those are delivered as + // protocol errors by the transport): a legacy HTTP server rejecting the probe before + // dispatch, e.g. header validation or a session requirement. + return error instanceof SdkHttpError && error.status === 400; + } + + /** + * Sets the per-request `_meta` envelope (and, on HTTP transports, the matching + * `MCP-Protocol-Version` header) that every outgoing request carries while `version` + * governs the connection. + */ + private _stampRequestEnvelope(transport: Transport, version: string): void { + this._requestMetaEnvelope = { + [PROTOCOL_VERSION_META_KEY]: version, + [CLIENT_INFO_META_KEY]: this._clientInfo, + [CLIENT_CAPABILITIES_META_KEY]: this._capabilities + }; + transport.setProtocolVersion?.(version); + } + + /** + * Undoes {@linkcode _stampRequestEnvelope} when discovery resolves to the initialize + * handshake: clears the `_meta` envelope AND the transport's version pin, so the fallback + * `initialize` goes out exactly like a fresh stateful connect — without the probed + * per-request version in the `MCP-Protocol-Version` header (a strict legacy server would + * reject that header on the very handshake meant for it). + */ + private _clearRequestEnvelope(transport: Transport): void { + this._requestMetaEnvelope = undefined; + transport.setProtocolVersion?.(undefined); + } + /** * After initialization has completed, this will be populated with the server's reported capabilities. */ @@ -481,15 +655,6 @@ export class Client extends Protocol { return this._serverVersion; } - /** - * After initialization has completed, this will be populated with the protocol version negotiated - * during the initialize handshake. When manually reconstructing a transport for reconnection, pass this - * value to the new transport so it continues sending the required `mcp-protocol-version` header. - */ - getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; - } - /** * After initialization has completed, this may be populated with information about the server's instructions. */ @@ -637,8 +802,18 @@ export class Client extends Protocol { return this._requestWithSchema({ method: 'completion/complete', params }, CompleteResultSchema, options); } - /** Sets the minimum severity level for log messages sent by the server. */ + /** + * Sets the minimum severity level for log messages sent by the server. + * + * Under a per-request protocol revision the `logging/setLevel` method does not exist: + * the level is declared on each request via the `_meta` envelope instead, so this + * updates the envelope stamped onto subsequent requests and sends nothing. + */ async setLoggingLevel(level: LoggingLevel, options?: RequestOptions) { + if (this._requestMetaEnvelope !== undefined) { + this._requestMetaEnvelope = { ...this._requestMetaEnvelope, [LOG_LEVEL_META_KEY]: level }; + return {} as EmptyResult; + } return this._requestWithSchema({ method: 'logging/setLevel', params: { level } }, EmptyResultSchema, options); } diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index bf554aba29..841e37a765 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -313,7 +313,7 @@ export class SSEClientTransport implements Transport { } } - setProtocolVersion(version: string): void { + setProtocolVersion(version?: string): void { this._protocolVersion = version; } } diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index 3b8ddafe5a..2414733209 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -1,12 +1,13 @@ import type { ReadableWritablePair } from 'node:stream/web'; -import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; +import type { FetchLike, JSONRPCErrorResponse, JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; import { createFetchWithInit, isInitializedNotification, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, + isStatefulProtocolVersion, JSONRPCMessageSchema, normalizeHeaders, SdkError, @@ -26,6 +27,27 @@ const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOp maxRetries: 2 }; +/** + * Parses a non-OK HTTP response body as a single correlatable JSON-RPC error response. + * + * Returns the error response when the body is `application/json` and parses as a JSON-RPC + * error response carrying a request id (per-request protocol revisions echo the originating + * request's id on every transport-built error). Returns `undefined` otherwise — notably for + * the `"id": null` bodies legacy servers use for connection-level rejections, which must + * keep failing the POST instead of being delivered. + */ +function parseErrorResponseBody(response: Response, text: string | null): JSONRPCErrorResponse | undefined { + if (text === null || text === '' || !response.headers.get('content-type')?.includes('application/json')) { + return undefined; + } + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(text)); + return isJSONRPCErrorResponse(message) && message.id !== undefined ? message : undefined; + } catch { + return undefined; + } +} + /** * Options for starting or authenticating an SSE connection */ @@ -216,12 +238,14 @@ export class StreamableHTTPClientTransport implements Transport { headers['Authorization'] = `Bearer ${token}`; } - if (this._sessionId) { + if (this._sessionId && !this._isSessionlessVersion()) { headers['mcp-session-id'] = this._sessionId; } if (this._protocolVersion) { headers['mcp-protocol-version'] = this._protocolVersion; } + // The `Mcp-Method` / `Mcp-Name` request headers belong to the HTTP header + // standardization work (SEP-2243) and are not emitted here yet. const extraHeaders = normalizeHeaders(this._requestInit?.headers); @@ -556,9 +580,14 @@ export class StreamableHTTPClientTransport implements Transport { const response = await (this._fetch ?? fetch)(this._url, init); - // Handle session ID received during initialization + // Handle session ID received during initialization. Per-request protocol + // revisions are sessionless: while this transport is pinned to one, an + // Mcp-Session-Id a server (or intermediary) emits is ignored — never + // stored, never replayed. (The back-compat fallback needs no exemption: + // the client clears the per-request pin before sending the initialize + // handshake, so its session id is captured by this branch normally.) const sessionId = response.headers.get('mcp-session-id'); - if (sessionId) { + if (sessionId && !this._isSessionlessVersion()) { this._sessionId = sessionId; } @@ -593,6 +622,20 @@ export class StreamableHTTPClientTransport implements Transport { const text = await response.text?.().catch(() => null); + // Per-request protocol revisions: servers answer pre-dispatch failures with an + // HTTP error status whose body is a single JSON-RPC error response echoing the + // request id (e.g. -32004 UnsupportedProtocolVersion on 400, -32601 on 404). + // Deliver those to onmessage so the protocol layer can correlate them with the + // originating request; anything else (legacy rejections carry `"id": null`) + // keeps failing the POST below. + if (this._protocolVersion !== undefined && !isStatefulProtocolVersion(this._protocolVersion)) { + const errorResponse = parseErrorResponseBody(response, text); + if (errorResponse !== undefined) { + this.onmessage?.(errorResponse); + return; + } + } + if (response.status === 403 && this._oauthProvider) { const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); @@ -698,6 +741,15 @@ export class StreamableHTTPClientTransport implements Transport { return this._sessionId; } + /** + * Whether this transport is pinned to a per-request (non-stateful) protocol + * revision. Those connections are sessionless: the client never sends + * `Mcp-Session-Id` and ignores one if a server emits it. + */ + private _isSessionlessVersion(): boolean { + return this._protocolVersion !== undefined && !isStatefulProtocolVersion(this._protocolVersion); + } + /** * Terminates the current session by sending a `DELETE` request to the server. * @@ -747,7 +799,7 @@ export class StreamableHTTPClientTransport implements Transport { } } - setProtocolVersion(version: string): void { + setProtocolVersion(version?: string): void { this._protocolVersion = version; } get protocolVersion(): string | undefined { diff --git a/packages/client/test/client/client.test.ts b/packages/client/test/client/client.test.ts new file mode 100644 index 0000000000..3ca4c9cf23 --- /dev/null +++ b/packages/client/test/client/client.test.ts @@ -0,0 +1,390 @@ +import type { JSONRPCRequest, Transport } from '@modelcontextprotocol/core'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + DRAFT_PROTOCOL_VERSION, + InMemoryTransport, + isJSONRPCRequest, + LATEST_PROTOCOL_VERSION, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core'; + +import { Client } from '../../src/client/client.js'; + +/** + * In-memory legacy-server stub: answers `server/discover` with -32601 (it predates discovery, + * so a probing client falls back to initialize), records each initialize request's + * protocolVersion, and replies with `respondWithVersion` (default: echo). + */ +function fakeInitializeServer(respondWithVersion?: string): { + clientTransport: InMemoryTransport; + requestedVersions: string[]; + requests: JSONRPCRequest[]; +} { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const requestedVersions: string[] = []; + const requests: JSONRPCRequest[] = []; + serverTransport.onmessage = message => { + if (!isJSONRPCRequest(message)) { + return; + } + requests.push(message); + if (message.method === 'server/discover') { + void serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32_601, message: 'Method not found' } + }); + return; + } + if (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; + } + void serverTransport.send({ jsonrpc: '2.0', id: message.id, result: {} }); + }; + return { clientTransport, requestedVersions, requests }; +} + +/** + * In-memory per-request-era server stub: answers `server/discover` with the given + * `supportedVersions` — after first rejecting each version in `rejectClaims` with -32004 — + * and answers `ping` with an empty result. Never speaks initialize. + */ +function fakeDiscoverServer(supportedVersions: string[], rejectClaims: string[] = []) { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const requests: JSONRPCRequest[] = []; + serverTransport.onmessage = message => { + if (!isJSONRPCRequest(message)) { + return; + } + requests.push(message); + const claimed = message.params?._meta?.[PROTOCOL_VERSION_META_KEY]; + if (typeof claimed === 'string' && rejectClaims.includes(claimed)) { + void serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: supportedVersions, requested: claimed } + } + }); + return; + } + if (message.method === 'server/discover') { + void serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { supportedVersions, capabilities: {}, serverInfo: { name: 'fake-discover-server', version: '0.0.0' } } + }); + return; + } + void serverTransport.send({ jsonrpc: '2.0', id: message.id, result: {} }); + }; + return { clientTransport, requests }; +} + +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, 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 after the declined discovery probe when no supported version is stateful', async () => { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION] }); + const { clientTransport, requestedVersions, requests } = fakeInitializeServer(); + + await expect(client.connect(clientTransport)).rejects.toThrow( + 'initialize cannot negotiate protocol versions newer than 2025-11-25' + ); + // The probe is the only thing that touched the wire: initialize was never sent. + expect(requests.map(request => request.method)).toEqual(['server/discover']); + 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, LATEST_PROTOCOL_VERSION] } + ); + const { clientTransport } = fakeInitializeServer(DRAFT_PROTOCOL_VERSION); + + await expect(client.connect(clientTransport)).rejects.toThrow( + `Server's protocol version is not supported: ${DRAFT_PROTOCOL_VERSION}` + ); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + }); + + describe('per-request era connect (discovery-based)', () => { + it('falls back to initialize without leaking the envelope after the probe is declined', async () => { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION] } + ); + const { clientTransport, requests } = fakeInitializeServer(); + + await client.connect(clientTransport); + + // The declined probe precedes the initialize handshake on the wire. + expect(requests.map(request => request.method)).toEqual(['server/discover', 'initialize']); + // The probe itself carried the full envelope claiming the preferred draft version... + const probeMeta = requests[0]!.params?._meta as Record; + expect(probeMeta[PROTOCOL_VERSION_META_KEY]).toBe(DRAFT_PROTOCOL_VERSION); + expect(probeMeta[CLIENT_INFO_META_KEY]).toEqual({ name: 'test-client', version: '1.0.0' }); + expect(probeMeta[CLIENT_CAPABILITIES_META_KEY]).toEqual({}); + + // ...but after the fallback nothing is stamped: a post-connect request carries no envelope keys. + await client.ping(); + const ping = requests.find(request => request.method === 'ping'); + expect(ping?.params?._meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + await client.close(); + }); + + it('retries the probe at most once on -32004: a second rejection fails connect()', async () => { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: ['2026-DRAFT-A', '2026-DRAFT-B'] } + ); + // The server lists DRAFT-B as supported but keeps rejecting it: a server that + // contradicts its own -32004 data must not produce an infinite retry loop. + const { clientTransport, requests } = fakeDiscoverServer(['2026-DRAFT-B'], ['2026-DRAFT-A', '2026-DRAFT-B']); + + await expect(client.connect(clientTransport)).rejects.toThrow('Unsupported protocol version'); + + expect(requests.map(request => request.method)).toEqual(['server/discover', 'server/discover']); + expect(requests[0]!.params?._meta?.[PROTOCOL_VERSION_META_KEY]).toBe('2026-DRAFT-A'); + expect(requests[1]!.params?._meta?.[PROTOCOL_VERSION_META_KEY]).toBe('2026-DRAFT-B'); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + + it('rejects when discovery succeeds but no version is mutually supported and none is stateful', async () => { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION] }); + const { clientTransport } = fakeDiscoverServer(['2099-01-01']); + + await expect(client.connect(clientTransport)).rejects.toThrow( + 'No mutually supported protocol version (server supports: 2099-01-01)' + ); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + + it('hard-fails without attempting initialize when discovery offers neither a mutual nor a stateful version', async () => { + // A dual-era client must not "helpfully" initialize with its own stateful versions + // when the server's discovery answer listed none of them. + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION] } + ); + const { clientTransport, requests } = fakeDiscoverServer(['2099-01-01']); + + await expect(client.connect(clientTransport)).rejects.toThrow( + 'No mutually supported protocol version (server supports: 2099-01-01)' + ); + expect(requests.map(request => request.method)).toEqual(['server/discover']); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + + it('falls back to initialize when discovery reports only stateful versions', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const requests: JSONRPCRequest[] = []; + serverTransport.onmessage = message => { + if (!isJSONRPCRequest(message)) { + return; + } + requests.push(message); + if (message.method === 'server/discover') { + void serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { + supportedVersions: [LATEST_PROTOCOL_VERSION], + capabilities: {}, + serverInfo: { name: 'stateful-discover-server', version: '0.0.0' } + } + }); + return; + } + if (message.method === 'initialize') { + const params = message.params as { protocolVersion: string }; + void serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: params.protocolVersion, + capabilities: {}, + serverInfo: { name: 'stateful-discover-server', version: '0.0.0' } + } + }); + return; + } + void serverTransport.send({ jsonrpc: '2.0', id: message.id, result: {} }); + }; + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION] } + ); + + await client.connect(clientTransport); + + expect(requests.map(request => request.method)).toEqual(['server/discover', 'initialize']); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + await client.close(); + }); + + it('falls back to initialize when a -32004 lists a mutually supported stateful version', async () => { + // The same server facts as "discovery reports only stateful versions", + // delivered via the error path: the -32004's supported list shares no + // per-request version but does share a stateful one. The outcome must + // match the success path — handshake fallback, not failure. + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const requests: JSONRPCRequest[] = []; + serverTransport.onmessage = message => { + if (!isJSONRPCRequest(message)) { + return; + } + requests.push(message); + if (message.method === 'server/discover') { + void serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + error: { + code: -32_004, + message: 'Unsupported protocol version', + data: { supported: ['2099-01-01', LATEST_PROTOCOL_VERSION], requested: DRAFT_PROTOCOL_VERSION } + } + }); + return; + } + if (message.method === 'initialize') { + const params = message.params as { protocolVersion: string }; + void serverTransport.send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: params.protocolVersion, + capabilities: {}, + serverInfo: { name: 'rejecting-server', version: '0.0.0' } + } + }); + return; + } + void serverTransport.send({ jsonrpc: '2.0', id: message.id, result: {} }); + }; + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION] } + ); + + await client.connect(clientTransport); + + expect(requests.map(request => request.method)).toEqual(['server/discover', 'initialize']); + // The fallback handshake goes out clean: no per-request envelope keys. + const initialize = requests[1]!; + expect(initialize.params?._meta?.[PROTOCOL_VERSION_META_KEY]).toBeUndefined(); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + await client.close(); + }); + + it('keeps the -32004 failure when its supported list shares neither a per-request nor a stateful version', async () => { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION] } + ); + const { clientTransport, requests } = fakeDiscoverServer(['2099-01-01'], [DRAFT_PROTOCOL_VERSION]); + + await expect(client.connect(clientTransport)).rejects.toThrow('Unsupported protocol version'); + + // No initialize attempt: the server told us its versions and none is usable. + expect(requests.map(request => request.method)).toEqual(['server/discover']); + expect(client.getNegotiatedProtocolVersion()).toBeUndefined(); + }); + + it('clears the transport version pin before the fallback initialize', async () => { + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION] } + ); + const { clientTransport, requests } = fakeInitializeServer(); + + // Record the pin in effect at the moment each request hits the wire — + // on HTTP transports this is exactly the MCP-Protocol-Version header. + const transport: Transport = clientTransport; + let pin: string | undefined; + transport.setProtocolVersion = (version?: string) => { + pin = version; + }; + const pinAtSend: Array = []; + const originalSend = clientTransport.send.bind(clientTransport); + transport.send = (message, options) => { + if (isJSONRPCRequest(message)) { + pinAtSend.push(pin); + } + return originalSend(message, options); + }; + + await client.connect(clientTransport); + + expect(requests.map(request => request.method)).toEqual(['server/discover', 'initialize']); + // The probe carried the claimed draft pin; the fallback initialize went + // out with the pin cleared (a strict legacy server would reject the + // stale draft header on the very handshake meant for it). + expect(pinAtSend).toEqual([DRAFT_PROTOCOL_VERSION, undefined]); + // After the handshake the negotiated stateful version is pinned. + expect(pin).toBe(LATEST_PROTOCOL_VERSION); + await client.close(); + }); + }); + + describe('per-request logging level', () => { + it('setLoggingLevel stamps the envelope instead of sending logging/setLevel', async () => { + const client = new Client({ name: 'test-client', version: '1.0.0' }, { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION] }); + const { clientTransport, requests } = fakeDiscoverServer([DRAFT_PROTOCOL_VERSION]); + await client.connect(clientTransport); + + await client.setLoggingLevel('warning'); + await client.ping(); + + // The removed RPC never touched the wire; the level rides the envelope instead. + expect(requests.map(request => request.method)).toEqual(['server/discover', 'ping']); + const ping = requests.find(request => request.method === 'ping'); + expect(ping?.params?._meta?.[LOG_LEVEL_META_KEY]).toBe('warning'); + expect(ping?.params?._meta?.[PROTOCOL_VERSION_META_KEY]).toBe(DRAFT_PROTOCOL_VERSION); + await client.close(); + }); + + it('setLoggingLevel still sends logging/setLevel on the initialize path', async () => { + const client = new Client({ name: 'test-client', version: '1.0.0' }); + const { clientTransport, requests } = fakeInitializeServer(); + await client.connect(clientTransport); + + await client.setLoggingLevel('warning'); + + const setLevel = requests.find(request => request.method === 'logging/setLevel'); + expect(setLevel?.params).toEqual({ level: 'warning' }); + await client.close(); + }); + }); +}); diff --git a/packages/client/test/client/streamableHttp.test.ts b/packages/client/test/client/streamableHttp.test.ts index 0edf8b75ac..4da6357e11 100644 --- a/packages/client/test/client/streamableHttp.test.ts +++ b/packages/client/test/client/streamableHttp.test.ts @@ -2019,4 +2019,115 @@ describe('StreamableHTTPClientTransport', () => { expect(onclose).toHaveBeenCalledTimes(1); }); }); + + describe('per-request protocol revisions: HTTP error bodies', () => { + const request: JSONRPCMessage = { jsonrpc: '2.0', method: 'server/discover', id: 3 }; + + const errorBodyResponse = (body: unknown, status = 400) => ({ + ok: false, + status, + statusText: 'Bad Request', + headers: new Headers({ 'content-type': 'application/json' }), + text: () => Promise.resolve(JSON.stringify(body)) + }); + + it('delivers a correlatable JSON-RPC error body to onmessage instead of throwing', async () => { + // Under a per-request (non-stateful) revision, servers answer pre-dispatch failures + // with an HTTP error whose body echoes the request id — the protocol layer needs the + // message (e.g. -32004 with data.supported) to retry or fall back. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { protocolVersion: '2026-07-28' }); + const errorBody = { + jsonrpc: '2.0', + id: 3, + error: { code: -32_004, message: 'Unsupported protocol version', data: { supported: ['2026-07-28'] } } + }; + (globalThis.fetch as Mock).mockResolvedValueOnce(errorBodyResponse(errorBody)); + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + await transport.send(request); + + expect(messageSpy).toHaveBeenCalledWith(errorBody); + }); + + it('keeps throwing for error bodies without a request id (legacy connection-level rejections)', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { protocolVersion: '2026-07-28' }); + (globalThis.fetch as Mock).mockResolvedValueOnce( + errorBodyResponse({ jsonrpc: '2.0', id: null, error: { code: -32_000, message: 'Bad Request: Server not initialized' } }) + ); + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + await expect(transport.send(request)).rejects.toThrow(SdkHttpError); + expect(messageSpy).not.toHaveBeenCalled(); + }); + + it('keeps throwing under stateful protocol versions (behavior unchanged for the 2025 line)', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { protocolVersion: '2025-11-25' }); + (globalThis.fetch as Mock).mockResolvedValueOnce( + errorBodyResponse({ jsonrpc: '2.0', id: 3, error: { code: -32_004, message: 'Unsupported protocol version' } }) + ); + const messageSpy = vi.fn(); + transport.onmessage = messageSpy; + + await expect(transport.send(request)).rejects.toThrow(SdkHttpError); + expect(messageSpy).not.toHaveBeenCalled(); + }); + }); + + describe('per-request protocol revisions: sessionless invariants', () => { + const okJsonResponse = (body: unknown, headers: Record = {}) => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json', ...headers }), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)) + }); + + it('ignores an Mcp-Session-Id a server emits while pinned to a per-request revision (no storage, no replay)', async () => { + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { protocolVersion: '2026-07-28' }); + const request: JSONRPCMessage = { jsonrpc: '2.0', method: 'tools/list', id: 1, params: {} }; + (globalThis.fetch as Mock).mockResolvedValue( + okJsonResponse({ jsonrpc: '2.0', id: 1, result: {} }, { 'mcp-session-id': 'injected-by-server' }) + ); + + await transport.send(request); + expect(transport.sessionId).toBeUndefined(); + + await transport.send({ ...request, id: 2 }); + const lastCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect((lastCall[1].headers as Headers).get('mcp-session-id')).toBeNull(); + }); + + it('captures the session id from the fallback initialize once the per-request pin is cleared', async () => { + // On the fallback path the client clears the probed per-request pin + // before sending the initialize handshake (a strict legacy server must + // never see the stale draft header on the very handshake meant for it); + // the handshake's session id is then captured by the normal un-pinned + // branch — no initialize-specific exemption exists. + transport = new StreamableHTTPClientTransport(new URL('http://localhost:1234/mcp'), { protocolVersion: '2026-07-28' }); + transport.setProtocolVersion(undefined); // what Client does before the fallback handshake + const initialize: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'initialize', + id: 1, + params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '0' } } + }; + (globalThis.fetch as Mock).mockResolvedValue( + okJsonResponse({ jsonrpc: '2.0', id: 1, result: {} }, { 'mcp-session-id': 'handshake-session' }) + ); + + await transport.send(initialize); + // The cleared pin means the handshake itself carries no version header. + const handshakeCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect((handshakeCall[1].headers as Headers).get('mcp-protocol-version')).toBeNull(); + expect(transport.sessionId).toBe('handshake-session'); + + // After the handshake pins a stateful version, the session id is replayed as today. + transport.setProtocolVersion('2025-11-25'); + await transport.send({ jsonrpc: '2.0', method: 'tools/list', id: 2, params: {} }); + const lastCall = (globalThis.fetch as Mock).mock.calls.at(-1)!; + expect((lastCall[1].headers as Headers).get('mcp-session-id')).toBe('handshake-session'); + }); + }); }); diff --git a/packages/codemod/src/generated/specSchemaMap.ts b/packages/codemod/src/generated/specSchemaMap.ts index 2536456fb8..77f3d3dfc8 100644 --- a/packages/codemod/src/generated/specSchemaMap.ts +++ b/packages/codemod/src/generated/specSchemaMap.ts @@ -27,6 +27,8 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'CreateMessageResultWithToolsSchema', 'CreateTaskResultSchema', 'CursorSchema', + 'DiscoverRequestSchema', + 'DiscoverResultSchema', 'ElicitRequestFormParamsSchema', 'ElicitRequestParamsSchema', 'ElicitRequestSchema', @@ -112,6 +114,7 @@ export const SPEC_SCHEMA_NAMES: ReadonlySet = new Set([ 'ReadResourceResultSchema', 'RelatedTaskMetadataSchema', 'RequestIdSchema', + 'RequestMetaEnvelopeSchema', 'RequestMetaSchema', 'RequestSchema', 'ResourceContentsSchema', diff --git a/packages/core/src/errors/notImplementedYetError.ts b/packages/core/src/errors/notImplementedYetError.ts new file mode 100644 index 0000000000..3108519b1d --- /dev/null +++ b/packages/core/src/errors/notImplementedYetError.ts @@ -0,0 +1,19 @@ +/** + * Marks a deliberately-open seam: code that is already wired into the message + * flow but whose behavior lands with a later stage of the 2026-07-28 spec + * implementation. Every throw site names what fills the gap in an adjacent + * code comment, so `git grep NotImplementedYet` is the inventory of remaining + * gaps; the implementation is complete when that grep comes back empty. + * + * Messages must stay wire-safe — transports may surface them in error + * responses — so they describe the missing behavior generically and never + * reference internal planning details. + * + * @internal + */ +export class NotImplementedYetError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotImplementedYetError'; + } +} diff --git a/packages/core/src/exports/public/index.ts b/packages/core/src/exports/public/index.ts index 729144f1a3..5780260946 100644 --- a/packages/core/src/exports/public/index.ts +++ b/packages/core/src/exports/public/index.ts @@ -71,15 +71,21 @@ export * from '../../types/types.js'; // Constants export { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + DRAFT_PROTOCOL_VERSION, INTERNAL_ERROR, INVALID_PARAMS, INVALID_REQUEST, JSONRPC_VERSION, LATEST_PROTOCOL_VERSION, + LOG_LEVEL_META_KEY, METHOD_NOT_FOUND, PARSE_ERROR, + PROTOCOL_VERSION_META_KEY, RELATED_TASK_META_KEY, + STATEFUL_PROTOCOL_VERSIONS, SUPPORTED_PROTOCOL_VERSIONS } from '../../types/constants.js'; @@ -87,7 +93,7 @@ export { export { ProtocolErrorCode } from '../../types/enums.js'; // Error classes -export { ProtocolError, UrlElicitationRequiredError } from '../../types/errors.js'; +export { ProtocolError, UnsupportedProtocolVersionError, UrlElicitationRequiredError } from '../../types/errors.js'; // Type guards and message parsing export { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a704267ee3..7f40daa2b2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,4 +1,5 @@ export * from './auth/errors.js'; +export * from './errors/notImplementedYetError.js'; export * from './errors/sdkErrors.js'; export * from './shared/auth.js'; export * from './shared/authUtils.js'; diff --git a/packages/core/src/shared/protocol.ts b/packages/core/src/shared/protocol.ts index ed78cc68d0..c9ef059998 100644 --- a/packages/core/src/shared/protocol.ts +++ b/packages/core/src/shared/protocol.ts @@ -9,6 +9,7 @@ import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, + Implementation, JSONRPCErrorResponse, JSONRPCNotification, JSONRPCRequest, @@ -24,6 +25,7 @@ import type { Request, RequestId, RequestMeta, + RequestMetaEnvelope, RequestMethod, RequestTypeMap, Result, @@ -31,6 +33,7 @@ import type { ServerCapabilities } from '../types/index.js'; import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, getNotificationSchema, getRequestSchema, getResultSchema, @@ -56,8 +59,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 `connect()`. * * @default {@linkcode SUPPORTED_PROTOCOL_VERSIONS} */ @@ -154,6 +159,18 @@ export type BaseContext = { */ method: string; + /** + * The protocol version governing this request. + * + * Resolved per request from the source the governing protocol revision defines: the initialize + * handshake for 2025-line revisions; the request's own `_meta` for revisions that carry a + * per-request envelope. Sources never mix and never fall back. + * + * For requests that arrive before the initialize handshake completes (where only `ping` is legal), + * this is the SDK's {@linkcode DEFAULT_NEGOTIATED_PROTOCOL_VERSION}. + */ + protocolVersion: string; + /** * Metadata from the original request. */ @@ -210,7 +227,13 @@ export type ServerContext = BaseContext & { mcpReq: { /** * Send a log message notification to the client. - * Respects the client's log level filter set via logging/setLevel. + * + * Delivery follows the protocol revision governing the request: under + * initialize-based revisions it respects the client's log level filter set via + * logging/setLevel; under per-request revisions the request's + * `io.modelcontextprotocol/logLevel` `_meta` claim is the opt-in — only messages + * at or above the claimed level are delivered, on the response stream of the + * originating request, and without a claim nothing is emitted for the request. */ log: (level: LoggingLevel, data: unknown, logger?: string) => Promise; @@ -246,6 +269,32 @@ export type ServerContext = BaseContext & { */ closeStandaloneSSE?: () => void; }; + + /** + * Facts about the client that is calling this request. + * + * The values are resolved per request from the source the governing protocol revision defines: the + * initialize handshake for 2025-line revisions; the request's own `_meta` for revisions that carry a + * per-request envelope. Sources never mix and never fall back. For requests that arrive before the + * initialize handshake completes (where only `ping` is legal), `capabilities` is `{}` and `info` is + * `undefined`. + * + * Capabilities are declarations, not authorization: this exists so the server does not ask a client to + * do something it cannot (e.g. elicitation). It MUST NOT be used to gate access to tools, resources, or + * data — that is the authorization layer's job. + */ + client: { + /** + * The capabilities the calling client declared. `{}` before the initialize handshake completes. + */ + capabilities: ClientCapabilities; + + /** + * The calling client's implementation name and version, or `undefined` before the initialize + * handshake completes. + */ + info: Implementation | undefined; + }; }; /** @@ -285,6 +334,24 @@ export abstract class Protocol { protected _supportedProtocolVersions: string[]; + /** + * The protocol version negotiated for the current connection, set by the concrete role at its + * negotiation point (the client when it receives InitializeResult, or when discovery-based + * version selection completes under a per-request revision; the server when it responds + * to initialize), or `undefined` before negotiation has completed. + */ + protected _negotiatedProtocolVersion?: string; + + /** + * The per-request `_meta` envelope stamped onto every outgoing request, or `undefined` when + * the governing protocol revision carries no envelope (the 2025 line and older). + * + * Set by the concrete role when a per-request (non-stateful) protocol revision governs the + * connection — the client, on completing discovery-based version selection. The reserved + * envelope keys are authoritative: they overwrite same-named keys in caller-supplied `_meta`. + */ + protected _requestMetaEnvelope?: RequestMetaEnvelope; + /** * Callback for when the connection is closed for any reason. * @@ -333,6 +400,19 @@ export abstract class Protocol { */ protected abstract buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ContextT; + /** + * The protocol version negotiated for the current connection, or `undefined` before + * negotiation has completed. + * + * It is read per request when building the handler context to populate + * `ctx.mcpReq.protocolVersion`. On the client side, when manually reconstructing a transport for + * reconnection, pass this value to the new transport so it continues sending the required + * `mcp-protocol-version` header. + */ + getNegotiatedProtocolVersion(): string | undefined { + return this._negotiatedProtocolVersion; + } + private async _oncancel(notification: CancelledNotification): Promise { if (!notification.params.requestId) { return; @@ -477,8 +557,6 @@ export abstract class Protocol { } private _onrequest(request: JSONRPCRequest, extra?: MessageExtraInfo): void { - const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; - // Capture the current transport at request time to ensure responses go to the correct client const capturedTransport = this._transport; @@ -487,27 +565,20 @@ export abstract class Protocol { const sendRequest = (r: Request, resultSchema: U, options?: RequestOptions) => this._requestWithSchema(r, resultSchema, { ...options, relatedRequestId: request.id }); - if (handler === undefined) { - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: request.id, - error: { - code: ProtocolErrorCode.MethodNotFound, - message: 'Method not found' - } - }; - capturedTransport?.send(errorResponse).catch(error => this._onerror(new Error(`Failed to send an error response: ${error}`))); - return; - } - const abortController = new AbortController(); this._requestHandlerAbortControllers.set(request.id, abortController); + // Connection-path context: sourced from the transport and the initialize handshake. + // Other dispatch paths build their own context before calling invokeRequestHandler(). const baseCtx: BaseContext = { sessionId: capturedTransport?.sessionId, mcpReq: { id: request.id, method: request.method, + // Resolved from the source the governing revision defines. At present that is the + // initialize-handshake-negotiated version (exposed by the concrete role); requests that + // arrive before the handshake completes (only `ping` is legal there) get the SDK default. + protocolVersion: this.getNegotiatedProtocolVersion() ?? DEFAULT_NEGOTIATED_PROTOCOL_VERSION, _meta: request.params?._meta, signal: abortController.signal, // BaseContext.mcpReq.send is declared with two overloads (spec-method-keyed and explicit-schema). Arrow @@ -532,41 +603,15 @@ export abstract class Protocol { }; const ctx = this.buildContext(baseCtx, extra); - // Starting with Promise.resolve() puts any synchronous errors into the monad as well. - Promise.resolve() - .then(() => handler(request, ctx)) - .then( - async result => { - if (abortController.signal.aborted) { - // Request was cancelled - return; - } - - const response: JSONRPCResponse = { - result, - jsonrpc: '2.0', - id: request.id - }; - await capturedTransport?.send(response); - }, - async error => { - if (abortController.signal.aborted) { - // Request was cancelled - return; - } - - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: request.id, - error: { - code: Number.isSafeInteger(error['code']) ? error['code'] : ProtocolErrorCode.InternalError, - message: error.message ?? 'Internal error', - ...(error['data'] !== undefined && { data: error['data'] }) - } - }; - await capturedTransport?.send(errorResponse); + this.invokeRequestHandler(request, ctx) + .then(async response => { + if (abortController.signal.aborted) { + // Request was cancelled + return; } - ) + + await capturedTransport?.send(response); + }) .catch(error => this._onerror(new Error(`Failed to send response: ${error}`))) .finally(() => { if (this._requestHandlerAbortControllers.get(request.id) === abortController) { @@ -575,6 +620,50 @@ export abstract class Protocol { }); } + /** + * Looks up and runs the handler for an incoming JSON-RPC request, mapping the outcome + * (no handler installed, handler result, or handler error) to the JSON-RPC response message. + * + * This is the shared dispatch core: it never touches the transport and never rejects with + * handler errors (they are mapped to error responses). Callers own response delivery, + * cancellation bookkeeping, and assembling the `ctx` appropriate to their dispatch path. + */ + protected invokeRequestHandler(request: JSONRPCRequest, ctx: ContextT): Promise { + const handler = this._requestHandlers.get(request.method) ?? this.fallbackRequestHandler; + + if (handler === undefined) { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: request.id, + error: { + code: ProtocolErrorCode.MethodNotFound, + message: 'Method not found' + } + }; + return Promise.resolve(errorResponse); + } + + // Starting with Promise.resolve() puts any synchronous errors into the monad as well. + return Promise.resolve() + .then(() => handler(request, ctx)) + .then( + (result): JSONRPCResponse => ({ + result, + jsonrpc: '2.0', + id: request.id + }), + (error): JSONRPCErrorResponse => ({ + jsonrpc: '2.0', + id: request.id, + error: { + code: Number.isSafeInteger(error['code']) ? error['code'] : ProtocolErrorCode.InternalError, + message: error.message ?? 'Internal error', + ...(error['data'] !== undefined && { data: error['data'] }) + } + }) + ); + } + private _onprogress(notification: ProgressNotification): void { const { progressToken, ...params } = notification.params; const messageId = Number(progressToken); @@ -735,11 +824,15 @@ export abstract class Protocol { if (options?.onprogress) { this._progressHandlers.set(messageId, options.onprogress); + } + + if (options?.onprogress || this._requestMetaEnvelope !== undefined) { jsonrpcRequest.params = { ...request.params, _meta: { ...request.params?._meta, - progressToken: messageId + ...this._requestMetaEnvelope, + ...(options?.onprogress && { progressToken: messageId }) } }; } diff --git a/packages/core/src/shared/transport.ts b/packages/core/src/shared/transport.ts index c606e2e3b5..35359c6d1c 100644 --- a/packages/core/src/shared/transport.ts +++ b/packages/core/src/shared/transport.ts @@ -1,4 +1,12 @@ -import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '../types/index.js'; +import type { + AuthInfo, + JSONRPCMessage, + JSONRPCNotification, + JSONRPCRequest, + JSONRPCResponse, + MessageExtraInfo, + RequestId +} from '../types/index.js'; export type FetchLike = (url: string | URL, init?: RequestInit) => Promise; @@ -68,6 +76,50 @@ export type TransportSendOptions = { */ onresumptiontoken?: ((token: string) => void) | undefined; }; +/** + * Per-request context a transport supplies when dispatching a stateless + * (draft-protocol-version) request. Everything in it is request-scoped — + * nothing is sourced from connection or session state. + * + * @internal + */ +export interface StatelessDispatchContext { + /** Validated authorization info from the transport layer (HTTP only). */ + authInfo?: AuthInfo; + + /** + * Aborts when the transport-level request ends before dispatch completes + * (HTTP: the client disconnected, which the spec treats as cancellation + * of the request). Absent on transports without a per-request lifetime. + */ + signal?: AbortSignal; + + /** + * Delivers a request-scoped notification (e.g. `notifications/progress`) + * on the originating response stream, before the final response. Absent + * when the transport cannot carry notifications for this request; + * notifications are then dropped. + */ + sendNotification?: (notification: JSONRPCNotification) => Promise; +} + +/** + * The handler shape a server installs on a transport to serve stateless + * (draft-protocol-version) requests: `dispatch` is request→response, always + * short-lived. Installed on the transport via + * {@linkcode Transport.setStatelessHandlers}. + * + * `dispatch` resolves with the JSON-RPC response message for the request — + * handler failures are mapped to error responses, never rejections. A + * rejection therefore signals an internal fault; transports answer it with a + * generic internal error that leaks nothing. + * + * @internal + */ +export interface StatelessHandlers { + dispatch(request: JSONRPCRequest, ctx: StatelessDispatchContext): Promise; +} + /** * Describes the minimal contract for an MCP transport that a client or server can communicate over. */ @@ -122,13 +174,26 @@ export interface Transport { sessionId?: string | undefined; /** - * Sets the protocol version used for the connection (called when the initialize response is received). + * Sets the protocol version used for the connection (called when the initialize response is + * received, or while a per-request revision governs the connection). Called with `undefined` + * to clear the pin — e.g. when a discovery probe resolves to the initialize fallback and the + * handshake must go out without the probed version. */ - setProtocolVersion?: ((version: string) => void) | undefined; + setProtocolVersion?: ((version?: string) => void) | undefined; /** * Sets the supported protocol versions for header validation (called during connect). * This allows the server to pass its supported versions to the transport. */ setSupportedProtocolVersions?: ((versions: string[]) => void) | undefined; + + /** + * Server-side. Installs the stateless dispatch handlers on this transport. + * Called by `Server.connect()` before the transport is started. Transports + * that implement this route stateless (draft-protocol-version) requests via + * `StatelessHandlers` instead of the `onmessage` path. + * + * @internal + */ + setStatelessHandlers?(handlers: StatelessHandlers): void; } diff --git a/packages/core/src/types/constants.ts b/packages/core/src/types/constants.ts index 878d5111cf..e88258fc5a 100644 --- a/packages/core/src/types/constants.ts +++ b/packages/core/src/types/constants.ts @@ -2,8 +2,63 @@ 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). + * Stateless: never negotiated via initialize. + */ +export const DRAFT_PROTOCOL_VERSION = '2026-07-28'; + export const RELATED_TASK_META_KEY = 'io.modelcontextprotocol/related-task'; +/* Reserved `_meta` keys for the per-request envelope (protocol revision 2026-07-28) */ + +/** + * `_meta` key carrying the MCP protocol version governing a request. + * + * For the HTTP transport, the value must match the `MCP-Protocol-Version` header. + */ +export const PROTOCOL_VERSION_META_KEY = 'io.modelcontextprotocol/protocolVersion'; + +/** + * `_meta` key identifying the client software making a request. + */ +export const CLIENT_INFO_META_KEY = 'io.modelcontextprotocol/clientInfo'; + +/** + * `_meta` key carrying the client's capabilities for a request. + * + * Capabilities are declared per request rather than once at initialization; + * servers must not infer capabilities from prior requests. + */ +export const CLIENT_CAPABILITIES_META_KEY = 'io.modelcontextprotocol/clientCapabilities'; + +/** + * `_meta` key carrying the desired log level for a request. + * + * When absent, the server must not send `notifications/message` notifications + * for the request. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. + */ +export const LOG_LEVEL_META_KEY = 'io.modelcontextprotocol/logLevel'; + /* JSON-RPC types */ export const JSONRPC_VERSION = '2.0'; diff --git a/packages/core/src/types/enums.ts b/packages/core/src/types/enums.ts index 0d80242a86..0e3b65f9f0 100644 --- a/packages/core/src/types/enums.ts +++ b/packages/core/src/types/enums.ts @@ -12,5 +12,15 @@ export enum ProtocolErrorCode { // MCP-specific error codes ResourceNotFound = -32_002, + /** + * Processing the request requires a capability the client did not declare + * in the request's `clientCapabilities` (protocol revision 2026-07-28). + */ + MissingRequiredClientCapability = -32_003, + /** + * The request's protocol version is unknown to the server or unsupported + * by it (protocol revision 2026-07-28). + */ + UnsupportedProtocolVersion = -32_004, UrlElicitationRequired = -32_042 } diff --git a/packages/core/src/types/errors.ts b/packages/core/src/types/errors.ts index 796c0d2bc5..a175686d13 100644 --- a/packages/core/src/types/errors.ts +++ b/packages/core/src/types/errors.ts @@ -1,5 +1,5 @@ import { ProtocolErrorCode } from './enums.js'; -import type { ElicitRequestURLParams } from './types.js'; +import type { ElicitRequestURLParams, UnsupportedProtocolVersionErrorData } from './types.js'; /** * Protocol errors are JSON-RPC errors that cross the wire as error responses. @@ -27,6 +27,13 @@ export class ProtocolError extends Error { } } + if (code === ProtocolErrorCode.UnsupportedProtocolVersion && data) { + const errorData = data as Partial; + if (Array.isArray(errorData.supported) && typeof errorData.requested === 'string') { + return new UnsupportedProtocolVersionError({ supported: errorData.supported, requested: errorData.requested }, message); + } + } + // Default to generic ProtocolError return new ProtocolError(code, message, data); } @@ -47,3 +54,32 @@ export class UrlElicitationRequiredError extends ProtocolError { return (this.data as { elicitations: ElicitRequestURLParams[] })?.elicitations ?? []; } } + +/** + * Error type for the `-32004` UnsupportedProtocolVersion protocol error (protocol + * revision 2026-07-28): the request's protocol version is unknown to the server or + * unsupported by it. + * + * The error data lists the protocol versions the receiver supports (`supported`), + * so the sender can choose a mutually supported version and retry, and echoes the + * version that was requested (`requested`). + */ +export class UnsupportedProtocolVersionError extends ProtocolError { + constructor(data: UnsupportedProtocolVersionErrorData, message: string = `Unsupported protocol version: ${data.requested}`) { + super(ProtocolErrorCode.UnsupportedProtocolVersion, message, data); + } + + /** + * Protocol versions the receiver supports. + */ + get supported(): string[] { + return (this.data as UnsupportedProtocolVersionErrorData).supported; + } + + /** + * The protocol version that was requested. + */ + get requested(): string { + return (this.data as UnsupportedProtocolVersionErrorData).requested; + } +} diff --git a/packages/core/src/types/schemas.ts b/packages/core/src/types/schemas.ts index a243c1b829..1fd9c09843 100644 --- a/packages/core/src/types/schemas.ts +++ b/packages/core/src/types/schemas.ts @@ -1,6 +1,13 @@ import * as z from 'zod/v4'; -import { JSONRPC_VERSION, RELATED_TASK_META_KEY } from './constants.js'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + JSONRPC_VERSION, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, + RELATED_TASK_META_KEY +} from './constants.js'; import type { JSONArray, JSONObject, @@ -113,7 +120,14 @@ export const ResultSchema = z.looseObject({ * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields) * for notes on `_meta` usage. */ - _meta: RequestMetaSchema.optional() + _meta: RequestMetaSchema.optional(), + /** + * Indicates the type of the result, allowing the receiver to determine how to + * parse the result object. Servers implementing protocol revision 2026-07-28 or + * later always include this field; results from earlier revisions omit it, and + * an absent value must be treated as `"complete"`. + */ + resultType: z.string().optional() }); /** @@ -552,6 +566,43 @@ export const InitializedNotificationSchema = NotificationSchema.extend({ params: NotificationsParamsSchema.optional() }); +/* Discovery */ +/** + * A request from the client asking the server to advertise its supported protocol + * versions, capabilities, and other metadata (protocol revision 2026-07-28). Servers + * MUST implement `server/discover`. Clients MAY call it but are not required to — + * version negotiation can also happen inline via the per-request `_meta` envelope. + */ +export const DiscoverRequestSchema = RequestSchema.extend({ + method: z.literal('server/discover'), + params: BaseRequestParamsSchema.optional() +}); + +/** + * The result returned by the server for a `server/discover` request. + */ +export const DiscoverResultSchema = ResultSchema.extend({ + /** + * MCP protocol versions this server supports. The client should choose a + * version from this list for use in subsequent requests. + */ + supportedVersions: z.array(z.string()), + /** + * The capabilities of the server. + */ + capabilities: ServerCapabilitiesSchema, + /** + * Information about the server software implementation. + */ + serverInfo: ImplementationSchema, + /** + * Instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + */ + instructions: z.string().optional() +}); + /* Ping */ /** * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. @@ -1509,6 +1560,48 @@ export const LoggingMessageNotificationSchema = NotificationSchema.extend({ params: LoggingMessageNotificationParamsSchema }); +/* Per-request `_meta` envelope */ +/** + * The per-request `_meta` envelope carried by every request under protocol revision + * 2026-07-28: the protocol version governing the request, the client implementation + * info, and the client's capabilities — declared per request rather than once at + * initialization — plus the optional log-level opt-in. + * + * This schema models the complete envelope on its own. The base request schemas + * ({@linkcode RequestMetaSchema}) deliberately stay lenient so the same wire schemas + * parse requests from earlier protocol revisions (no envelope) as well; envelope + * requiredness is enforced per request at dispatch time, not here. + */ +export const RequestMetaEnvelopeSchema = z.looseObject({ + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken: ProgressTokenSchema.optional(), + /** + * The MCP protocol version being used for this request. For the HTTP transport, + * the value must match the `MCP-Protocol-Version` header. + */ + [PROTOCOL_VERSION_META_KEY]: z.string(), + /** + * Identifies the client software making the request. + */ + [CLIENT_INFO_META_KEY]: ImplementationSchema, + /** + * The client's capabilities for this specific request. An empty object means the + * client supports no optional capabilities. Servers must not infer capabilities + * from prior requests. + */ + [CLIENT_CAPABILITIES_META_KEY]: ClientCapabilitiesSchema, + /** + * The desired log level for this request. When absent, the server must not send + * `notifications/message` notifications for the request. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577); remains + * in the specification for at least twelve months. + */ + [LOG_LEVEL_META_KEY]: LoggingLevelSchema.optional() +}); + /* Sampling */ /** * Hints to use for model selection. @@ -2079,6 +2172,7 @@ export const RootsListChangedNotificationSchema = NotificationSchema.extend({ export const ClientRequestSchema = z.union([ PingRequestSchema, InitializeRequestSchema, + DiscoverRequestSchema, CompleteRequestSchema, SetLevelRequestSchema, GetPromptRequestSchema, @@ -2142,6 +2236,7 @@ export const ServerNotificationSchema = z.union([ export const ServerResultSchema = z.union([ EmptyResultSchema, InitializeResultSchema, + DiscoverResultSchema, CompleteResultSchema, GetPromptResultSchema, ListPromptsResultSchema, @@ -2159,6 +2254,7 @@ export const ServerResultSchema = z.union([ const resultSchemas: Record = { ping: EmptyResultSchema, initialize: InitializeResultSchema, + 'server/discover': DiscoverResultSchema, 'completion/complete': CompleteResultSchema, 'logging/setLevel': EmptyResultSchema, 'prompts/get': GetPromptResultSchema, diff --git a/packages/core/src/types/spec.types.2025-11-25.ts b/packages/core/src/types/spec.types.2025-11-25.ts new file mode 100644 index 0000000000..225a53c2d7 --- /dev/null +++ b/packages/core/src/types/spec.types.2025-11-25.ts @@ -0,0 +1,2559 @@ +/** + * This file is automatically generated from the Model Context Protocol specification. + * + * Source: https://github.com/modelcontextprotocol/modelcontextprotocol + * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/2025-11-25/schema.ts + * Last updated from commit: 357adac47ab2654b64799f994e6db8d3df4ee19d + * + * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. + * To update this file, run: pnpm run fetch:spec-types 2025-11-25 + */ /* JSON-RPC types */ + +/** + * Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. + * + * @category JSON-RPC + */ +export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse; + +/** @internal */ +export const LATEST_PROTOCOL_VERSION = '2025-11-25'; +/** @internal */ +export const JSONRPC_VERSION = '2.0'; + +/** + * A progress token, used to associate progress notifications with the original request. + * + * @category Common Types + */ +export type ProgressToken = string | number; + +/** + * An opaque token used to represent a cursor for pagination. + * + * @category Common Types + */ +export type Cursor = string; + +/** + * Common params for any task-augmented request. + * + * @internal + */ +export interface TaskAugmentedRequestParams extends RequestParams { + /** + * If specified, the caller is requesting task-augmented execution for this request. + * The request will return a CreateTaskResult immediately, and the actual result can be + * retrieved later via tasks/result. + * + * Task augmentation is subject to capability negotiation - receivers MUST declare support + * for task augmentation of specific request types in their capabilities. + */ + task?: TaskMetadata; +} +/** + * Common params for any request. + * + * @internal + */ +export interface RequestParams { + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { + /** + * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by notifications/progress). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. + */ + progressToken?: ProgressToken; + [key: string]: unknown; + }; +} + +/** @internal */ +export interface Request { + method: string; + // Allow unofficial extensions of `Request.params` without impacting `RequestParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** @internal */ +export interface NotificationParams { + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** @internal */ +export interface Notification { + method: string; + // Allow unofficial extensions of `Notification.params` without impacting `NotificationParams`. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: { [key: string]: any }; +} + +/** + * @category Common Types + */ +export interface Result { + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; + [key: string]: unknown; +} + +/** + * @category Common Types + */ +export interface Error { + /** + * The error type that occurred. + */ + code: number; + /** + * A short description of the error. The message SHOULD be limited to a concise single sentence. + */ + message: string; + /** + * Additional information about the error. The value of this member is defined by the sender (e.g. detailed error information, nested errors etc.). + */ + data?: unknown; +} + +/** + * A uniquely identifying ID for a request in JSON-RPC. + * + * @category Common Types + */ +export type RequestId = string | number; + +/** + * A request that expects a response. + * + * @category JSON-RPC + */ +export interface JSONRPCRequest extends Request { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; +} + +/** + * A notification which does not expect a response. + * + * @category JSON-RPC + */ +export interface JSONRPCNotification extends Notification { + jsonrpc: typeof JSONRPC_VERSION; +} + +/** + * A successful (non-error) response to a request. + * + * @category JSON-RPC + */ +export interface JSONRPCResultResponse { + jsonrpc: typeof JSONRPC_VERSION; + id: RequestId; + result: Result; +} + +/** + * A response to a request that indicates an error occurred. + * + * @category JSON-RPC + */ +export interface JSONRPCErrorResponse { + jsonrpc: typeof JSONRPC_VERSION; + id?: RequestId; + error: Error; +} + +/** + * A response to a request, containing either the result or error. + * + * @category JSON-RPC + */ +export type JSONRPCResponse = JSONRPCResultResponse | JSONRPCErrorResponse; + +// Standard JSON-RPC error codes +export const PARSE_ERROR = -32700; +export const INVALID_REQUEST = -32600; +export const METHOD_NOT_FOUND = -32601; +export const INVALID_PARAMS = -32602; +export const INTERNAL_ERROR = -32603; + +// Implementation-specific JSON-RPC error codes [-32000, -32099] +/** @internal */ +export const URL_ELICITATION_REQUIRED = -32042; + +/** + * An error response that indicates that the server requires the client to provide additional information via an elicitation request. + * + * @internal + */ +export interface URLElicitationRequiredError extends Omit { + error: Error & { + code: typeof URL_ELICITATION_REQUIRED; + data: { + elicitations: ElicitRequestURLParams[]; + [key: string]: unknown; + }; + }; +} + +/* Empty result */ +/** + * A response that indicates success but carries no data. + * + * @category Common Types + */ +export type EmptyResult = Result; + +/* Cancellation */ +/** + * Parameters for a `notifications/cancelled` notification. + * + * @category `notifications/cancelled` + */ +export interface CancelledNotificationParams extends NotificationParams { + /** + * The ID of the request to cancel. + * + * This MUST correspond to the ID of a request previously issued in the same direction. + * This MUST be provided for cancelling non-task requests. + * This MUST NOT be used for cancelling tasks (use the `tasks/cancel` request instead). + */ + requestId?: RequestId; + + /** + * An optional string describing the reason for the cancellation. This MAY be logged or presented to the user. + */ + reason?: string; +} + +/** + * This notification can be sent by either side to indicate that it is cancelling a previously-issued request. + * + * The request SHOULD still be in-flight, but due to communication latency, it is always possible that this notification MAY arrive after the request has already finished. + * + * This notification indicates that the result will be unused, so any associated processing SHOULD cease. + * + * A client MUST NOT attempt to cancel its `initialize` request. + * + * For task cancellation, use the `tasks/cancel` request instead of this notification. + * + * @category `notifications/cancelled` + */ +export interface CancelledNotification extends JSONRPCNotification { + method: 'notifications/cancelled'; + params: CancelledNotificationParams; +} + +/* Initialization */ +/** + * Parameters for an `initialize` request. + * + * @category `initialize` + */ +export interface InitializeRequestParams extends RequestParams { + /** + * The latest version of the Model Context Protocol that the client supports. The client MAY decide to support older versions as well. + */ + protocolVersion: string; + capabilities: ClientCapabilities; + clientInfo: Implementation; +} + +/** + * This request is sent from the client to the server when it first connects, asking it to begin initialization. + * + * @category `initialize` + */ +export interface InitializeRequest extends JSONRPCRequest { + method: 'initialize'; + params: InitializeRequestParams; +} + +/** + * After receiving an initialize request from the client, the server sends this response. + * + * @category `initialize` + */ +export interface InitializeResult extends Result { + /** + * The version of the Model Context Protocol that the server wants to use. This may not match the version that the client requested. If the client cannot support this version, it MUST disconnect. + */ + protocolVersion: string; + capabilities: ServerCapabilities; + serverInfo: Implementation; + + /** + * Instructions describing how to use the server and its features. + * + * This can be used by clients to improve the LLM's understanding of available tools, resources, etc. It can be thought of like a "hint" to the model. For example, this information MAY be added to the system prompt. + */ + instructions?: string; +} + +/** + * This notification is sent from the client to the server after initialization has finished. + * + * @category `notifications/initialized` + */ +export interface InitializedNotification extends JSONRPCNotification { + method: 'notifications/initialized'; + params?: NotificationParams; +} + +/** + * Capabilities a client may support. Known capabilities are defined here, in this schema, but this is not a closed set: any client can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ClientCapabilities { + /** + * Experimental, non-standard capabilities that the client supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the client supports listing roots. + */ + roots?: { + /** + * Whether the client supports notifications for changes to the roots list. + */ + listChanged?: boolean; + }; + /** + * Present if the client supports sampling from an LLM. + */ + sampling?: { + /** + * Whether the client supports context inclusion via includeContext parameter. + * If not declared, servers SHOULD only use `includeContext: "none"` (or omit it). + */ + context?: object; + /** + * Whether the client supports tool use via tools and toolChoice parameters. + */ + tools?: object; + }; + /** + * Present if the client supports elicitation from the server. + */ + elicitation?: { form?: object; url?: object }; + + /** + * Present if the client supports task-augmented requests. + */ + tasks?: { + /** + * Whether this client supports tasks/list. + */ + list?: object; + /** + * Whether this client supports tasks/cancel. + */ + cancel?: object; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for sampling-related requests. + */ + sampling?: { + /** + * Whether the client supports task-augmented sampling/createMessage requests. + */ + createMessage?: object; + }; + /** + * Task support for elicitation-related requests. + */ + elicitation?: { + /** + * Whether the client supports task-augmented elicitation/create requests. + */ + create?: object; + }; + }; + }; +} + +/** + * Capabilities that a server may support. Known capabilities are defined here, in this schema, but this is not a closed set: any server can define its own, additional capabilities. + * + * @category `initialize` + */ +export interface ServerCapabilities { + /** + * Experimental, non-standard capabilities that the server supports. + */ + experimental?: { [key: string]: object }; + /** + * Present if the server supports sending log messages to the client. + */ + logging?: object; + /** + * Present if the server supports argument autocompletion suggestions. + */ + completions?: object; + /** + * Present if the server offers any prompt templates. + */ + prompts?: { + /** + * Whether this server supports notifications for changes to the prompt list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any resources to read. + */ + resources?: { + /** + * Whether this server supports subscribing to resource updates. + */ + subscribe?: boolean; + /** + * Whether this server supports notifications for changes to the resource list. + */ + listChanged?: boolean; + }; + /** + * Present if the server offers any tools to call. + */ + tools?: { + /** + * Whether this server supports notifications for changes to the tool list. + */ + listChanged?: boolean; + }; + /** + * Present if the server supports task-augmented requests. + */ + tasks?: { + /** + * Whether this server supports tasks/list. + */ + list?: object; + /** + * Whether this server supports tasks/cancel. + */ + cancel?: object; + /** + * Specifies which request types can be augmented with tasks. + */ + requests?: { + /** + * Task support for tool-related requests. + */ + tools?: { + /** + * Whether the server supports task-augmented tools/call requests. + */ + call?: object; + }; + }; + }; +} + +/** + * An optionally-sized icon that can be displayed in a user interface. + * + * @category Common Types + */ +export interface Icon { + /** + * A standard URI pointing to an icon resource. May be an HTTP/HTTPS URL or a + * `data:` URI with Base64-encoded image data. + * + * Consumers SHOULD takes steps to ensure URLs serving icons are from the + * same domain as the client/server or a trusted domain. + * + * Consumers SHOULD take appropriate precautions when consuming SVGs as they can contain + * executable JavaScript. + * + * @format uri + */ + src: string; + + /** + * Optional MIME type override if the source MIME type is missing or generic. + * For example: `"image/png"`, `"image/jpeg"`, or `"image/svg+xml"`. + */ + mimeType?: string; + + /** + * Optional array of strings that specify sizes at which the icon can be used. + * Each string should be in WxH format (e.g., `"48x48"`, `"96x96"`) or `"any"` for scalable formats like SVG. + * + * If not provided, the client should assume that the icon can be used at any size. + */ + sizes?: string[]; + + /** + * Optional specifier for the theme this icon is designed for. `light` indicates + * the icon is designed to be used with a light background, and `dark` indicates + * the icon is designed to be used with a dark background. + * + * If not provided, the client should assume the icon can be used with any theme. + */ + theme?: 'light' | 'dark'; +} + +/** + * Base interface to add `icons` property. + * + * @internal + */ +export interface Icons { + /** + * Optional set of sized icons that the client can display in a user interface. + * + * Clients that support rendering icons MUST support at least the following MIME types: + * - `image/png` - PNG images (safe, universal compatibility) + * - `image/jpeg` (and `image/jpg`) - JPEG images (safe, universal compatibility) + * + * Clients that support rendering icons SHOULD also support: + * - `image/svg+xml` - SVG images (scalable but requires security precautions) + * - `image/webp` - WebP images (modern, efficient format) + */ + icons?: Icon[]; +} + +/** + * Base interface for metadata with name (identifier) and title (display name) properties. + * + * @internal + */ +export interface BaseMetadata { + /** + * Intended for programmatic or logical use, but used as a display name in past specs or fallback (if title isn't present). + */ + name: string; + + /** + * Intended for UI and end-user contexts — optimized to be human-readable and easily understood, + * even by those unfamiliar with domain-specific terminology. + * + * If not provided, the name should be used for display (except for Tool, + * where `annotations.title` should be given precedence over using `name`, + * if present). + */ + title?: string; +} + +/** + * Describes the MCP implementation. + * + * @category `initialize` + */ +export interface Implementation extends BaseMetadata, Icons { + version: string; + + /** + * An optional human-readable description of what this implementation does. + * + * This can be used by clients or servers to provide context about their purpose + * and capabilities. For example, a server might describe the types of resources + * or tools it provides, while a client might describe its intended use case. + */ + description?: string; + + /** + * An optional URL of the website for this implementation. + * + * @format uri + */ + websiteUrl?: string; +} + +/* Ping */ +/** + * A ping, issued by either the server or the client, to check that the other party is still alive. The receiver must promptly respond, or else may be disconnected. + * + * @category `ping` + */ +export interface PingRequest extends JSONRPCRequest { + method: 'ping'; + params?: RequestParams; +} + +/* Progress notifications */ + +/** + * Parameters for a `notifications/progress` notification. + * + * @category `notifications/progress` + */ +export interface ProgressNotificationParams extends NotificationParams { + /** + * The progress token which was given in the initial request, used to associate this notification with the request that is proceeding. + */ + progressToken: ProgressToken; + /** + * The progress thus far. This should increase every time progress is made, even if the total is unknown. + * + * @TJS-type number + */ + progress: number; + /** + * Total number of items to process (or total progress required), if known. + * + * @TJS-type number + */ + total?: number; + /** + * An optional message describing the current progress. + */ + message?: string; +} + +/** + * An out-of-band notification used to inform the receiver of a progress update for a long-running request. + * + * @category `notifications/progress` + */ +export interface ProgressNotification extends JSONRPCNotification { + method: 'notifications/progress'; + params: ProgressNotificationParams; +} + +/* Pagination */ +/** + * Common parameters for paginated requests. + * + * @internal + */ +export interface PaginatedRequestParams extends RequestParams { + /** + * An opaque token representing the current pagination position. + * If provided, the server should return results starting after this cursor. + */ + cursor?: Cursor; +} + +/** @internal */ +export interface PaginatedRequest extends JSONRPCRequest { + params?: PaginatedRequestParams; +} + +/** @internal */ +export interface PaginatedResult extends Result { + /** + * An opaque token representing the pagination position after the last returned result. + * If present, there may be more results available. + */ + nextCursor?: Cursor; +} + +/* Resources */ +/** + * Sent from the client to request a list of resources the server has. + * + * @category `resources/list` + */ +export interface ListResourcesRequest extends PaginatedRequest { + method: 'resources/list'; +} + +/** + * The server's response to a resources/list request from the client. + * + * @category `resources/list` + */ +export interface ListResourcesResult extends PaginatedResult { + resources: Resource[]; +} + +/** + * Sent from the client to request a list of resource templates the server has. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesRequest extends PaginatedRequest { + method: 'resources/templates/list'; +} + +/** + * The server's response to a resources/templates/list request from the client. + * + * @category `resources/templates/list` + */ +export interface ListResourceTemplatesResult extends PaginatedResult { + resourceTemplates: ResourceTemplate[]; +} + +/** + * Common parameters when working with resources. + * + * @internal + */ +export interface ResourceRequestParams extends RequestParams { + /** + * The URI of the resource. The URI can use any protocol; it is up to the server how to interpret it. + * + * @format uri + */ + uri: string; +} + +/** + * Parameters for a `resources/read` request. + * + * @category `resources/read` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ReadResourceRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to the server, to read a specific resource URI. + * + * @category `resources/read` + */ +export interface ReadResourceRequest extends JSONRPCRequest { + method: 'resources/read'; + params: ReadResourceRequestParams; +} + +/** + * The server's response to a resources/read request from the client. + * + * @category `resources/read` + */ +export interface ReadResourceResult extends Result { + contents: (TextResourceContents | BlobResourceContents)[]; +} + +/** + * An optional notification from the server to the client, informing it that the list of resources it can read from has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/resources/list_changed` + */ +export interface ResourceListChangedNotification extends JSONRPCNotification { + method: 'notifications/resources/list_changed'; + params?: NotificationParams; +} + +/** + * Parameters for a `resources/subscribe` request. + * + * @category `resources/subscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface SubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request resources/updated notifications from the server whenever a particular resource changes. + * + * @category `resources/subscribe` + */ +export interface SubscribeRequest extends JSONRPCRequest { + method: 'resources/subscribe'; + params: SubscribeRequestParams; +} + +/** + * Parameters for a `resources/unsubscribe` request. + * + * @category `resources/unsubscribe` + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UnsubscribeRequestParams extends ResourceRequestParams {} + +/** + * Sent from the client to request cancellation of resources/updated notifications from the server. This should follow a previous resources/subscribe request. + * + * @category `resources/unsubscribe` + */ +export interface UnsubscribeRequest extends JSONRPCRequest { + method: 'resources/unsubscribe'; + params: UnsubscribeRequestParams; +} + +/** + * Parameters for a `notifications/resources/updated` notification. + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotificationParams extends NotificationParams { + /** + * The URI of the resource that has been updated. This might be a sub-resource of the one that the client actually subscribed to. + * + * @format uri + */ + uri: string; +} + +/** + * A notification from the server to the client, informing it that a resource has changed and may need to be read again. This should only be sent if the client previously sent a resources/subscribe request. + * + * @category `notifications/resources/updated` + */ +export interface ResourceUpdatedNotification extends JSONRPCNotification { + method: 'notifications/resources/updated'; + params: ResourceUpdatedNotificationParams; +} + +/** + * A known resource that the server is capable of reading. + * + * @category `resources/list` + */ +export interface Resource extends BaseMetadata, Icons { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + + /** + * A description of what this resource represents. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + * + * This can be used by Hosts to display file sizes and estimate context window usage. + */ + size?: number; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A template description for resources available on the server. + * + * @category `resources/templates/list` + */ +export interface ResourceTemplate extends BaseMetadata, Icons { + /** + * A URI template (according to RFC 6570) that can be used to construct resource URIs. + * + * @format uri-template + */ + uriTemplate: string; + + /** + * A description of what this template is for. + * + * This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * The MIME type for all resources that match this template. This should only be included if all resources matching this template have the same type. + */ + mimeType?: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The contents of a specific resource or sub-resource. + * + * @internal + */ +export interface ResourceContents { + /** + * The URI of this resource. + * + * @format uri + */ + uri: string; + /** + * The MIME type of this resource, if known. + */ + mimeType?: string; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * @category Content + */ +export interface TextResourceContents extends ResourceContents { + /** + * The text of the item. This must only be set if the item can actually be represented as text (not binary data). + */ + text: string; +} + +/** + * @category Content + */ +export interface BlobResourceContents extends ResourceContents { + /** + * A base64-encoded string representing the binary data of the item. + * + * @format byte + */ + blob: string; +} + +/* Prompts */ +/** + * Sent from the client to request a list of prompts and prompt templates the server has. + * + * @category `prompts/list` + */ +export interface ListPromptsRequest extends PaginatedRequest { + method: 'prompts/list'; +} + +/** + * The server's response to a prompts/list request from the client. + * + * @category `prompts/list` + */ +export interface ListPromptsResult extends PaginatedResult { + prompts: Prompt[]; +} + +/** + * Parameters for a `prompts/get` request. + * + * @category `prompts/get` + */ +export interface GetPromptRequestParams extends RequestParams { + /** + * The name of the prompt or prompt template. + */ + name: string; + /** + * Arguments to use for templating the prompt. + */ + arguments?: { [key: string]: string }; +} + +/** + * Used by the client to get a prompt provided by the server. + * + * @category `prompts/get` + */ +export interface GetPromptRequest extends JSONRPCRequest { + method: 'prompts/get'; + params: GetPromptRequestParams; +} + +/** + * The server's response to a prompts/get request from the client. + * + * @category `prompts/get` + */ +export interface GetPromptResult extends Result { + /** + * An optional description for the prompt. + */ + description?: string; + messages: PromptMessage[]; +} + +/** + * A prompt or prompt template that the server offers. + * + * @category `prompts/list` + */ +export interface Prompt extends BaseMetadata, Icons { + /** + * An optional description of what this prompt provides + */ + description?: string; + + /** + * A list of arguments to use for templating the prompt. + */ + arguments?: PromptArgument[]; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Describes an argument that a prompt can accept. + * + * @category `prompts/list` + */ +export interface PromptArgument extends BaseMetadata { + /** + * A human-readable description of the argument. + */ + description?: string; + /** + * Whether this argument must be provided. + */ + required?: boolean; +} + +/** + * The sender or recipient of messages and data in a conversation. + * + * @category Common Types + */ +export type Role = 'user' | 'assistant'; + +/** + * Describes a message returned as part of a prompt. + * + * This is similar to `SamplingMessage`, but also supports the embedding of + * resources from the MCP server. + * + * @category `prompts/get` + */ +export interface PromptMessage { + role: Role; + content: ContentBlock; +} + +/** + * A resource that the server is capable of reading, included in a prompt or tool call result. + * + * Note: resource links returned by tools are not guaranteed to appear in the results of `resources/list` requests. + * + * @category Content + */ +export interface ResourceLink extends Resource { + type: 'resource_link'; +} + +/** + * The contents of a resource, embedded into a prompt or tool call result. + * + * It is up to the client how best to render embedded resources for the benefit + * of the LLM and/or the user. + * + * @category Content + */ +export interface EmbeddedResource { + type: 'resource'; + resource: TextResourceContents | BlobResourceContents; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} +/** + * An optional notification from the server to the client, informing it that the list of prompts it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/prompts/list_changed` + */ +export interface PromptListChangedNotification extends JSONRPCNotification { + method: 'notifications/prompts/list_changed'; + params?: NotificationParams; +} + +/* Tools */ +/** + * Sent from the client to request a list of tools the server has. + * + * @category `tools/list` + */ +export interface ListToolsRequest extends PaginatedRequest { + method: 'tools/list'; +} + +/** + * The server's response to a tools/list request from the client. + * + * @category `tools/list` + */ +export interface ListToolsResult extends PaginatedResult { + tools: Tool[]; +} + +/** + * The server's response to a tool call. + * + * @category `tools/call` + */ +export interface CallToolResult extends Result { + /** + * A list of content objects that represent the unstructured result of the tool call. + */ + content: ContentBlock[]; + + /** + * An optional JSON object that represents the structured result of the tool call. + */ + structuredContent?: { [key: string]: unknown }; + + /** + * Whether the tool call ended in an error. + * + * If not set, this is assumed to be false (the call was successful). + * + * Any errors that originate from the tool SHOULD be reported inside the result + * object, with `isError` set to true, _not_ as an MCP protocol-level error + * response. Otherwise, the LLM would not be able to see that an error occurred + * and self-correct. + * + * However, any errors in _finding_ the tool, an error indicating that the + * server does not support tool calls, or any other exceptional conditions, + * should be reported as an MCP error response. + */ + isError?: boolean; +} + +/** + * Parameters for a `tools/call` request. + * + * @category `tools/call` + */ +export interface CallToolRequestParams extends TaskAugmentedRequestParams { + /** + * The name of the tool. + */ + name: string; + /** + * Arguments to use for the tool call. + */ + arguments?: { [key: string]: unknown }; +} + +/** + * Used by the client to invoke a tool provided by the server. + * + * @category `tools/call` + */ +export interface CallToolRequest extends JSONRPCRequest { + method: 'tools/call'; + params: CallToolRequestParams; +} + +/** + * An optional notification from the server to the client, informing it that the list of tools it offers has changed. This may be issued by servers without any previous subscription from the client. + * + * @category `notifications/tools/list_changed` + */ +export interface ToolListChangedNotification extends JSONRPCNotification { + method: 'notifications/tools/list_changed'; + params?: NotificationParams; +} + +/** + * Additional properties describing a Tool to clients. + * + * NOTE: all properties in ToolAnnotations are **hints**. + * They are not guaranteed to provide a faithful description of + * tool behavior (including descriptive properties like `title`). + * + * Clients should never make tool use decisions based on ToolAnnotations + * received from untrusted servers. + * + * @category `tools/list` + */ +export interface ToolAnnotations { + /** + * A human-readable title for the tool. + */ + title?: string; + + /** + * If true, the tool does not modify its environment. + * + * Default: false + */ + readOnlyHint?: boolean; + + /** + * If true, the tool may perform destructive updates to its environment. + * If false, the tool performs only additive updates. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: true + */ + destructiveHint?: boolean; + + /** + * If true, calling the tool repeatedly with the same arguments + * will have no additional effect on its environment. + * + * (This property is meaningful only when `readOnlyHint == false`) + * + * Default: false + */ + idempotentHint?: boolean; + + /** + * If true, this tool may interact with an "open world" of external + * entities. If false, the tool's domain of interaction is closed. + * For example, the world of a web search tool is open, whereas that + * of a memory tool is not. + * + * Default: true + */ + openWorldHint?: boolean; +} + +/** + * Execution-related properties for a tool. + * + * @category `tools/list` + */ +export interface ToolExecution { + /** + * Indicates whether this tool supports task-augmented execution. + * This allows clients to handle long-running operations through polling + * the task system. + * + * - "forbidden": Tool does not support task-augmented execution (default when absent) + * - "optional": Tool may support task-augmented execution + * - "required": Tool requires task-augmented execution + * + * Default: "forbidden" + */ + taskSupport?: 'forbidden' | 'optional' | 'required'; +} + +/** + * Definition for a tool the client can call. + * + * @category `tools/list` + */ +export interface Tool extends BaseMetadata, Icons { + /** + * A human-readable description of the tool. + * + * This can be used by clients to improve the LLM's understanding of available tools. It can be thought of like a "hint" to the model. + */ + description?: string; + + /** + * A JSON Schema object defining the expected parameters for the tool. + */ + inputSchema: { + $schema?: string; + type: 'object'; + properties?: { [key: string]: object }; + required?: string[]; + }; + + /** + * Execution-related properties for this tool. + */ + execution?: ToolExecution; + + /** + * An optional JSON Schema object defining the structure of the tool's output returned in + * the structuredContent field of a CallToolResult. + * + * Defaults to JSON Schema 2020-12 when no explicit $schema is provided. + * Currently restricted to type: "object" at the root level. + */ + outputSchema?: { + $schema?: string; + type: 'object'; + properties?: { [key: string]: object }; + required?: string[]; + }; + + /** + * Optional additional tool information. + * + * Display name precedence order is: title, annotations.title, then name. + */ + annotations?: ToolAnnotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/* Tasks */ + +/** + * The status of a task. + * + * @category `tasks` + */ +export type TaskStatus = + | 'working' // The request is currently being processed + | 'input_required' // The task is waiting for input (e.g., elicitation or sampling) + | 'completed' // The request completed successfully and results are available + | 'failed' // The associated request did not complete successfully. For tool calls specifically, this includes cases where the tool call result has `isError` set to true. + | 'cancelled'; // The request was cancelled before completion + +/** + * Metadata for augmenting a request with task execution. + * Include this in the `task` field of the request parameters. + * + * @category `tasks` + */ +export interface TaskMetadata { + /** + * Requested duration in milliseconds to retain task from creation. + */ + ttl?: number; +} + +/** + * Metadata for associating messages with a task. + * Include this in the `_meta` field under the key `io.modelcontextprotocol/related-task`. + * + * @category `tasks` + */ +export interface RelatedTaskMetadata { + /** + * The task identifier this message is associated with. + */ + taskId: string; +} + +/** + * Data associated with a task. + * + * @category `tasks` + */ +export interface Task { + /** + * The task identifier. + */ + taskId: string; + + /** + * Current task state. + */ + status: TaskStatus; + + /** + * Optional human-readable message describing the current task state. + * This can provide context for any status, including: + * - Reasons for "cancelled" status + * - Summaries for "completed" status + * - Diagnostic information for "failed" status (e.g., error details, what went wrong) + */ + statusMessage?: string; + + /** + * ISO 8601 timestamp when the task was created. + */ + createdAt: string; + + /** + * ISO 8601 timestamp when the task was last updated. + */ + lastUpdatedAt: string; + + /** + * Actual retention duration from creation in milliseconds, null for unlimited. + * @nullable + */ + ttl: number | null; + + /** + * Suggested polling interval in milliseconds. + */ + pollInterval?: number; +} + +/** + * A response to a task-augmented request. + * + * @category `tasks` + */ +export interface CreateTaskResult extends Result { + task: Task; +} + +/** + * A request to retrieve the state of a task. + * + * @category `tasks/get` + */ +export interface GetTaskRequest extends JSONRPCRequest { + method: 'tasks/get'; + params: { + /** + * The task identifier to query. + */ + taskId: string; + }; +} + +/** + * The response to a tasks/get request. + * + * @category `tasks/get` + */ +export type GetTaskResult = Result & Task; + +/** + * A request to retrieve the result of a completed task. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadRequest extends JSONRPCRequest { + method: 'tasks/result'; + params: { + /** + * The task identifier to retrieve results for. + */ + taskId: string; + }; +} + +/** + * The response to a tasks/result request. + * The structure matches the result type of the original request. + * For example, a tools/call task would return the CallToolResult structure. + * + * @category `tasks/result` + */ +export interface GetTaskPayloadResult extends Result { + [key: string]: unknown; +} + +/** + * A request to cancel a task. + * + * @category `tasks/cancel` + */ +export interface CancelTaskRequest extends JSONRPCRequest { + method: 'tasks/cancel'; + params: { + /** + * The task identifier to cancel. + */ + taskId: string; + }; +} + +/** + * The response to a tasks/cancel request. + * + * @category `tasks/cancel` + */ +export type CancelTaskResult = Result & Task; + +/** + * A request to retrieve a list of tasks. + * + * @category `tasks/list` + */ +export interface ListTasksRequest extends PaginatedRequest { + method: 'tasks/list'; +} + +/** + * The response to a tasks/list request. + * + * @category `tasks/list` + */ +export interface ListTasksResult extends PaginatedResult { + tasks: Task[]; +} + +/** + * Parameters for a `notifications/tasks/status` notification. + * + * @category `notifications/tasks/status` + */ +export type TaskStatusNotificationParams = NotificationParams & Task; + +/** + * An optional notification from the receiver to the requestor, informing them that a task's status has changed. Receivers are not required to send these notifications. + * + * @category `notifications/tasks/status` + */ +export interface TaskStatusNotification extends JSONRPCNotification { + method: 'notifications/tasks/status'; + params: TaskStatusNotificationParams; +} + +/* Logging */ + +/** + * Parameters for a `logging/setLevel` request. + * + * @category `logging/setLevel` + */ +export interface SetLevelRequestParams extends RequestParams { + /** + * The level of logging that the client wants to receive from the server. The server should send all logs at this level and higher (i.e., more severe) to the client as notifications/message. + */ + level: LoggingLevel; +} + +/** + * A request from the client to the server, to enable or adjust logging. + * + * @category `logging/setLevel` + */ +export interface SetLevelRequest extends JSONRPCRequest { + method: 'logging/setLevel'; + params: SetLevelRequestParams; +} + +/** + * Parameters for a `notifications/message` notification. + * + * @category `notifications/message` + */ +export interface LoggingMessageNotificationParams extends NotificationParams { + /** + * The severity of this log message. + */ + level: LoggingLevel; + /** + * An optional name of the logger issuing this message. + */ + logger?: string; + /** + * The data to be logged, such as a string message or an object. Any JSON serializable type is allowed here. + */ + data: unknown; +} + +/** + * JSONRPCNotification of a log message passed from server to client. If no logging/setLevel request has been sent from the client, the server MAY decide which messages to send automatically. + * + * @category `notifications/message` + */ +export interface LoggingMessageNotification extends JSONRPCNotification { + method: 'notifications/message'; + params: LoggingMessageNotificationParams; +} + +/** + * The severity of a log message. + * + * These map to syslog message severities, as specified in RFC-5424: + * https://datatracker.ietf.org/doc/html/rfc5424#section-6.2.1 + * + * @category Common Types + */ +export type LoggingLevel = 'debug' | 'info' | 'notice' | 'warning' | 'error' | 'critical' | 'alert' | 'emergency'; + +/* Sampling */ +/** + * Parameters for a `sampling/createMessage` request. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequestParams extends TaskAugmentedRequestParams { + messages: SamplingMessage[]; + /** + * The server's preferences for which model to select. The client MAY ignore these preferences. + */ + modelPreferences?: ModelPreferences; + /** + * An optional system prompt the server wants to use for sampling. The client MAY modify or omit this prompt. + */ + systemPrompt?: string; + /** + * A request to include context from one or more MCP servers (including the caller), to be attached to the prompt. + * The client MAY ignore this request. + * + * Default is "none". Values "thisServer" and "allServers" are soft-deprecated. Servers SHOULD only use these values if the client + * declares ClientCapabilities.sampling.context. These values may be removed in future spec releases. + */ + includeContext?: 'none' | 'thisServer' | 'allServers'; + /** + * @TJS-type number + */ + temperature?: number; + /** + * The requested maximum number of tokens to sample (to prevent runaway completions). + * + * The client MAY choose to sample fewer tokens than the requested maximum. + */ + maxTokens: number; + stopSequences?: string[]; + /** + * Optional metadata to pass through to the LLM provider. The format of this metadata is provider-specific. + */ + metadata?: object; + /** + * Tools that the model may use during generation. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + */ + tools?: Tool[]; + /** + * Controls how the model uses tools. + * The client MUST return an error if this field is provided but ClientCapabilities.sampling.tools is not declared. + * Default is `{ mode: "auto" }`. + */ + toolChoice?: ToolChoice; +} + +/** + * Controls tool selection behavior for sampling requests. + * + * @category `sampling/createMessage` + */ +export interface ToolChoice { + /** + * Controls the tool use ability of the model: + * - "auto": Model decides whether to use tools (default) + * - "required": Model MUST use at least one tool before completing + * - "none": Model MUST NOT use any tools + */ + mode?: 'auto' | 'required' | 'none'; +} + +/** + * A request from the server to sample an LLM via the client. The client has full discretion over which model to select. The client should also inform the user before beginning sampling, to allow them to inspect the request (human in the loop) and decide whether to approve it. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageRequest extends JSONRPCRequest { + method: 'sampling/createMessage'; + params: CreateMessageRequestParams; +} + +/** + * The client's response to a sampling/createMessage request from the server. + * The client should inform the user before returning the sampled message, to allow them + * to inspect the response (human in the loop) and decide whether to allow the server to see it. + * + * @category `sampling/createMessage` + */ +export interface CreateMessageResult extends Result, SamplingMessage { + /** + * The name of the model that generated the message. + */ + model: string; + + /** + * The reason why sampling stopped, if known. + * + * Standard values: + * - "endTurn": Natural end of the assistant's turn + * - "stopSequence": A stop sequence was encountered + * - "maxTokens": Maximum token limit was reached + * - "toolUse": The model wants to use one or more tools + * + * This field is an open string to allow for provider-specific stop reasons. + */ + stopReason?: 'endTurn' | 'stopSequence' | 'maxTokens' | 'toolUse' | string; +} + +/** + * Describes a message issued to or received from an LLM API. + * + * @category `sampling/createMessage` + */ +export interface SamplingMessage { + role: Role; + content: SamplingMessageContentBlock | SamplingMessageContentBlock[]; + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * @category `sampling/createMessage` + */ +export type SamplingMessageContentBlock = TextContent | ImageContent | AudioContent | ToolUseContent | ToolResultContent; + +/** + * Optional annotations for the client. The client can use annotations to inform how objects are used or displayed + * + * @category Common Types + */ +export interface Annotations { + /** + * Describes who the intended audience of this object or data is. + * + * It can include multiple entries to indicate content useful for multiple audiences (e.g., `["user", "assistant"]`). + */ + audience?: Role[]; + + /** + * Describes how important this data is for operating the server. + * + * A value of 1 means "most important," and indicates that the data is + * effectively required, while 0 means "least important," and indicates that + * the data is entirely optional. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + priority?: number; + + /** + * The moment the resource was last modified, as an ISO 8601 formatted string. + * + * Should be an ISO 8601 formatted string (e.g., "2025-01-12T15:00:58Z"). + * + * Examples: last activity timestamp in an open file, timestamp when the resource + * was attached, etc. + */ + lastModified?: string; +} + +/** + * @category Content + */ +export type ContentBlock = TextContent | ImageContent | AudioContent | ResourceLink | EmbeddedResource; + +/** + * Text provided to or from an LLM. + * + * @category Content + */ +export interface TextContent { + type: 'text'; + + /** + * The text content of the message. + */ + text: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * An image provided to or from an LLM. + * + * @category Content + */ +export interface ImageContent { + type: 'image'; + + /** + * The base64-encoded image data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the image. Different providers may support different image types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * Audio provided to or from an LLM. + * + * @category Content + */ +export interface AudioContent { + type: 'audio'; + + /** + * The base64-encoded audio data. + * + * @format byte + */ + data: string; + + /** + * The MIME type of the audio. Different providers may support different audio types. + */ + mimeType: string; + + /** + * Optional annotations for the client. + */ + annotations?: Annotations; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A request from the assistant to call a tool. + * + * @category `sampling/createMessage` + */ +export interface ToolUseContent { + type: 'tool_use'; + + /** + * A unique identifier for this tool use. + * + * This ID is used to match tool results to their corresponding tool uses. + */ + id: string; + + /** + * The name of the tool to call. + */ + name: string; + + /** + * The arguments to pass to the tool, conforming to the tool's input schema. + */ + input: { [key: string]: unknown }; + + /** + * Optional metadata about the tool use. Clients SHOULD preserve this field when + * including tool uses in subsequent sampling requests to enable caching optimizations. + * + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The result of a tool use, provided by the user back to the assistant. + * + * @category `sampling/createMessage` + */ +export interface ToolResultContent { + type: 'tool_result'; + + /** + * The ID of the tool use this result corresponds to. + * + * This MUST match the ID from a previous ToolUseContent. + */ + toolUseId: string; + + /** + * The unstructured result content of the tool use. + * + * This has the same format as CallToolResult.content and can include text, images, + * audio, resource links, and embedded resources. + */ + content: ContentBlock[]; + + /** + * An optional structured result object. + * + * If the tool defined an outputSchema, this SHOULD conform to that schema. + */ + structuredContent?: { [key: string]: unknown }; + + /** + * Whether the tool use resulted in an error. + * + * If true, the content typically describes the error that occurred. + * Default: false + */ + isError?: boolean; + + /** + * Optional metadata about the tool result. Clients SHOULD preserve this field when + * including tool results in subsequent sampling requests to enable caching optimizations. + * + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * The server's preferences for model selection, requested of the client during sampling. + * + * Because LLMs can vary along multiple dimensions, choosing the "best" model is + * rarely straightforward. Different models excel in different areas—some are + * faster but less capable, others are more capable but more expensive, and so + * on. This interface allows servers to express their priorities across multiple + * dimensions to help clients make an appropriate selection for their use case. + * + * These preferences are always advisory. The client MAY ignore them. It is also + * up to the client to decide how to interpret these preferences and how to + * balance them against other considerations. + * + * @category `sampling/createMessage` + */ +export interface ModelPreferences { + /** + * Optional hints to use for model selection. + * + * If multiple hints are specified, the client MUST evaluate them in order + * (such that the first match is taken). + * + * The client SHOULD prioritize these hints over the numeric priorities, but + * MAY still use the priorities to select from ambiguous matches. + */ + hints?: ModelHint[]; + + /** + * How much to prioritize cost when selecting a model. A value of 0 means cost + * is not important, while a value of 1 means cost is the most important + * factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + costPriority?: number; + + /** + * How much to prioritize sampling speed (latency) when selecting a model. A + * value of 0 means speed is not important, while a value of 1 means speed is + * the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + speedPriority?: number; + + /** + * How much to prioritize intelligence and capabilities when selecting a + * model. A value of 0 means intelligence is not important, while a value of 1 + * means intelligence is the most important factor. + * + * @TJS-type number + * @minimum 0 + * @maximum 1 + */ + intelligencePriority?: number; +} + +/** + * Hints to use for model selection. + * + * Keys not declared here are currently left unspecified by the spec and are up + * to the client to interpret. + * + * @category `sampling/createMessage` + */ +export interface ModelHint { + /** + * A hint for a model name. + * + * The client SHOULD treat this as a substring of a model name; for example: + * - `claude-3-5-sonnet` should match `claude-3-5-sonnet-20241022` + * - `sonnet` should match `claude-3-5-sonnet-20241022`, `claude-3-sonnet-20240229`, etc. + * - `claude` should match any Claude model + * + * The client MAY also map the string to a different provider's model name or a different model family, as long as it fills a similar niche; for example: + * - `gemini-1.5-flash` could match `claude-3-haiku-20240307` + */ + name?: string; +} + +/* Autocomplete */ +/** + * Parameters for a `completion/complete` request. + * + * @category `completion/complete` + */ +export interface CompleteRequestParams extends RequestParams { + ref: PromptReference | ResourceTemplateReference; + /** + * The argument's information + */ + argument: { + /** + * The name of the argument + */ + name: string; + /** + * The value of the argument to use for completion matching. + */ + value: string; + }; + + /** + * Additional, optional context for completions + */ + context?: { + /** + * Previously-resolved variables in a URI template or prompt. + */ + arguments?: { [key: string]: string }; + }; +} + +/** + * A request from the client to the server, to ask for completion options. + * + * @category `completion/complete` + */ +export interface CompleteRequest extends JSONRPCRequest { + method: 'completion/complete'; + params: CompleteRequestParams; +} + +/** + * The server's response to a completion/complete request + * + * @category `completion/complete` + */ +export interface CompleteResult extends Result { + completion: { + /** + * An array of completion values. Must not exceed 100 items. + */ + values: string[]; + /** + * The total number of completion options available. This can exceed the number of values actually sent in the response. + */ + total?: number; + /** + * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown. + */ + hasMore?: boolean; + }; +} + +/** + * A reference to a resource or resource template definition. + * + * @category `completion/complete` + */ +export interface ResourceTemplateReference { + type: 'ref/resource'; + /** + * The URI or URI template of the resource. + * + * @format uri-template + */ + uri: string; +} + +/** + * Identifies a prompt. + * + * @category `completion/complete` + */ +export interface PromptReference extends BaseMetadata { + type: 'ref/prompt'; +} + +/* Roots */ +/** + * Sent from the server to request a list of root URIs from the client. Roots allow + * servers to ask for specific directories or files to operate on. A common example + * for roots is providing a set of repositories or directories a server should operate + * on. + * + * This request is typically used when the server needs to understand the file system + * structure or access specific locations that the client has permission to read from. + * + * @category `roots/list` + */ +export interface ListRootsRequest extends JSONRPCRequest { + method: 'roots/list'; + params?: RequestParams; +} + +/** + * The client's response to a roots/list request from the server. + * This result contains an array of Root objects, each representing a root directory + * or file that the server can operate on. + * + * @category `roots/list` + */ +export interface ListRootsResult extends Result { + roots: Root[]; +} + +/** + * Represents a root directory or file that the server can operate on. + * + * @category `roots/list` + */ +export interface Root { + /** + * The URI identifying the root. This *must* start with file:// for now. + * This restriction may be relaxed in future versions of the protocol to allow + * other URI schemes. + * + * @format uri + */ + uri: string; + /** + * An optional name for the root. This can be used to provide a human-readable + * identifier for the root, which may be useful for display purposes or for + * referencing the root in other parts of the application. + */ + name?: string; + + /** + * See [General fields: `_meta`](/specification/2025-11-25/basic/index#meta) for notes on `_meta` usage. + */ + _meta?: { [key: string]: unknown }; +} + +/** + * A notification from the client to the server, informing it that the list of roots has changed. + * This notification should be sent whenever the client adds, removes, or modifies any root. + * The server should then request an updated list of roots using the ListRootsRequest. + * + * @category `notifications/roots/list_changed` + */ +export interface RootsListChangedNotification extends JSONRPCNotification { + method: 'notifications/roots/list_changed'; + params?: NotificationParams; +} + +/** + * The parameters for a request to elicit non-sensitive information from the user via a form in the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequestFormParams extends TaskAugmentedRequestParams { + /** + * The elicitation mode. + */ + mode?: 'form'; + + /** + * The message to present to the user describing what information is being requested. + */ + message: string; + + /** + * A restricted subset of JSON Schema. + * Only top-level properties are allowed, without nesting. + */ + requestedSchema: { + $schema?: string; + type: 'object'; + properties: { + [key: string]: PrimitiveSchemaDefinition; + }; + required?: string[]; + }; +} + +/** + * The parameters for a request to elicit information from the user via a URL in the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequestURLParams extends TaskAugmentedRequestParams { + /** + * The elicitation mode. + */ + mode: 'url'; + + /** + * The message to present to the user explaining why the interaction is needed. + */ + message: string; + + /** + * The ID of the elicitation, which must be unique within the context of the server. + * The client MUST treat this ID as an opaque value. + */ + elicitationId: string; + + /** + * The URL that the user should navigate to. + * + * @format uri + */ + url: string; +} + +/** + * The parameters for a request to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export type ElicitRequestParams = ElicitRequestFormParams | ElicitRequestURLParams; + +/** + * A request from the server to elicit additional information from the user via the client. + * + * @category `elicitation/create` + */ +export interface ElicitRequest extends JSONRPCRequest { + method: 'elicitation/create'; + params: ElicitRequestParams; +} + +/** + * Restricted schema definitions that only allow primitive types + * without nested objects or arrays. + * + * @category `elicitation/create` + */ +export type PrimitiveSchemaDefinition = StringSchema | NumberSchema | BooleanSchema | EnumSchema; + +/** + * @category `elicitation/create` + */ +export interface StringSchema { + type: 'string'; + title?: string; + description?: string; + minLength?: number; + maxLength?: number; + format?: 'email' | 'uri' | 'date' | 'date-time'; + default?: string; +} + +/** + * @category `elicitation/create` + */ +export interface NumberSchema { + type: 'number' | 'integer'; + title?: string; + description?: string; + minimum?: number; + maximum?: number; + default?: number; +} + +/** + * @category `elicitation/create` + */ +export interface BooleanSchema { + type: 'boolean'; + title?: string; + description?: string; + default?: boolean; +} + +/** + * Schema for single-selection enumeration without display titles for options. + * + * @category `elicitation/create` + */ +export interface UntitledSingleSelectEnumSchema { + type: 'string'; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum values to choose from. + */ + enum: string[]; + /** + * Optional default value. + */ + default?: string; +} + +/** + * Schema for single-selection enumeration with display titles for each option. + * + * @category `elicitation/create` + */ +export interface TitledSingleSelectEnumSchema { + type: 'string'; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Array of enum options with values and display labels. + */ + oneOf: Array<{ + /** + * The enum value. + */ + const: string; + /** + * Display label for this option. + */ + title: string; + }>; + /** + * Optional default value. + */ + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Combined single selection enumeration +export type SingleSelectEnumSchema = UntitledSingleSelectEnumSchema | TitledSingleSelectEnumSchema; + +/** + * Schema for multiple-selection enumeration without display titles for options. + * + * @category `elicitation/create` + */ +export interface UntitledMultiSelectEnumSchema { + type: 'array'; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for the array items. + */ + items: { + type: 'string'; + /** + * Array of enum values to choose from. + */ + enum: string[]; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * Schema for multiple-selection enumeration with display titles for each option. + * + * @category `elicitation/create` + */ +export interface TitledMultiSelectEnumSchema { + type: 'array'; + /** + * Optional title for the enum field. + */ + title?: string; + /** + * Optional description for the enum field. + */ + description?: string; + /** + * Minimum number of items to select. + */ + minItems?: number; + /** + * Maximum number of items to select. + */ + maxItems?: number; + /** + * Schema for array items with enum options and display labels. + */ + items: { + /** + * Array of enum options with values and display labels. + */ + anyOf: Array<{ + /** + * The constant enum value. + */ + const: string; + /** + * Display title for this option. + */ + title: string; + }>; + }; + /** + * Optional default value. + */ + default?: string[]; +} + +/** + * @category `elicitation/create` + */ +// Combined multiple selection enumeration +export type MultiSelectEnumSchema = UntitledMultiSelectEnumSchema | TitledMultiSelectEnumSchema; + +/** + * Use TitledSingleSelectEnumSchema instead. + * This interface will be removed in a future version. + * + * @category `elicitation/create` + */ +export interface LegacyTitledEnumSchema { + type: 'string'; + title?: string; + description?: string; + enum: string[]; + /** + * (Legacy) Display names for enum values. + * Non-standard according to JSON schema 2020-12. + */ + enumNames?: string[]; + default?: string; +} + +/** + * @category `elicitation/create` + */ +// Union type for all enum schemas +export type EnumSchema = SingleSelectEnumSchema | MultiSelectEnumSchema | LegacyTitledEnumSchema; + +/** + * The client's response to an elicitation request. + * + * @category `elicitation/create` + */ +export interface ElicitResult extends Result { + /** + * The user action in response to the elicitation. + * - "accept": User submitted the form/confirmed the action + * - "decline": User explicitly decline the action + * - "cancel": User dismissed without making an explicit choice + */ + action: 'accept' | 'decline' | 'cancel'; + + /** + * The submitted form data, only present when action is "accept" and mode was "form". + * Contains values matching the requested schema. + * Omitted for out-of-band mode responses. + */ + content?: { [key: string]: string | number | boolean | string[] }; +} + +/** + * An optional notification from the server to the client, informing it of a completion of a out-of-band elicitation request. + * + * @category `notifications/elicitation/complete` + */ +export interface ElicitationCompleteNotification extends JSONRPCNotification { + method: 'notifications/elicitation/complete'; + params: { + /** + * The ID of the elicitation that completed. + */ + elicitationId: string; + }; +} + +/* Client messages */ +/** @internal */ +export type ClientRequest = + | PingRequest + | InitializeRequest + | CompleteRequest + | SetLevelRequest + | GetPromptRequest + | ListPromptsRequest + | ListResourcesRequest + | ListResourceTemplatesRequest + | ReadResourceRequest + | SubscribeRequest + | UnsubscribeRequest + | CallToolRequest + | ListToolsRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +/** @internal */ +export type ClientNotification = + | CancelledNotification + | ProgressNotification + | InitializedNotification + | RootsListChangedNotification + | TaskStatusNotification; + +/** @internal */ +export type ClientResult = + | EmptyResult + | CreateMessageResult + | ListRootsResult + | ElicitResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; + +/* Server messages */ +/** @internal */ +export type ServerRequest = + | PingRequest + | CreateMessageRequest + | ListRootsRequest + | ElicitRequest + | GetTaskRequest + | GetTaskPayloadRequest + | ListTasksRequest + | CancelTaskRequest; + +/** @internal */ +export type ServerNotification = + | CancelledNotification + | ProgressNotification + | LoggingMessageNotification + | ResourceUpdatedNotification + | ResourceListChangedNotification + | ToolListChangedNotification + | PromptListChangedNotification + | ElicitationCompleteNotification + | TaskStatusNotification; + +/** @internal */ +export type ServerResult = + | EmptyResult + | InitializeResult + | CompleteResult + | GetPromptResult + | ListPromptsResult + | ListResourceTemplatesResult + | ListResourcesResult + | ReadResourceResult + | CallToolResult + | ListToolsResult + | GetTaskResult + | GetTaskPayloadResult + | ListTasksResult + | CancelTaskResult; diff --git a/packages/core/src/types/spec.types.ts b/packages/core/src/types/spec.types.draft.ts similarity index 75% rename from packages/core/src/types/spec.types.ts rename to packages/core/src/types/spec.types.draft.ts index a03f21f134..79ce60a5fe 100644 --- a/packages/core/src/types/spec.types.ts +++ b/packages/core/src/types/spec.types.draft.ts @@ -3,10 +3,10 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: 5c25208be86db5033f644a4e0d005e08f699ef3d + * Last updated from commit: 9d700ed62dcf86cb77475c9b81930611a9182f46 * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. - * To update this file, run: pnpm run fetch:spec-types + * To update this file, run: pnpm run fetch:spec-types draft */ /* JSON types */ /** @@ -34,7 +34,7 @@ export type JSONArray = JSONValue[]; export type JSONRPCMessage = JSONRPCRequest | JSONRPCNotification | JSONRPCResponse; /** @internal */ -export const LATEST_PROTOCOL_VERSION = 'DRAFT-2026-v1'; +export const LATEST_PROTOCOL_VERSION = '2026-07-28'; /** @internal */ export const JSONRPC_VERSION = '2.0'; @@ -48,7 +48,8 @@ export const JSONRPC_VERSION = '2.0'; * **Prefix:** * - Optional — if specified, MUST be a series of _labels_ separated by dots (`.`), followed by a slash (`/`). * - Labels MUST start with a letter and end with a letter or digit. Interior characters may be letters, digits, or hyphens (`-`). - * - Any prefix consisting of zero or more labels, followed by `modelcontextprotocol` or `mcp`, followed by any label, is **reserved** for MCP use. For example: `modelcontextprotocol.io/`, `mcp.dev/`, `api.modelcontextprotocol.org/`, and `tools.mcp.com/` are all reserved. + * - Implementations SHOULD use reverse DNS notation (e.g., `com.example/` rather than `example.com/`). + * - Any prefix where the second label is `modelcontextprotocol` or `mcp` is **reserved** for MCP use. For example: `io.modelcontextprotocol/`, `dev.mcp/`, `org.modelcontextprotocol.api/`, and `com.mcp.tools/` are all reserved. However, `com.example.mcp/` is NOT reserved, as the second label is `example`. * * **Name:** * - Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`). @@ -71,6 +72,42 @@ export interface RequestMetaObject extends MetaObject { * If specified, the caller is requesting out-of-band progress notifications for this request (as represented by {@link ProgressNotification | notifications/progress}). The value of this parameter is an opaque token that will be attached to any subsequent notifications. The receiver is not obligated to provide these notifications. */ progressToken?: ProgressToken; + /** + * The MCP Protocol Version being used for this request. Required. + * + * For the HTTP transport, this value MUST match the `MCP-Protocol-Version` + * header; otherwise the server MUST return a `400 Bad Request`. If the + * server does not support the requested version, it MUST return an + * {@link UnsupportedProtocolVersionError}. + */ + 'io.modelcontextprotocol/protocolVersion': string; + /** + * Identifies the client software making the request. Required. + * + * The {@link Implementation} schema requires `name` and `version`; other + * fields are optional. + */ + 'io.modelcontextprotocol/clientInfo': Implementation; + /** + * The client's capabilities for this specific request. Required. + * + * Capabilities are declared per-request rather than once at initialization; + * an empty object means the client supports no optional capabilities. + * Servers MUST NOT infer capabilities from prior requests. + */ + 'io.modelcontextprotocol/clientCapabilities': ClientCapabilities; + /** + * The desired log level for this request. Optional. + * + * If absent, the server MUST NOT send any {@link LoggingMessageNotification | notifications/message} + * notifications for this request. The client opts in to log messages by + * explicitly setting a level. Replaces the former `logging/setLevel` RPC. + * + * @deprecated Deprecated as of protocol version 2026-07-28 (SEP-2577). + * Remains in the specification for at least twelve months; see the + * deprecated features registry. + */ + 'io.modelcontextprotocol/logLevel'?: LoggingLevel; } /** @@ -87,30 +124,13 @@ export type ProgressToken = string | number; */ export type Cursor = string; -/** - * Common params for any task-augmented request. - * - * @internal - */ -export interface TaskAugmentedRequestParams extends RequestParams { - /** - * If specified, the caller is requesting task-augmented execution for this request. - * The request will return a {@link CreateTaskResult} immediately, and the actual result can be - * retrieved later via {@link GetTaskPayloadRequest | tasks/result}. - * - * Task augmentation is subject to capability negotiation - receivers MUST declare support - * for task augmentation of specific request types in their capabilities. - */ - task?: TaskMetadata; -} - /** * Common params for any request. * * @category Common Types */ export interface RequestParams { - _meta?: RequestMetaObject; + _meta: RequestMetaObject; } /** @internal */ @@ -138,6 +158,16 @@ export interface Notification { params?: { [key: string]: any }; } +/** + * Indicates the type of a {@link Result} object, allowing the client to + * determine how to parse the response. + * + * complete - the request completed successfully and the result contains the final content. + * input_required - the request requires additional input and the result contains an {@link InputRequiredResult} object with instructions for the client to provide additional input before retrying the original request. + * @category Common Types + */ +export type ResultType = 'complete' | 'input_required' | string; + /** * Common result fields. * @@ -145,6 +175,16 @@ export interface Notification { */ export interface Result { _meta?: MetaObject; + /** + * Indicates the type of the result, which allows the client to determine + * how to parse the result object. + * + * Servers implementing this protocol version MUST include this field. + * For backward compatibility, when a client receives a result from a + * server implementing an earlier protocol version (which does not include + * `resultType`), the client MUST treat the absent field as `"complete"`. + */ + resultType: ResultType; [key: string]: unknown; } @@ -256,15 +296,14 @@ export interface InvalidRequestError extends Error { /** * A JSON-RPC error indicating that the requested method does not exist or is not available. * - * In MCP, this error is returned when a request is made for a method that requires a capability that has not been declared. This can occur in either direction: + * In MCP, a server returns this error when a client invokes a method the server does not implement — either a genuinely unknown method, or one gated behind a server capability the server did not advertise (e.g., calling `prompts/list` when the `prompts` capability was not advertised). * - * - A server returning this error when the client requests a capability it doesn't support (e.g., requesting completions when the `completions` capability was not advertised) - * - A client returning this error when the server requests a capability it doesn't support (e.g., requesting roots when the client did not declare the `roots` capability) + * A request that requires a client capability the client did not declare is signalled instead by {@link MissingRequiredClientCapabilityError} (`-32003`). * * @see {@link https://www.jsonrpc.org/specification#error_object | JSON-RPC 2.0 Error Object} * - * @example Roots not supported - * {@includeCode ./examples/MethodNotFoundError/roots-not-supported.json} + * @example Prompts not supported + * {@includeCode ./examples/MethodNotFoundError/prompts-not-supported.json} * * @category Errors */ @@ -281,7 +320,6 @@ export interface MethodNotFoundError extends Error { * - **Prompts**: Unknown prompt name or missing required arguments * - **Pagination**: Invalid or expired cursor values * - **Logging**: Invalid log level - * - **Tasks**: Invalid or nonexistent task ID, invalid cursor, or attempting to cancel a task already in a terminal status * - **Elicitation**: Server requests an elicitation mode not declared in client capabilities * - **Sampling**: Missing tool result or tool results mixed with other content * @@ -319,24 +357,68 @@ export interface InternalError extends Error { code: typeof INTERNAL_ERROR; } -// Implementation-specific JSON-RPC error codes [-32000, -32099] -/** @internal */ -export const URL_ELICITATION_REQUIRED = -32042; +/** + * Error code returned when a server requires a client capability that was + * not declared in the request's `clientCapabilities`. + * + * @category Errors + */ +export const MISSING_REQUIRED_CLIENT_CAPABILITY = -32003; /** - * An error response that indicates that the server requires the client to provide additional information via an elicitation request. + * Error code returned when the request's protocol version is not supported + * by the server. * - * @example Authorization required - * {@includeCode ./examples/URLElicitationRequiredError/authorization-required.json} + * @category Errors + */ +export const UNSUPPORTED_PROTOCOL_VERSION = -32004; + +/** + * Returned when the request's protocol version is unknown to the server or + * unsupported (e.g., a known experimental or draft version the server has + * chosen not to implement). For HTTP, the response status code MUST be + * `400 Bad Request`. * - * @internal + * @example Unsupported protocol version + * {@includeCode ./examples/UnsupportedProtocolVersionError/unsupported-version.json} + * + * @category Errors */ -export interface URLElicitationRequiredError extends Omit { +export interface UnsupportedProtocolVersionError extends Omit { error: Error & { - code: typeof URL_ELICITATION_REQUIRED; + code: typeof UNSUPPORTED_PROTOCOL_VERSION; data: { - elicitations: ElicitRequestURLParams[]; - [key: string]: unknown; + /** + * Protocol versions the server supports. The client should choose a + * mutually supported version from this list and retry. + */ + supported: string[]; + /** + * The protocol version that was requested by the client. + */ + requested: string; + }; + }; +} + +/** + * Returned when processing a request requires a capability the client did not + * declare in `clientCapabilities`. For HTTP, the response status code MUST be + * `400 Bad Request`. + * + * @example Missing elicitation capability + * {@includeCode ./examples/MissingRequiredClientCapabilityError/missing-elicitation-capability.json} + * + * @category Errors + */ +export interface MissingRequiredClientCapabilityError extends Omit { + error: Error & { + code: typeof MISSING_REQUIRED_CLIENT_CAPABILITY; + data: { + /** + * The capabilities the server requires from the client to process this request. + */ + requiredCapabilities: ClientCapabilities; }; }; } @@ -349,6 +431,79 @@ export interface URLElicitationRequiredError extends Omit; export type JSONRPCMessage = Infer; export type RequestParams = Infer; export type NotificationParams = Infer; +/** + * The per-request `_meta` envelope carried by every request under protocol revision + * 2026-07-28 (protocol version, client info, client capabilities, optional log level). + */ +export type RequestMetaEnvelope = Infer; /* Empty result */ export type EmptyResult = Infer; @@ -224,6 +232,10 @@ export type ServerCapabilities = Infer; export type InitializeResult = Infer; export type InitializedNotification = Infer; +/* Discovery */ +export type DiscoverRequest = Infer; +export type DiscoverResult = Infer; + /* Ping */ export type PingRequest = Infer; @@ -383,6 +395,7 @@ export type NotificationTypeMap = MethodToTypeMap { + it('is an Error with the class name and the given message', () => { + const error = new NotImplementedYetError('stateless request dispatch is not implemented yet'); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(NotImplementedYetError); + expect(error.name).toBe('NotImplementedYetError'); + expect(error.message).toBe('stateless request dispatch is not implemented yet'); + }); + + it('is distinguishable from plain errors via instanceof', () => { + expect(new Error('x')).not.toBeInstanceOf(NotImplementedYetError); + }); +}); diff --git a/packages/core/test/shared/protocol.test.ts b/packages/core/test/shared/protocol.test.ts index 6e77430d61..9f0a19c559 100644 --- a/packages/core/test/shared/protocol.test.ts +++ b/packages/core/test/shared/protocol.test.ts @@ -20,7 +20,7 @@ import type { Result, ServerCapabilities } from '../../src/types/index.js'; -import { ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; +import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, ProtocolError, ProtocolErrorCode } from '../../src/types/index.js'; import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors.js'; // Test Protocol subclass for testing @@ -910,3 +910,59 @@ describe('mergeCapabilities', () => { expect(merged).toEqual({}); }); }); + +describe('ctx.mcpReq.protocolVersion population', () => { + // Protocol subclass with a settable negotiated version, mirroring how Server/Client + // assign the inherited protected field at their negotiation points. + class NegotiatedVersionProtocol extends Protocol { + set negotiated(version: string | undefined) { + this._negotiatedProtocolVersion = version; + } + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } + } + + async function captureProtocolVersion(p: NegotiatedVersionProtocol, t: MockTransport): Promise { + await p.connect(t); + const captured = new Promise(resolve => { + p.setRequestHandler('ping', async (_request, ctx) => { + resolve(ctx.mcpReq.protocolVersion); + return {}; + }); + }); + t.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); + return captured; + } + + test('falls back to the SDK default before the handshake completes', async () => { + const p = new NegotiatedVersionProtocol(); + // negotiated is undefined: this models a request (only ping is legal) arriving pre-initialize. + const version = await captureProtocolVersion(p, new MockTransport()); + expect(version).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + }); + + test('reads the negotiated version once the role has set it', async () => { + const p = new NegotiatedVersionProtocol(); + p.negotiated = '2025-06-18'; + const version = await captureProtocolVersion(p, new MockTransport()); + expect(version).toBe('2025-06-18'); + }); + + test('the base Protocol getter returns undefined so role-less subclasses get the default', async () => { + const p = createTestProtocol(); + const t = new MockTransport(); + await p.connect(t); + const captured = new Promise(resolve => { + p.setRequestHandler('ping', async (_request, ctx) => { + resolve(ctx.mcpReq.protocolVersion); + return {}; + }); + }); + t.onmessage?.({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} }); + expect(await captured).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + }); +}); diff --git a/packages/core/test/spec.types.test.ts b/packages/core/test/spec.types.2025-11-25.test.ts similarity index 75% rename from packages/core/test/spec.types.test.ts rename to packages/core/test/spec.types.2025-11-25.test.ts index d26a4cd701..bfe2870d9b 100644 --- a/packages/core/test/spec.types.test.ts +++ b/packages/core/test/spec.types.2025-11-25.test.ts @@ -1,4 +1,8 @@ /** + * Compares the SDK's types against the frozen 2025-11-25 release schema + * (spec.types.2025-11-25.ts). The draft-schema comparison lives in + * spec.types.draft.test.ts. + * * This contains: * - Static type checks to verify the Spec's types are compatible with the SDK's types * (mutually assignable — no type-level workarounds should be needed) @@ -8,7 +12,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import type * as SpecTypes from '../src/types/spec.types.js'; +import type * as SpecTypes from '../src/types/spec.types.2025-11-25.js'; import type * as SDKTypes from '../src/types/index.js'; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -19,13 +23,6 @@ type WithJSONRPC = T & { jsonrpc: '2.0' }; // Adds the `jsonrpc` and `id` properties to a type, to match the on-wire format of requests. type WithJSONRPCRequest = T & { jsonrpc: '2.0'; id: SDKTypes.RequestId }; -// The spec defines typed *ResultResponse interfaces (e.g. InitializeResultResponse) that pair a -// JSONRPCResultResponse envelope with a specific result type. The SDK doesn't export these because -// nothing in the SDK needs the combined type — Protocol._onresponse() unwraps the envelope and -// validates the inner result separately. We define this locally to verify the composition still -// type-checks against the spec without polluting the SDK's public API. -type TypedResultResponse = SDKTypes.JSONRPCResultResponse & { result: R }; - const sdkTypeChecks = { RequestParams: (sdk: SDKTypes.RequestParams, spec: SpecTypes.RequestParams) => { sdk = spec; @@ -40,6 +37,7 @@ const sdkTypeChecks = { spec = sdk; }, InitializeRequestParams: (sdk: SDKTypes.InitializeRequestParams, spec: SpecTypes.InitializeRequestParams) => { + // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object`; the SDK follows the draft schema's JSONObject sdk = spec; spec = sdk; }, @@ -90,7 +88,9 @@ const sdkTypeChecks = { spec = sdk; }, CreateMessageRequestParams: (sdk: SDKTypes.CreateMessageRequestParams, spec: SpecTypes.CreateMessageRequestParams) => { + // @ts-expect-error 2025-11-25 types `metadata` as `object`; the SDK follows the draft schema's JSONObject sdk = spec; + // @ts-expect-error the SDK's JSONValue-typed tool inputSchema properties are not assignable to 2025-11-25's `object` spec = sdk; }, CompleteRequestParams: (sdk: SDKTypes.CompleteRequestParams, spec: SpecTypes.CompleteRequestParams) => { @@ -245,7 +245,9 @@ const sdkTypeChecks = { spec = sdk; }, Tool: (sdk: SDKTypes.Tool, spec: SpecTypes.Tool) => { + // @ts-expect-error 2025-11-25 types inputSchema/outputSchema properties as `object`; the SDK follows the draft schema's JSONValue sdk = spec; + // @ts-expect-error the SDK's JSONValue-typed inputSchema/outputSchema properties are not assignable to 2025-11-25's `object` spec = sdk; }, ListToolsRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListToolsRequest) => { @@ -253,7 +255,9 @@ const sdkTypeChecks = { spec = sdk; }, ListToolsResult: (sdk: SDKTypes.ListToolsResult, spec: SpecTypes.ListToolsResult) => { + // @ts-expect-error 2025-11-25 vs draft Tool typing; see the Tool check above sdk = spec; + // @ts-expect-error 2025-11-25 vs draft Tool typing; see the Tool check above spec = sdk; }, CallToolResult: (sdk: SDKTypes.CallToolResult, spec: SpecTypes.CallToolResult) => { @@ -473,31 +477,41 @@ const sdkTypeChecks = { spec = sdk; }, CreateMessageRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CreateMessageRequest) => { + // @ts-expect-error 2025-11-25 vs draft typing of params metadata/tools; see the CreateMessageRequestParams check above sdk = spec; + // @ts-expect-error 2025-11-25 vs draft typing of params metadata/tools; see the CreateMessageRequestParams check above spec = sdk; }, InitializeRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.InitializeRequest) => { + // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object`; the SDK follows the draft schema's JSONObject sdk = spec; spec = sdk; }, InitializeResult: (sdk: SDKTypes.InitializeResult, spec: SpecTypes.InitializeResult) => { + // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object`; the SDK follows the draft schema's JSONObject sdk = spec; spec = sdk; }, ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { + // @ts-expect-error 2025-11-25 types experimental/sampling/elicitation/tasks blobs as `object`; the SDK follows the draft schema's JSONObject sdk = spec; spec = sdk; }, ServerCapabilities: (sdk: SDKTypes.ServerCapabilities, spec: SpecTypes.ServerCapabilities) => { + // @ts-expect-error 2025-11-25 types experimental/logging/completions/tasks blobs as `object`; the SDK follows the draft schema's JSONObject sdk = spec; spec = sdk; }, ClientRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ClientRequest) => { + // @ts-expect-error 2025-11-25 types capabilities.experimental values as `object` (via the InitializeRequest member); the SDK follows the draft schema's JSONObject sdk = spec; + // @ts-expect-error the SDK union carries the draft schema's DiscoverRequest (server/discover), absent from released 2025-11-25 — deliberate wire-superset surface, like the capabilities `extensions` key spec = sdk; }, ServerRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ServerRequest) => { + // @ts-expect-error 2025-11-25 vs draft typing of CreateMessageRequest params; see the CreateMessageRequestParams check above sdk = spec; + // @ts-expect-error 2025-11-25 vs draft typing of CreateMessageRequest params; see the CreateMessageRequestParams check above spec = sdk; }, LoggingMessageNotification: (sdk: WithJSONRPC, spec: SpecTypes.LoggingMessageNotification) => { @@ -552,10 +566,6 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - TaskAugmentedRequestParams: (sdk: SDKTypes.TaskAugmentedRequestParams, spec: SpecTypes.TaskAugmentedRequestParams) => { - sdk = spec; - spec = sdk; - }, ToolExecution: (sdk: SDKTypes.ToolExecution, spec: SpecTypes.ToolExecution) => { sdk = spec; spec = sdk; @@ -572,35 +582,15 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - Task: (sdk: SDKTypes.Task, spec: SpecTypes.Task) => { - sdk = spec; - spec = sdk; - }, - CreateTaskResult: (sdk: SDKTypes.CreateTaskResult, spec: SpecTypes.CreateTaskResult) => { - sdk = spec; - spec = sdk; - }, - GetTaskResult: (sdk: SDKTypes.GetTaskResult, spec: SpecTypes.GetTaskResult) => { - sdk = spec; - spec = sdk; - }, - GetTaskPayloadRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskPayloadRequest) => { - sdk = spec; - spec = sdk; - }, - ListTasksRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListTasksRequest) => { - sdk = spec; - spec = sdk; - }, - ListTasksResult: (sdk: SDKTypes.ListTasksResult, spec: SpecTypes.ListTasksResult) => { + TaskAugmentedRequestParams: (sdk: SDKTypes.TaskAugmentedRequestParams, spec: SpecTypes.TaskAugmentedRequestParams) => { sdk = spec; spec = sdk; }, - CancelTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CancelTaskRequest) => { + Task: (sdk: SDKTypes.Task, spec: SpecTypes.Task) => { sdk = spec; spec = sdk; }, - CancelTaskResult: (sdk: SDKTypes.CancelTaskResult, spec: SpecTypes.CancelTaskResult) => { + CreateTaskResult: (sdk: SDKTypes.CreateTaskResult, spec: SpecTypes.CreateTaskResult) => { sdk = spec; spec = sdk; }, @@ -608,156 +598,39 @@ const sdkTypeChecks = { sdk = spec; spec = sdk; }, - GetTaskPayloadResult: (sdk: SDKTypes.GetTaskPayloadResult, spec: SpecTypes.GetTaskPayloadResult) => { - sdk = spec; - spec = sdk; - }, - TaskStatusNotificationParams: (sdk: SDKTypes.TaskStatusNotificationParams, spec: SpecTypes.TaskStatusNotificationParams) => { - sdk = spec; - spec = sdk; - }, - TaskStatusNotification: (sdk: WithJSONRPC, spec: SpecTypes.TaskStatusNotification) => { - sdk = spec; - spec = sdk; - }, - - /* JSON primitives */ - JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { - sdk = spec; - spec = sdk; - }, - JSONObject: (sdk: SDKTypes.JSONObject, spec: SpecTypes.JSONObject) => { - sdk = spec; - spec = sdk; - }, - JSONArray: (sdk: SDKTypes.JSONArray, spec: SpecTypes.JSONArray) => { - sdk = spec; - spec = sdk; - }, - - /* Meta types */ - MetaObject: (sdk: SDKTypes.MetaObject, spec: SpecTypes.MetaObject) => { - sdk = spec; - spec = sdk; - }, - RequestMetaObject: (sdk: SDKTypes.RequestMetaObject, spec: SpecTypes.RequestMetaObject) => { - sdk = spec; - spec = sdk; - }, - - /* Error types */ - ParseError: (sdk: SDKTypes.ParseError, spec: SpecTypes.ParseError) => { - sdk = spec; - spec = sdk; - }, - InvalidRequestError: (sdk: SDKTypes.InvalidRequestError, spec: SpecTypes.InvalidRequestError) => { - sdk = spec; - spec = sdk; - }, - MethodNotFoundError: (sdk: SDKTypes.MethodNotFoundError, spec: SpecTypes.MethodNotFoundError) => { - sdk = spec; - spec = sdk; - }, - InvalidParamsError: (sdk: SDKTypes.InvalidParamsError, spec: SpecTypes.InvalidParamsError) => { - sdk = spec; - spec = sdk; - }, - InternalError: (sdk: SDKTypes.InternalError, spec: SpecTypes.InternalError) => { - sdk = spec; - spec = sdk; - }, - - /* ResultResponse types — see TypedResultResponse comment above */ - InitializeResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.InitializeResultResponse) => { - sdk = spec; - spec = sdk; - }, - PingResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.PingResultResponse) => { - sdk = spec; - spec = sdk; - }, - ListResourcesResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListResourcesResultResponse) => { - sdk = spec; - spec = sdk; - }, - ListResourceTemplatesResultResponse: ( - sdk: TypedResultResponse, - spec: SpecTypes.ListResourceTemplatesResultResponse - ) => { - sdk = spec; - spec = sdk; - }, - ReadResourceResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ReadResourceResultResponse) => { - sdk = spec; - spec = sdk; - }, - SubscribeResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.SubscribeResultResponse) => { - sdk = spec; - spec = sdk; - }, - UnsubscribeResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.UnsubscribeResultResponse) => { - sdk = spec; - spec = sdk; - }, - ListPromptsResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListPromptsResultResponse) => { - sdk = spec; - spec = sdk; - }, - GetPromptResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.GetPromptResultResponse) => { - sdk = spec; - spec = sdk; - }, - ListToolsResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListToolsResultResponse) => { - sdk = spec; - spec = sdk; - }, - CallToolResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CallToolResultResponse) => { - sdk = spec; - spec = sdk; - }, - CreateTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CreateTaskResultResponse) => { - sdk = spec; - spec = sdk; - }, - GetTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.GetTaskResultResponse) => { + GetTaskResult: (sdk: SDKTypes.GetTaskResult, spec: SpecTypes.GetTaskResult) => { sdk = spec; spec = sdk; }, - GetTaskPayloadResultResponse: ( - sdk: TypedResultResponse, - spec: SpecTypes.GetTaskPayloadResultResponse - ) => { + GetTaskPayloadRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.GetTaskPayloadRequest) => { sdk = spec; spec = sdk; }, - CancelTaskResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CancelTaskResultResponse) => { + GetTaskPayloadResult: (sdk: SDKTypes.GetTaskPayloadResult, spec: SpecTypes.GetTaskPayloadResult) => { sdk = spec; spec = sdk; }, - ListTasksResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListTasksResultResponse) => { + ListTasksRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.ListTasksRequest) => { sdk = spec; spec = sdk; }, - SetLevelResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.SetLevelResultResponse) => { + ListTasksResult: (sdk: SDKTypes.ListTasksResult, spec: SpecTypes.ListTasksResult) => { sdk = spec; spec = sdk; }, - CreateMessageResultResponse: ( - sdk: TypedResultResponse, - spec: SpecTypes.CreateMessageResultResponse - ) => { + CancelTaskRequest: (sdk: WithJSONRPCRequest, spec: SpecTypes.CancelTaskRequest) => { sdk = spec; spec = sdk; }, - CompleteResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.CompleteResultResponse) => { + CancelTaskResult: (sdk: SDKTypes.CancelTaskResult, spec: SpecTypes.CancelTaskResult) => { sdk = spec; spec = sdk; }, - ListRootsResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ListRootsResultResponse) => { + TaskStatusNotificationParams: (sdk: SDKTypes.TaskStatusNotificationParams, spec: SpecTypes.TaskStatusNotificationParams) => { sdk = spec; spec = sdk; }, - ElicitResultResponse: (sdk: TypedResultResponse, spec: SpecTypes.ElicitResultResponse) => { + TaskStatusNotification: (sdk: WithJSONRPC, spec: SpecTypes.TaskStatusNotification) => { sdk = spec; spec = sdk; } @@ -790,8 +663,16 @@ type AssertExactKeys< /** Constraint: T must resolve to `true`. */ type Assert = T; +/** + * Same as {@link AssertExactKeys}, but tolerates the SDK's `resultType` key on + * result shapes: the SDK follows the draft schema's optional `resultType` + * passthrough (absent means "complete"), which is not in released 2025-11-25. + * Every other key still has to match exactly. + */ +type AssertExactKeysWithResultType = AssertExactKeys; + /* - * Excluded from key-level assertions (23 entries): + * Excluded from key-level assertions (21 entries): * * Union types — KnownKeys cannot meaningfully enumerate their members (15): * ClientRequest, ServerRequest, ClientNotification, ServerNotification, @@ -799,12 +680,11 @@ type Assert = T; * SamplingMessageContentBlock, ElicitRequestParams, PrimitiveSchemaDefinition, * SingleSelectEnumSchema, MultiSelectEnumSchema, EnumSchema * - * Primitive type aliases — no object keys to compare (8): - * JSONValue, JSONArray, Role, LoggingLevel, ProgressToken, RequestId, - * Cursor, TaskStatus + * Primitive type aliases — no object keys to compare (6): + * Role, LoggingLevel, ProgressToken, RequestId, Cursor, TaskStatus */ -// -- Simple types (96) -- +// -- Simple types (88) -- type _K_RequestParams = Assert>; type _K_NotificationParams = Assert>; @@ -831,27 +711,29 @@ type _K_ElicitRequestURLParams = Assert>; type _K_BaseMetadata = Assert>; type _K_Implementation = Assert>; -type _K_PaginatedResult = Assert>; -type _K_ListRootsResult = Assert>; +type _K_PaginatedResult = Assert>; +type _K_ListRootsResult = Assert>; type _K_Root = Assert>; -type _K_ElicitResult = Assert>; -type _K_CompleteResult = Assert>; +type _K_ElicitResult = Assert>; +type _K_CompleteResult = Assert>; type _K_Request = Assert>; -type _K_Result = Assert>; +type _K_Result = Assert>; type _K_JSONRPCRequest = Assert>; type _K_JSONRPCNotification = Assert>; -type _K_EmptyResult = Assert>; +type _K_EmptyResult = Assert>; type _K_Notification = Assert>; type _K_ResourceTemplateReference = Assert>; // @ts-expect-error Genuine mismatch: SDK PromptReference is missing 'title' from spec type _K_PromptReference = Assert>; type _K_ToolAnnotations = Assert>; type _K_Tool = Assert>; -type _K_ListToolsResult = Assert>; -type _K_CallToolResult = Assert>; -type _K_ListResourcesResult = Assert>; -type _K_ListResourceTemplatesResult = Assert>; -type _K_ReadResourceResult = Assert>; +type _K_ListToolsResult = Assert>; +type _K_CallToolResult = Assert>; +type _K_ListResourcesResult = Assert>; +type _K_ListResourceTemplatesResult = Assert< + AssertExactKeysWithResultType +>; +type _K_ReadResourceResult = Assert>; type _K_ResourceContents = Assert>; type _K_TextResourceContents = Assert>; type _K_BlobResourceContents = Assert>; @@ -859,8 +741,8 @@ type _K_Resource = Assert // @ts-expect-error Genuine mismatch: SDK PromptArgument is missing 'title' from spec type _K_PromptArgument = Assert>; type _K_Prompt = Assert>; -type _K_ListPromptsResult = Assert>; -type _K_GetPromptResult = Assert>; +type _K_ListPromptsResult = Assert>; +type _K_GetPromptResult = Assert>; type _K_TextContent = Assert>; type _K_ImageContent = Assert>; type _K_AudioContent = Assert>; @@ -883,8 +765,10 @@ type _K_TitledMultiSelectEnumSchema = Assert>; type _K_JSONRPCErrorResponse = Assert>; type _K_JSONRPCResultResponse = Assert>; -type _K_InitializeResult = Assert>; +type _K_InitializeResult = Assert>; +// @ts-expect-error SDK follows the draft schema's `extensions` capability key; not in released 2025-11-25 type _K_ClientCapabilities = Assert>; +// @ts-expect-error SDK follows the draft schema's `extensions` capability key; not in released 2025-11-25 type _K_ServerCapabilities = Assert>; type _K_SamplingMessage = Assert>; type _K_Icon = Assert>; @@ -895,28 +779,19 @@ type _K_ToolChoice = Assert>; type _K_ToolResultContent = Assert>; type _K_Annotations = Assert>; -type _K_TaskAugmentedRequestParams = Assert>; type _K_ToolExecution = Assert>; type _K_TaskMetadata = Assert>; type _K_RelatedTaskMetadata = Assert>; +type _K_TaskAugmentedRequestParams = Assert>; type _K_Task = Assert>; -type _K_CreateTaskResult = Assert>; -type _K_GetTaskResult = Assert>; -type _K_ListTasksResult = Assert>; -type _K_CancelTaskResult = Assert>; -type _K_GetTaskPayloadResult = Assert>; +type _K_CreateTaskResult = Assert>; +type _K_GetTaskResult = Assert>; +type _K_GetTaskPayloadResult = Assert>; +type _K_ListTasksResult = Assert>; +type _K_CancelTaskResult = Assert>; type _K_TaskStatusNotificationParams = Assert< AssertExactKeys >; -type _K_JSONObject = Assert>; -type _K_MetaObject = Assert>; -// @ts-expect-error Genuine mismatch: SDK RequestMetaObject has extra 'io.modelcontextprotocol/related-task' not in spec -type _K_RequestMetaObject = Assert>; -type _K_ParseError = Assert>; -type _K_InvalidRequestError = Assert>; -type _K_MethodNotFoundError = Assert>; -type _K_InvalidParamsError = Assert>; -type _K_InternalError = Assert>; // -- WithJSONRPC-wrapped notification types (11) -- // SDK notification types do not include `jsonrpc` — the spec types do. We wrap @@ -971,62 +846,17 @@ type _K_ListPromptsRequest = Assert, SpecTypes.GetPromptRequest>>; type _K_CreateMessageRequest = Assert, SpecTypes.CreateMessageRequest>>; type _K_InitializeRequest = Assert, SpecTypes.InitializeRequest>>; +type _K_GetTaskRequest = Assert, SpecTypes.GetTaskRequest>>; type _K_GetTaskPayloadRequest = Assert< AssertExactKeys, SpecTypes.GetTaskPayloadRequest> >; type _K_ListTasksRequest = Assert, SpecTypes.ListTasksRequest>>; type _K_CancelTaskRequest = Assert, SpecTypes.CancelTaskRequest>>; -type _K_GetTaskRequest = Assert, SpecTypes.GetTaskRequest>>; - -// -- TypedResultResponse-wrapped types (21) -- -// The spec defines typed *ResultResponse interfaces that pair JSONRPCResultResponse -// with a specific result. We compare TypedResultResponse against the -// spec's combined type. - -type _K_InitializeResultResponse = Assert< - AssertExactKeys, SpecTypes.InitializeResultResponse> ->; -type _K_PingResultResponse = Assert, SpecTypes.PingResultResponse>>; -type _K_ListResourcesResultResponse = Assert< - AssertExactKeys, SpecTypes.ListResourcesResultResponse> ->; -type _K_ListResourceTemplatesResultResponse = Assert< - AssertExactKeys, SpecTypes.ListResourceTemplatesResultResponse> ->; -type _K_ReadResourceResultResponse = Assert< - AssertExactKeys, SpecTypes.ReadResourceResultResponse> ->; -type _K_SubscribeResultResponse = Assert, SpecTypes.SubscribeResultResponse>>; -type _K_UnsubscribeResultResponse = Assert, SpecTypes.UnsubscribeResultResponse>>; -type _K_ListPromptsResultResponse = Assert< - AssertExactKeys, SpecTypes.ListPromptsResultResponse> ->; -type _K_GetPromptResultResponse = Assert, SpecTypes.GetPromptResultResponse>>; -type _K_ListToolsResultResponse = Assert, SpecTypes.ListToolsResultResponse>>; -type _K_CallToolResultResponse = Assert, SpecTypes.CallToolResultResponse>>; -type _K_CreateTaskResultResponse = Assert< - AssertExactKeys, SpecTypes.CreateTaskResultResponse> ->; -type _K_GetTaskResultResponse = Assert, SpecTypes.GetTaskResultResponse>>; -type _K_GetTaskPayloadResultResponse = Assert< - AssertExactKeys, SpecTypes.GetTaskPayloadResultResponse> ->; -type _K_CancelTaskResultResponse = Assert< - AssertExactKeys, SpecTypes.CancelTaskResultResponse> ->; -type _K_ListTasksResultResponse = Assert, SpecTypes.ListTasksResultResponse>>; -type _K_SetLevelResultResponse = Assert, SpecTypes.SetLevelResultResponse>>; -type _K_CreateMessageResultResponse = Assert< - AssertExactKeys, SpecTypes.CreateMessageResultResponse> ->; -type _K_CompleteResultResponse = Assert, SpecTypes.CompleteResultResponse>>; -type _K_ListRootsResultResponse = Assert, SpecTypes.ListRootsResultResponse>>; -type _K_ElicitResultResponse = Assert, SpecTypes.ElicitResultResponse>>; // -- Name mismatches (2) -- // SDK exports these under different names than the spec. -type _K_CreateMessageResult = Assert>; +type _K_CreateMessageResult = Assert>; type _K_ResourceTemplate = Assert>; // Types excluded from the key-parity completeness guard: union types and primitive aliases @@ -1048,9 +878,7 @@ const KEY_PARITY_EXCLUDED = [ 'SingleSelectEnumSchema', 'MultiSelectEnumSchema', 'EnumSchema', - // Primitive aliases (8) - 'JSONValue', - 'JSONArray', + // Primitive aliases (6) 'Role', 'LoggingLevel', 'ProgressToken', @@ -1059,8 +887,8 @@ const KEY_PARITY_EXCLUDED = [ 'TaskStatus' ]; -// This file is .gitignore'd, and fetched by `npm run fetch:spec-types` (called by `npm run test`) -const SPEC_TYPES_FILE = path.resolve(__dirname, '../src/types/spec.types.ts'); +// Generated from the frozen 2025-11-25 release schema by `pnpm run fetch:spec-types 2025-11-25`. +const SPEC_TYPES_FILE = path.resolve(__dirname, '../src/types/spec.types.2025-11-25.ts'); const SDK_TYPES_FILE = path.resolve(__dirname, '../src/types/types.ts'); const MISSING_SDK_TYPES = [ @@ -1078,7 +906,7 @@ function extractKeyParityTypes(source: string): string[] { return [...source.matchAll(/^type _K_(\w+)\s*=/gm)].map(m => m[1]!); } -describe('Spec Types', () => { +describe('Spec Types (2025-11-25)', () => { const specTypes = extractExportedTypes(fs.readFileSync(SPEC_TYPES_FILE, 'utf8')); const sdkTypes = extractExportedTypes(fs.readFileSync(SDK_TYPES_FILE, 'utf8')); const typesToCheck = specTypes.filter(type => !MISSING_SDK_TYPES.includes(type)); @@ -1086,7 +914,7 @@ describe('Spec Types', () => { it('should define some expected types', () => { expect(specTypes).toContain('JSONRPCNotification'); expect(specTypes).toContain('ElicitResult'); - expect(specTypes).toHaveLength(176); + expect(specTypes).toHaveLength(145); }); it('should have up to date list of missing sdk types', () => { diff --git a/packages/core/test/spec.types.draft.test.ts b/packages/core/test/spec.types.draft.test.ts new file mode 100644 index 0000000000..bcf7022aa6 --- /dev/null +++ b/packages/core/test/spec.types.draft.test.ts @@ -0,0 +1,547 @@ +/** + * Compares the SDK's types against the in-progress draft schema (spec.types.draft.ts). + * The frozen-release comparison lives in spec.types.2025-11-25.test.ts. + * + * The SDK does not implement the draft surface yet: every draft type whose shape the SDK + * does not (yet) match is listed in DRAFT_MISSING_SDK_TYPES below. Removing a name from + * that list forces a real mutual-assignability check to be added to sdkTypeChecks (the + * completeness tests below fail otherwise) — implementation work burns the list down. + * + * Unlike MISSING_SDK_TYPES in the 2025-11-25 comparison, names in this list may well + * exist in the SDK (e.g. RequestParams) — they are listed because the draft changed + * their shape, not necessarily because the SDK lacks them. + */ +import fs from 'node:fs'; +import path from 'node:path'; + +import { + LATEST_PROTOCOL_VERSION, + MISSING_REQUIRED_CLIENT_CAPABILITY, + UNSUPPORTED_PROTOCOL_VERSION +} from '../src/types/spec.types.draft.js'; +import type * as SpecTypes from '../src/types/spec.types.draft.js'; +import type * as SDKTypes from '../src/types/index.js'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + LOG_LEVEL_META_KEY, + PROTOCOL_VERSION_META_KEY, + ProtocolErrorCode +} from '../src/types/index.js'; + +/* eslint-disable @typescript-eslint/no-unused-vars */ + +// Adds the `jsonrpc` property to a type, to match the on-wire format of notifications. +type WithJSONRPC = T & { jsonrpc: '2.0' }; + +// Adds the `jsonrpc` and `id` properties to a type, to match the on-wire format of requests. +type WithJSONRPCRequest = T & { jsonrpc: '2.0'; id: SDKTypes.RequestId }; + +const sdkTypeChecks = { + JSONValue: (sdk: SDKTypes.JSONValue, spec: SpecTypes.JSONValue) => { + sdk = spec; + spec = sdk; + }, + JSONObject: (sdk: SDKTypes.JSONObject, spec: SpecTypes.JSONObject) => { + sdk = spec; + spec = sdk; + }, + JSONArray: (sdk: SDKTypes.JSONArray, spec: SpecTypes.JSONArray) => { + sdk = spec; + spec = sdk; + }, + MetaObject: (sdk: SDKTypes.MetaObject, spec: SpecTypes.MetaObject) => { + sdk = spec; + spec = sdk; + }, + // The SDK models the draft's required per-request `_meta` envelope as + // RequestMetaEnvelope (the base request schemas stay lenient; envelope + // requiredness is enforced at dispatch). This check also pins the + // *_META_KEY constants: a drifted key name breaks mutual assignability. + RequestMetaObject: (sdk: SDKTypes.RequestMetaEnvelope, spec: SpecTypes.RequestMetaObject) => { + sdk = spec; + spec = sdk; + }, + ProgressToken: (sdk: SDKTypes.ProgressToken, spec: SpecTypes.ProgressToken) => { + sdk = spec; + spec = sdk; + }, + Cursor: (sdk: SDKTypes.Cursor, spec: SpecTypes.Cursor) => { + sdk = spec; + spec = sdk; + }, + Request: (sdk: SDKTypes.Request, spec: SpecTypes.Request) => { + sdk = spec; + spec = sdk; + }, + NotificationParams: (sdk: SDKTypes.NotificationParams, spec: SpecTypes.NotificationParams) => { + sdk = spec; + spec = sdk; + }, + RequestId: (sdk: SDKTypes.RequestId, spec: SpecTypes.RequestId) => { + sdk = spec; + spec = sdk; + }, + JSONRPCRequest: (sdk: SDKTypes.JSONRPCRequest, spec: SpecTypes.JSONRPCRequest) => { + sdk = spec; + spec = sdk; + }, + JSONRPCNotification: (sdk: WithJSONRPC, spec: SpecTypes.JSONRPCNotification) => { + sdk = spec; + spec = sdk; + }, + JSONRPCErrorResponse: (sdk: SDKTypes.JSONRPCErrorResponse, spec: SpecTypes.JSONRPCErrorResponse) => { + sdk = spec; + spec = sdk; + }, + ParseError: (sdk: SDKTypes.ParseError, spec: SpecTypes.ParseError) => { + sdk = spec; + spec = sdk; + }, + InvalidRequestError: (sdk: SDKTypes.InvalidRequestError, spec: SpecTypes.InvalidRequestError) => { + sdk = spec; + spec = sdk; + }, + MethodNotFoundError: (sdk: SDKTypes.MethodNotFoundError, spec: SpecTypes.MethodNotFoundError) => { + sdk = spec; + spec = sdk; + }, + InvalidParamsError: (sdk: SDKTypes.InvalidParamsError, spec: SpecTypes.InvalidParamsError) => { + sdk = spec; + spec = sdk; + }, + InternalError: (sdk: SDKTypes.InternalError, spec: SpecTypes.InternalError) => { + sdk = spec; + spec = sdk; + }, + CancelledNotificationParams: (sdk: SDKTypes.CancelledNotificationParams, spec: SpecTypes.CancelledNotificationParams) => { + sdk = spec; + spec = sdk; + }, + CancelledNotification: (sdk: WithJSONRPC, spec: SpecTypes.CancelledNotification) => { + sdk = spec; + spec = sdk; + }, + ClientCapabilities: (sdk: SDKTypes.ClientCapabilities, spec: SpecTypes.ClientCapabilities) => { + sdk = spec; + spec = sdk; + }, + ServerCapabilities: (sdk: SDKTypes.ServerCapabilities, spec: SpecTypes.ServerCapabilities) => { + sdk = spec; + spec = sdk; + }, + Icon: (sdk: SDKTypes.Icon, spec: SpecTypes.Icon) => { + sdk = spec; + spec = sdk; + }, + Icons: (sdk: SDKTypes.Icons, spec: SpecTypes.Icons) => { + sdk = spec; + spec = sdk; + }, + BaseMetadata: (sdk: SDKTypes.BaseMetadata, spec: SpecTypes.BaseMetadata) => { + sdk = spec; + spec = sdk; + }, + Implementation: (sdk: SDKTypes.Implementation, spec: SpecTypes.Implementation) => { + sdk = spec; + spec = sdk; + }, + ProgressNotificationParams: (sdk: SDKTypes.ProgressNotificationParams, spec: SpecTypes.ProgressNotificationParams) => { + sdk = spec; + spec = sdk; + }, + ProgressNotification: (sdk: WithJSONRPC, spec: SpecTypes.ProgressNotification) => { + sdk = spec; + spec = sdk; + }, + ResourceListChangedNotification: ( + sdk: WithJSONRPC, + spec: SpecTypes.ResourceListChangedNotification + ) => { + sdk = spec; + spec = sdk; + }, + ResourceUpdatedNotificationParams: ( + sdk: SDKTypes.ResourceUpdatedNotificationParams, + spec: SpecTypes.ResourceUpdatedNotificationParams + ) => { + sdk = spec; + spec = sdk; + }, + ResourceUpdatedNotification: (sdk: WithJSONRPC, spec: SpecTypes.ResourceUpdatedNotification) => { + sdk = spec; + spec = sdk; + }, + Resource: (sdk: SDKTypes.Resource, spec: SpecTypes.Resource) => { + sdk = spec; + spec = sdk; + }, + ResourceTemplate: (sdk: SDKTypes.ResourceTemplateType, spec: SpecTypes.ResourceTemplate) => { + sdk = spec; + spec = sdk; + }, + ResourceContents: (sdk: SDKTypes.ResourceContents, spec: SpecTypes.ResourceContents) => { + sdk = spec; + spec = sdk; + }, + TextResourceContents: (sdk: SDKTypes.TextResourceContents, spec: SpecTypes.TextResourceContents) => { + sdk = spec; + spec = sdk; + }, + BlobResourceContents: (sdk: SDKTypes.BlobResourceContents, spec: SpecTypes.BlobResourceContents) => { + sdk = spec; + spec = sdk; + }, + Prompt: (sdk: SDKTypes.Prompt, spec: SpecTypes.Prompt) => { + sdk = spec; + spec = sdk; + }, + PromptArgument: (sdk: SDKTypes.PromptArgument, spec: SpecTypes.PromptArgument) => { + sdk = spec; + spec = sdk; + }, + Role: (sdk: SDKTypes.Role, spec: SpecTypes.Role) => { + sdk = spec; + spec = sdk; + }, + PromptMessage: (sdk: SDKTypes.PromptMessage, spec: SpecTypes.PromptMessage) => { + sdk = spec; + spec = sdk; + }, + ResourceLink: (sdk: SDKTypes.ResourceLink, spec: SpecTypes.ResourceLink) => { + sdk = spec; + spec = sdk; + }, + EmbeddedResource: (sdk: SDKTypes.EmbeddedResource, spec: SpecTypes.EmbeddedResource) => { + sdk = spec; + spec = sdk; + }, + PromptListChangedNotification: ( + sdk: WithJSONRPC, + spec: SpecTypes.PromptListChangedNotification + ) => { + sdk = spec; + spec = sdk; + }, + ToolListChangedNotification: (sdk: WithJSONRPC, spec: SpecTypes.ToolListChangedNotification) => { + sdk = spec; + spec = sdk; + }, + ToolAnnotations: (sdk: SDKTypes.ToolAnnotations, spec: SpecTypes.ToolAnnotations) => { + sdk = spec; + spec = sdk; + }, + LoggingMessageNotificationParams: ( + sdk: SDKTypes.LoggingMessageNotificationParams, + spec: SpecTypes.LoggingMessageNotificationParams + ) => { + sdk = spec; + spec = sdk; + }, + LoggingMessageNotification: (sdk: WithJSONRPC, spec: SpecTypes.LoggingMessageNotification) => { + sdk = spec; + spec = sdk; + }, + LoggingLevel: (sdk: SDKTypes.LoggingLevel, spec: SpecTypes.LoggingLevel) => { + sdk = spec; + spec = sdk; + }, + ToolChoice: (sdk: SDKTypes.ToolChoice, spec: SpecTypes.ToolChoice) => { + sdk = spec; + spec = sdk; + }, + Annotations: (sdk: SDKTypes.Annotations, spec: SpecTypes.Annotations) => { + sdk = spec; + spec = sdk; + }, + ContentBlock: (sdk: SDKTypes.ContentBlock, spec: SpecTypes.ContentBlock) => { + sdk = spec; + spec = sdk; + }, + TextContent: (sdk: SDKTypes.TextContent, spec: SpecTypes.TextContent) => { + sdk = spec; + spec = sdk; + }, + ImageContent: (sdk: SDKTypes.ImageContent, spec: SpecTypes.ImageContent) => { + sdk = spec; + spec = sdk; + }, + AudioContent: (sdk: SDKTypes.AudioContent, spec: SpecTypes.AudioContent) => { + sdk = spec; + spec = sdk; + }, + ToolUseContent: (sdk: SDKTypes.ToolUseContent, spec: SpecTypes.ToolUseContent) => { + sdk = spec; + spec = sdk; + }, + ModelPreferences: (sdk: SDKTypes.ModelPreferences, spec: SpecTypes.ModelPreferences) => { + sdk = spec; + spec = sdk; + }, + ModelHint: (sdk: SDKTypes.ModelHint, spec: SpecTypes.ModelHint) => { + sdk = spec; + spec = sdk; + }, + ResourceTemplateReference: (sdk: SDKTypes.ResourceTemplateReference, spec: SpecTypes.ResourceTemplateReference) => { + sdk = spec; + spec = sdk; + }, + PromptReference: (sdk: SDKTypes.PromptReference, spec: SpecTypes.PromptReference) => { + sdk = spec; + spec = sdk; + }, + Root: (sdk: SDKTypes.Root, spec: SpecTypes.Root) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestFormParams: (sdk: SDKTypes.ElicitRequestFormParams, spec: SpecTypes.ElicitRequestFormParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestURLParams: (sdk: SDKTypes.ElicitRequestURLParams, spec: SpecTypes.ElicitRequestURLParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequestParams: (sdk: SDKTypes.ElicitRequestParams, spec: SpecTypes.ElicitRequestParams) => { + sdk = spec; + spec = sdk; + }, + ElicitRequest: (sdk: SDKTypes.ElicitRequest, spec: SpecTypes.ElicitRequest) => { + sdk = spec; + spec = sdk; + }, + PrimitiveSchemaDefinition: (sdk: SDKTypes.PrimitiveSchemaDefinition, spec: SpecTypes.PrimitiveSchemaDefinition) => { + sdk = spec; + spec = sdk; + }, + StringSchema: (sdk: SDKTypes.StringSchema, spec: SpecTypes.StringSchema) => { + sdk = spec; + spec = sdk; + }, + NumberSchema: (sdk: SDKTypes.NumberSchema, spec: SpecTypes.NumberSchema) => { + sdk = spec; + spec = sdk; + }, + BooleanSchema: (sdk: SDKTypes.BooleanSchema, spec: SpecTypes.BooleanSchema) => { + sdk = spec; + spec = sdk; + }, + UntitledSingleSelectEnumSchema: (sdk: SDKTypes.UntitledSingleSelectEnumSchema, spec: SpecTypes.UntitledSingleSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + TitledSingleSelectEnumSchema: (sdk: SDKTypes.TitledSingleSelectEnumSchema, spec: SpecTypes.TitledSingleSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + SingleSelectEnumSchema: (sdk: SDKTypes.SingleSelectEnumSchema, spec: SpecTypes.SingleSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + UntitledMultiSelectEnumSchema: (sdk: SDKTypes.UntitledMultiSelectEnumSchema, spec: SpecTypes.UntitledMultiSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + TitledMultiSelectEnumSchema: (sdk: SDKTypes.TitledMultiSelectEnumSchema, spec: SpecTypes.TitledMultiSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + MultiSelectEnumSchema: (sdk: SDKTypes.MultiSelectEnumSchema, spec: SpecTypes.MultiSelectEnumSchema) => { + sdk = spec; + spec = sdk; + }, + LegacyTitledEnumSchema: (sdk: SDKTypes.LegacyTitledEnumSchema, spec: SpecTypes.LegacyTitledEnumSchema) => { + sdk = spec; + spec = sdk; + }, + EnumSchema: (sdk: SDKTypes.EnumSchema, spec: SpecTypes.EnumSchema) => { + sdk = spec; + spec = sdk; + }, + ElicitationCompleteNotification: ( + sdk: WithJSONRPC, + spec: SpecTypes.ElicitationCompleteNotification + ) => { + sdk = spec; + spec = sdk; + }, + ClientNotification: (sdk: WithJSONRPC, spec: SpecTypes.ClientNotification) => { + sdk = spec; + spec = sdk; + } +}; + +// Generated from the draft schema by `pnpm run fetch:spec-types draft `. +const SPEC_TYPES_FILE = path.resolve(__dirname, '../src/types/spec.types.draft.ts'); + +/** + * Draft spec types the SDK does not match yet. Spec-implementation work for the + * 2026-07-28 release removes entries from this list as the SDK adopts each shape. + */ +const DRAFT_MISSING_SDK_TYPES = [ + // Inlined in the SDK (same as the 2025-11-25 comparison): + 'Error', // The inner error object of a JSONRPCError + + // SEP-2575 per-request envelope: draft requests REQUIRE a `_meta` envelope + // (`io.modelcontextprotocol/protocolVersion`, clientInfo, clientCapabilities). The + // envelope itself is modeled by RequestMetaEnvelope (see sdkTypeChecks above); the + // request shapes below stay here because the SDK wire schemas deliberately keep + // `_meta` lenient — the same schemas parse pre-2026 requests (no envelope) and 2026 + // requests, with envelope requiredness enforced per request at dispatch. They burn + // only if the SDK ever models era-specific request types. + 'RequestParams', + 'PaginatedRequestParams', + 'ResourceRequestParams', + 'CallToolRequestParams', + 'CompleteRequestParams', + 'GetPromptRequestParams', + 'ReadResourceRequestParams', + 'CreateMessageRequestParams', + 'PaginatedRequest', + 'CallToolRequest', + 'CompleteRequest', + 'GetPromptRequest', + 'ListPromptsRequest', + 'ListResourceTemplatesRequest', + 'ListResourcesRequest', + 'ListRootsRequest', + 'ListToolsRequest', + 'ReadResourceRequest', + 'CreateMessageRequest', + 'ClientRequest', + + // SEP-2322 (MRTR) → PR for MRTR: draft results carry a required `resultType` + // discriminator. The SDK base result schema carries `resultType` as an optional + // passthrough only (absent means "complete"); per-result modeling lands with MRTR. + 'Result', + 'EmptyResult', + 'PaginatedResult', + 'CallToolResult', + 'CompleteResult', + 'ElicitResult', + 'GetPromptResult', + 'ListPromptsResult', + 'ListResourceTemplatesResult', + 'ListResourcesResult', + 'ListRootsResult', + 'ListToolsResult', + 'ReadResourceResult', + 'CreateMessageResult', + 'ClientResult', + 'ServerResult', + 'ResultType', + + // SEP-2549 cacheable results: `ttlMs`/`cacheScope` caching hints on the list/read + // result shapes → PR for SEP-2549: + 'CacheableResult', + + // Response envelopes embedding the changed Result shape → PR for MRTR: + 'JSONRPCResultResponse', + 'JSONRPCResponse', + 'JSONRPCMessage', + 'CallToolResultResponse', + 'CompleteResultResponse', + 'GetPromptResultResponse', + 'ListPromptsResultResponse', + 'ListResourceTemplatesResultResponse', + 'ListResourcesResultResponse', + 'ListToolsResultResponse', + 'ReadResourceResultResponse', + + // SEP-2575 sessionless discovery: the SDK ships the wire shapes + // (DiscoverRequestSchema / DiscoverResultSchema), but the draft shapes embed the + // required `_meta` envelope (request) and required `resultType` (result → MRTR PR), + // so they do not match yet; DiscoverResultResponse is a response wrapper (→ MRTR PR): + 'DiscoverRequest', + 'DiscoverResult', + 'DiscoverResultResponse', + + // SEP-2567 input requests/responses (new surface) → PR for MRTR: + 'InputRequest', + 'InputRequests', + 'InputRequiredResult', + 'InputResponse', + 'InputResponseRequestParams', + 'InputResponses', + + // Draft subscriptions surface (new) → PR for subscriptions/listen: + 'SubscriptionFilter', + 'SubscriptionsAcknowledgedNotification', + 'SubscriptionsAcknowledgedNotificationParams', + 'SubscriptionsListenRequest', + 'SubscriptionsListenRequestParams', + + // New typed protocol errors: the SDK ships -32003/-32004 as ProtocolErrorCode + // entries plus the UnsupportedProtocolVersionError class (errors.ts); the spec's + // per-code error *response envelope* interfaces are not modeled as wire types: + 'MissingRequiredClientCapabilityError', + 'UnsupportedProtocolVersionError', + + // Other shapes changed by the draft schema: sampling content changes (SamplingMessage, + // SamplingMessageContentBlock, ToolResultContent) → backchannel PR; open tool + // input/output schema typing (Tool); loosened Notification.params (Notification); + // server notification union, which gains the subscriptions ack (ServerNotification → + // PR for subscriptions/listen): + 'SamplingMessage', + 'SamplingMessageContentBlock', + 'ToolResultContent', + 'Tool', + 'Notification', + 'ServerNotification' +]; + +function extractExportedTypes(source: string): string[] { + const matches = [...source.matchAll(/export\s+(?:interface|class|type)\s+(\w+)\b/g)]; + return matches.map(m => m[1]!); +} + +describe('Spec Types (draft)', () => { + const specTypes = extractExportedTypes(fs.readFileSync(SPEC_TYPES_FILE, 'utf8')); + const typesToCheck = specTypes.filter(type => !DRAFT_MISSING_SDK_TYPES.includes(type)); + + it('pins the draft protocol version and the new error codes', () => { + expect(LATEST_PROTOCOL_VERSION).toBe('2026-07-28'); + expect(MISSING_REQUIRED_CLIENT_CAPABILITY).toBe(-32003); + expect(UNSUPPORTED_PROTOCOL_VERSION).toBe(-32004); + expect(ProtocolErrorCode.MissingRequiredClientCapability).toBe(MISSING_REQUIRED_CLIENT_CAPABILITY); + expect(ProtocolErrorCode.UnsupportedProtocolVersion).toBe(UNSUPPORTED_PROTOCOL_VERSION); + }); + + it('pins the per-request _meta envelope keys to the draft schema', () => { + expect(PROTOCOL_VERSION_META_KEY).toBe('io.modelcontextprotocol/protocolVersion'); + expect(CLIENT_INFO_META_KEY).toBe('io.modelcontextprotocol/clientInfo'); + expect(CLIENT_CAPABILITIES_META_KEY).toBe('io.modelcontextprotocol/clientCapabilities'); + expect(LOG_LEVEL_META_KEY).toBe('io.modelcontextprotocol/logLevel'); + }); + + it('should define some expected types', () => { + expect(specTypes).toContain('DiscoverRequest'); + expect(specTypes).toContain('InputRequiredResult'); + expect(specTypes).toContain('SubscriptionsListenRequest'); + expect(specTypes).toHaveLength(150); + }); + + it('should only allowlist types that exist in the draft schema', () => { + for (const typeName of DRAFT_MISSING_SDK_TYPES) { + expect(specTypes).toContain(typeName); + } + }); + + it('should have comprehensive compatibility tests', () => { + const missingTests = []; + + for (const typeName of typesToCheck) { + if (!sdkTypeChecks[typeName as keyof typeof sdkTypeChecks]) { + missingTests.push(typeName); + } + } + + expect(missingTests).toHaveLength(0); + }); + + describe('Missing SDK Types', () => { + it.each(DRAFT_MISSING_SDK_TYPES)('%s should not be present in DRAFT_MISSING_SDK_TYPES if it has a compatibility test', type => { + expect(sdkTypeChecks[type as keyof typeof sdkTypeChecks]).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/types.test.ts b/packages/core/test/types.test.ts index 280f2ede9f..a92615bceb 100644 --- a/packages/core/test/types.test.ts +++ b/packages/core/test/types.test.ts @@ -1,6 +1,8 @@ import { CallToolRequestSchema, CallToolResultSchema, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, ClientCapabilitiesSchema, ClientRequestSchema, CompleteRequestSchema, @@ -8,10 +10,17 @@ import { CreateMessageRequestSchema, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, + DiscoverRequestSchema, + DiscoverResultSchema, ElicitRequestFormParamsSchema, + EmptyResultSchema, LATEST_PROTOCOL_VERSION, + LOG_LEVEL_META_KEY, PromptMessageSchema, + PROTOCOL_VERSION_META_KEY, + RequestMetaEnvelopeSchema, ResourceLinkSchema, + ResultSchema, SamplingMessageSchema, SUPPORTED_PROTOCOL_VERSIONS, ToolChoiceSchema, @@ -1049,3 +1058,117 @@ describe('2025-11-25 task wire interop (task feature removed; wire types remain) } }); }); + +describe('2026-07-28 wire shapes', () => { + describe('RequestMetaEnvelope', () => { + const envelope = { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'test-client', version: '1.0.0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + + test('accepts a complete envelope', () => { + const result = RequestMetaEnvelopeSchema.safeParse(envelope); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data[PROTOCOL_VERSION_META_KEY]).toBe('2026-07-28'); + expect(result.data[CLIENT_INFO_META_KEY]).toEqual({ name: 'test-client', version: '1.0.0' }); + expect(result.data[CLIENT_CAPABILITIES_META_KEY]).toEqual({}); + } + }); + + test('accepts the optional log level, progress token, and unknown keys', () => { + const result = RequestMetaEnvelopeSchema.safeParse({ + ...envelope, + [LOG_LEVEL_META_KEY]: 'warning', + progressToken: 'token-1', + 'com.example/custom': { anything: true } + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data[LOG_LEVEL_META_KEY]).toBe('warning'); + expect(result.data.progressToken).toBe('token-1'); + expect(result.data['com.example/custom']).toEqual({ anything: true }); + } + }); + + test.each([PROTOCOL_VERSION_META_KEY, CLIENT_INFO_META_KEY, CLIENT_CAPABILITIES_META_KEY])( + 'rejects an envelope missing %s', + key => { + const incomplete: Record = { ...envelope }; + delete incomplete[key]; + expect(RequestMetaEnvelopeSchema.safeParse(incomplete).success).toBe(false); + } + ); + + test('rejects an invalid log level', () => { + const result = RequestMetaEnvelopeSchema.safeParse({ ...envelope, [LOG_LEVEL_META_KEY]: 'loud' }); + expect(result.success).toBe(false); + }); + }); + + describe('DiscoverRequest', () => { + test('parses a discover request with and without params', () => { + expect(DiscoverRequestSchema.safeParse({ method: 'server/discover' }).success).toBe(true); + expect( + DiscoverRequestSchema.safeParse({ + method: 'server/discover', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: '2026-07-28' } } + }).success + ).toBe(true); + }); + + test('rejects other methods', () => { + expect(DiscoverRequestSchema.safeParse({ method: 'initialize' }).success).toBe(false); + }); + }); + + describe('DiscoverResult', () => { + const result = { + supportedVersions: ['2026-07-28'], + capabilities: { tools: { listChanged: true } }, + serverInfo: { name: 'test-server', version: '1.0.0' } + }; + + test('parses a discover result', () => { + const parsed = DiscoverResultSchema.safeParse({ ...result, resultType: 'complete', instructions: 'Use the echo tool.' }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.supportedVersions).toEqual(['2026-07-28']); + expect(parsed.data.capabilities).toEqual({ tools: { listChanged: true } }); + expect(parsed.data.serverInfo).toEqual({ name: 'test-server', version: '1.0.0' }); + expect(parsed.data.instructions).toBe('Use the echo tool.'); + } + }); + + test.each(['supportedVersions', 'capabilities', 'serverInfo'])('rejects a discover result missing %s', key => { + const incomplete: Record = { ...result }; + delete incomplete[key]; + expect(DiscoverResultSchema.safeParse(incomplete).success).toBe(false); + }); + }); + + describe('Result resultType passthrough', () => { + test('accepts results with and without resultType (absent means "complete")', () => { + const withIt = ResultSchema.safeParse({ resultType: 'complete' }); + expect(withIt.success).toBe(true); + if (withIt.success) { + expect(withIt.data.resultType).toBe('complete'); + } + const withoutIt = ResultSchema.safeParse({}); + expect(withoutIt.success).toBe(true); + if (withoutIt.success) { + expect(withoutIt.data.resultType).toBeUndefined(); + } + }); + + test('rejects a non-string resultType', () => { + expect(ResultSchema.safeParse({ resultType: 42 }).success).toBe(false); + }); + + test('EmptyResult accepts resultType but still rejects unknown keys', () => { + expect(EmptyResultSchema.safeParse({ resultType: 'complete' }).success).toBe(true); + expect(EmptyResultSchema.safeParse({ unexpected: true }).success).toBe(false); + }); + }); +}); diff --git a/packages/core/test/types/constants.test.ts b/packages/core/test/types/constants.test.ts new file mode 100644 index 0000000000..ecd339ed2a --- /dev/null +++ b/packages/core/test/types/constants.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { DRAFT_PROTOCOL_VERSION, isStatefulProtocolVersion, STATEFUL_PROTOCOL_VERSIONS } from '../../src/types/constants.js'; +import { LATEST_PROTOCOL_VERSION as FROZEN_2025_11_25_PROTOCOL_VERSION } from '../../src/types/spec.types.2025-11-25.js'; +import { LATEST_PROTOCOL_VERSION as DRAFT_SPEC_LATEST_PROTOCOL_VERSION } from '../../src/types/spec.types.draft.js'; + +describe('protocol version constants', () => { + it('pins the draft wire literal to the draft specification schema', () => { + expect(DRAFT_PROTOCOL_VERSION).toBe(DRAFT_SPEC_LATEST_PROTOCOL_VERSION); + }); + + it('classifies the draft specification revision as stateless', () => { + expect(isStatefulProtocolVersion(DRAFT_SPEC_LATEST_PROTOCOL_VERSION)).toBe(false); + }); + + it('classifies every released revision up to 2025-11-25 as stateful, newest first', () => { + expect(STATEFUL_PROTOCOL_VERSIONS[0]).toBe(FROZEN_2025_11_25_PROTOCOL_VERSION); + expect(STATEFUL_PROTOCOL_VERSIONS).toEqual([...STATEFUL_PROTOCOL_VERSIONS].sort().reverse()); + for (const version of STATEFUL_PROTOCOL_VERSIONS) { + expect(isStatefulProtocolVersion(version)).toBe(true); + } + }); +}); diff --git a/packages/core/test/types/errors.test.ts b/packages/core/test/types/errors.test.ts new file mode 100644 index 0000000000..b908dfb397 --- /dev/null +++ b/packages/core/test/types/errors.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { ProtocolErrorCode } from '../../src/types/enums.js'; +import { ProtocolError, UnsupportedProtocolVersionError } from '../../src/types/errors.js'; + +describe('UnsupportedProtocolVersionError', () => { + const data = { supported: ['2025-11-25', '2025-06-18'], requested: '2026-07-28' }; + + it('carries code -32004 and the supported/requested data', () => { + const error = new UnsupportedProtocolVersionError(data); + expect(error.code).toBe(ProtocolErrorCode.UnsupportedProtocolVersion); + expect(error.code).toBe(-32004); + expect(error.supported).toEqual(['2025-11-25', '2025-06-18']); + expect(error.requested).toBe('2026-07-28'); + expect(error.data).toEqual(data); + }); + + it('defaults the message from the requested version', () => { + const error = new UnsupportedProtocolVersionError(data); + expect(error.message).toBe('Unsupported protocol version: 2026-07-28'); + const custom = new UnsupportedProtocolVersionError(data, 'try another version'); + expect(custom.message).toBe('try another version'); + }); + + it('is materialized by ProtocolError.fromError', () => { + const error = ProtocolError.fromError(-32004, 'Unsupported protocol version: 2026-07-28', data); + expect(error).toBeInstanceOf(UnsupportedProtocolVersionError); + if (error instanceof UnsupportedProtocolVersionError) { + expect(error.supported).toEqual(['2025-11-25', '2025-06-18']); + expect(error.requested).toBe('2026-07-28'); + } + expect(error.message).toBe('Unsupported protocol version: 2026-07-28'); + }); + + it('falls back to a generic ProtocolError when the data is missing or malformed', () => { + for (const malformed of [undefined, {}, { supported: 'not-an-array', requested: '2026-07-28' }, { supported: ['2025-11-25'] }]) { + const error = ProtocolError.fromError(-32004, 'unsupported', malformed); + expect(error).toBeInstanceOf(ProtocolError); + expect(error).not.toBeInstanceOf(UnsupportedProtocolVersionError); + expect(error.code).toBe(-32004); + expect(error.data).toEqual(malformed); + } + }); +}); diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 68a0c224f0..bbaf1fe06c 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -10,7 +10,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { getRequestListener } from '@hono/node-server'; -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; +import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, StatelessHandlers, Transport } from '@modelcontextprotocol/core'; import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; @@ -130,6 +130,20 @@ export class NodeStreamableHTTPServerTransport implements Transport { return this._webStandardTransport.onmessage; } + /** + * Sets the supported protocol versions for header validation and stateless + * routing. Called by the server during `connect()` to pass its supported + * versions; forwarded to the wrapped Web Standard transport. + */ + setSupportedProtocolVersions(versions: string[]): void { + this._webStandardTransport.setSupportedProtocolVersions(versions); + } + + /** @internal */ + setStatelessHandlers(handlers: StatelessHandlers): void { + this._webStandardTransport.setStatelessHandlers(handlers); + } + /** * Starts the transport. This is required by the {@linkcode Transport} interface but is a no-op * for the Streamable HTTP transport as connections are managed per-request. diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 9e117f05c3..7aed25bc10 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -6,6 +6,7 @@ import type { CreateMessageRequestParamsWithTools, CreateMessageResult, CreateMessageResultWithTools, + DiscoverResult, ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, @@ -13,6 +14,7 @@ import type { InitializeRequest, InitializeResult, JSONRPCRequest, + JSONRPCResponse, JsonSchemaType, jsonSchemaValidator, ListRootsRequest, @@ -22,32 +24,43 @@ import type { NotificationMethod, NotificationOptions, ProtocolOptions, + RequestMetaEnvelope, RequestMethod, RequestOptions, ResourceUpdatedNotification, Result, ServerCapabilities, ServerContext, + StatelessDispatchContext, ToolResultContent, - ToolUseContent + ToolUseContent, + Transport } from '@modelcontextprotocol/core'; import { CallToolRequestSchema, CallToolResultSchema, + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, CreateMessageResultSchema, CreateMessageResultWithToolsSchema, ElicitResultSchema, EmptyResultSchema, + isStatefulProtocolVersion, LATEST_PROTOCOL_VERSION, ListRootsResultSchema, + LOG_LEVEL_META_KEY, LoggingLevelSchema, mergeCapabilities, + NotImplementedYetError, parseSchema, Protocol, + PROTOCOL_VERSION_META_KEY, ProtocolError, ProtocolErrorCode, + RequestMetaEnvelopeSchema, SdkError, - SdkErrorCode + SdkErrorCode, + UnsupportedProtocolVersionError } from '@modelcontextprotocol/core'; import { DefaultJsonSchemaValidator } from '@modelcontextprotocol/server/_shims'; @@ -73,6 +86,24 @@ export type ServerOptions = ProtocolOptions & { jsonSchemaValidator?: jsonSchemaValidator; }; +/** + * Client→server request methods that earlier protocol revisions defined and revision + * 2026-07-28 removed: lifecycle and per-session methods have no meaning when every + * request is self-contained (`initialize`, `ping`, `logging/setLevel` — replaced by the + * per-request `logLevel` `_meta` claim) and resource subscriptions moved to + * `subscriptions/listen` (`resources/subscribe`, `resources/unsubscribe`). They are + * rejected on the stateless dispatch path with `-32601` (Method not found), exactly as + * if the method did not exist — handlers for them remain registered only to serve + * stateful-era traffic. + */ +const STATELESS_REMOVED_METHODS: ReadonlySet = new Set([ + 'initialize', + 'ping', + 'logging/setLevel', + 'resources/subscribe', + 'resources/unsubscribe' +]); + /** * An MCP server on top of a pluggable transport. * @@ -83,7 +114,6 @@ export type ServerOptions = ProtocolOptions & { export class Server extends Protocol { private _clientCapabilities?: ClientCapabilities; private _clientVersion?: Implementation; - private _negotiatedProtocolVersion?: string; private _capabilities: ServerCapabilities; private _instructions?: string; private _jsonSchemaValidator: jsonSchemaValidator; @@ -106,6 +136,10 @@ export class Server extends Protocol { this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new DefaultJsonSchemaValidator(); this.setRequestHandler('initialize', request => this._oninitialize(request)); + // Discovery is served built-in, like ping: the response is derived entirely + // from the server's own configuration, so there is nothing for user code to + // decide. Registering a user handler for 'server/discover' replaces this default. + this.setRequestHandler('server/discover', () => this._ondiscover()); this.setNotificationHandler('notifications/initialized', () => this.oninitialized?.()); if (this._capabilities.logging) { @@ -126,6 +160,151 @@ export class Server extends Protocol { }); } + /** + * Attaches to the given transport, starts it, and starts listening for messages. + * + * Installs the stateless dispatch handlers on transports that support + * per-request routing (the seam is optional on the {@linkcode Transport} + * contract) before `super.connect()` starts the transport, so the first + * message cannot arrive before the router is wired. + */ + override async connect(transport: Transport): Promise { + transport.setStatelessHandlers?.({ + dispatch: (request, ctx) => this._dispatchStateless(request, ctx) + }); + await super.connect(transport); + } + + /** + * Serves one stateless (draft-protocol-version) request routed here by the + * transport, outside the `onmessage` / session flow. + * + * Order of checks: method gate (`-32601`), envelope acceptance (`-32602`), + * version negotiation (`-32004`), then handler dispatch. The method gate runs + * first because JSON-RPC resolves the method before interpreting params; the + * envelope is validated before handler lookup so that a request with an + * incomplete `_meta` is rejected for the actual problem (`-32602`) rather + * than reporting `-32601` for a method this server happens not to implement. + */ + private async _dispatchStateless(request: JSONRPCRequest, dispatchCtx: StatelessDispatchContext): Promise { + // Removed-method gate. This protocol revision removed these RPCs, but the + // handlers for them are still registered to serve stateful-era traffic, so + // the lookup in invokeRequestHandler() would happily serve them. The + // response shape is byte-identical to the unknown-method -32601. + if (STATELESS_REMOVED_METHODS.has(request.method)) { + return { + jsonrpc: '2.0', + id: request.id, + error: { code: ProtocolErrorCode.MethodNotFound, message: 'Method not found' } + }; + } + + // Envelope acceptance. This revision requires the protocol version, client + // info, and client capabilities in every request's _meta. The wire schemas + // deliberately stay lenient so they also parse earlier-revision requests + // (no envelope); era-requiredness is enforced here, at dispatch. + const parsed = parseSchema(RequestMetaEnvelopeSchema, request.params?._meta ?? {}); + if (!parsed.success) { + const issues = [...new Set(parsed.error.issues.map(issue => issue.path.join('.')).filter(Boolean))].join(', '); + return { + jsonrpc: '2.0', + id: request.id, + error: { + code: ProtocolErrorCode.InvalidParams, + message: + `Invalid request _meta envelope${issues ? ` (${issues})` : ''}: this protocol revision requires ` + + `${PROTOCOL_VERSION_META_KEY}, ${CLIENT_INFO_META_KEY}, and ${CLIENT_CAPABILITIES_META_KEY} on every request` + } + }; + } + const envelope = parsed.data; + + // Version negotiation. The stateless path serves only the non-stateful + // revisions this server lists; anything else gets -32004 with the full + // supported list so the caller can pick a mutual version and retry. + // (Transports only route non-stateful claims here; re-checking against the + // envelope keeps the error shape with the dispatch logic and covers claims + // the routing layer could not see.) + const requested = envelope[PROTOCOL_VERSION_META_KEY]; + if (isStatefulProtocolVersion(requested) || !this._supportedProtocolVersions.includes(requested)) { + const error = new UnsupportedProtocolVersionError({ supported: [...this._supportedProtocolVersions], requested }); + return { + jsonrpc: '2.0', + id: request.id, + error: { code: error.code, message: error.message, data: { supported: error.supported, requested: error.requested } } + }; + } + + return await this.invokeRequestHandler(request, this._buildStatelessContext(request, envelope, dispatchCtx)); + } + + /** + * Builds the per-request handler context for a stateless dispatch. Every fact + * is sourced from the request's own `_meta` envelope or the transport's + * per-request dispatch context — never from handshake or session state, and + * never inherited from a previous request. + */ + private _buildStatelessContext( + request: JSONRPCRequest, + envelope: RequestMetaEnvelope, + dispatchCtx: StatelessDispatchContext + ): ServerContext { + return { + sessionId: undefined, + mcpReq: { + id: request.id, + method: request.method, + protocolVersion: envelope[PROTOCOL_VERSION_META_KEY], + _meta: request.params?._meta, + signal: dispatchCtx.signal ?? new AbortController().signal, + send: (() => { + // TODO(SEP-2322 MRTR PR): under this revision, server-to-client + // interactions are embedded in results as input requests; there is + // no backchannel to send a standalone request on. + throw new NotImplementedYetError('Server-to-client requests are not supported on the stateless path yet'); + }) as ServerContext['mcpReq']['send'], + notify: async notification => { + // Request-scoped notifications ride the originating response stream. + await dispatchCtx.sendNotification?.({ jsonrpc: '2.0', ...notification }); + }, + log: async (level, data, logger) => { + // Per-request logging: the request's logLevel _meta claim is the + // opt-in (replacing the removed logging/setLevel RPC). Without a + // claim this revision forbids notifications/message for the request; + // with one, only messages at or above the claimed level are + // delivered, riding the originating response stream like every + // request-scoped notification. The claim is read from this request's + // own envelope and never stored, so it cannot leak into any other + // request's dispatch. + const claimedLevel = envelope[LOG_LEVEL_META_KEY]; + if (!this._capabilities.logging || claimedLevel === undefined || this._isBelowLevel(level, claimedLevel)) { + return; + } + await dispatchCtx.sendNotification?.({ + jsonrpc: '2.0', + method: 'notifications/message', + params: { level, data, logger } + }); + }, + elicitInput: () => { + // TODO(SEP-2322 MRTR PR): elicitation becomes an input request + // embedded in this request's result. + throw new NotImplementedYetError('Eliciting user input is not supported on the stateless path yet'); + }, + requestSampling: () => { + // TODO(SEP-2322 MRTR PR): sampling becomes an input request embedded + // in this request's result. + throw new NotImplementedYetError('Requesting sampling is not supported on the stateless path yet'); + } + }, + client: { + capabilities: envelope[CLIENT_CAPABILITIES_META_KEY], + info: envelope[CLIENT_INFO_META_KEY] + }, + http: dispatchCtx.authInfo ? { authInfo: dispatchCtx.authInfo } : undefined + }; + } + protected override buildContext(ctx: BaseContext, transportInfo?: MessageExtraInfo): ServerContext { // Only create http when there's actual HTTP transport info or auth info const hasHttpInfo = ctx.http || transportInfo?.request || transportInfo?.closeSSEStream || transportInfo?.closeStandaloneSSEStream; @@ -137,6 +316,12 @@ export class Server extends Protocol { elicitInput: (params, options) => this.elicitInput(params, options), requestSampling: (params, options) => this.createMessage(params, options) }, + // Sourced from the handshake state retained at initialize - the only source that exists today. + // Before the handshake completes (only `ping` is legal there), capabilities is `{}` and info undefined. + client: { + capabilities: this._clientCapabilities ?? {}, + info: this._clientVersion + }, http: hasHttpInfo ? { ...ctx.http, @@ -154,10 +339,14 @@ export class Server extends Protocol { // Map LogLevelSchema to severity index private readonly LOG_LEVEL_SEVERITY = new Map(LoggingLevelSchema.options.map((level, index) => [level, index])); + // Is `level` strictly below `threshold` in RFC 5424 severity order? + private _isBelowLevel = (level: LoggingLevel, threshold: LoggingLevel): boolean => + this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(threshold)!; + // Is a message with the given level ignored in the log level set for the given session id? private isMessageIgnored = (level: LoggingLevel, sessionId?: string): boolean => { const currentLevel = this._loggingLevels.get(sessionId); - return currentLevel ? this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(currentLevel)! : false; + return currentLevel ? this._isBelowLevel(level, currentLevel) : false; }; /** @@ -345,7 +534,8 @@ export class Server extends Protocol { } case 'ping': - case 'initialize': { + case 'initialize': + case 'server/discover': { // No specific capability required for these methods break; } @@ -358,9 +548,12 @@ export class Server extends Protocol { this._clientCapabilities = request.params.capabilities; this._clientVersion = request.params.clientInfo; - const protocolVersion = this._supportedProtocolVersions.includes(requestedVersion) + // initialize negotiates stateful versions only; an empty stateful subset falls back to the + // latest released version, matching the previous behavior for an empty supported list. + const statefulVersions = this._supportedProtocolVersions.filter(version => isStatefulProtocolVersion(version)); + const protocolVersion = statefulVersions.includes(requestedVersion) ? requestedVersion - : (this._supportedProtocolVersions[0] ?? LATEST_PROTOCOL_VERSION); + : (statefulVersions[0] ?? LATEST_PROTOCOL_VERSION); this._negotiatedProtocolVersion = protocolVersion; this.transport?.setProtocolVersion?.(protocolVersion); @@ -373,8 +566,58 @@ export class Server extends Protocol { }; } + /** + * Built-in handler for `server/discover`: advertises the protocol versions this + * server is configured to support (the same list an UnsupportedProtocolVersionError + * reports in `error.data.supported`), its capabilities, its implementation info, + * and its instructions (when configured). + * + * Discovery is connection-less: like `ping`, it is answered both before and after + * the initialize handshake, and on the stateless dispatch path. + */ + private _ondiscover(): DiscoverResult { + return { + supportedVersions: [...this._supportedProtocolVersions], + capabilities: this._discoverCapabilities(), + serverInfo: this._serverInfo, + ...(this._instructions && { instructions: this._instructions }) + }; + } + + /** + * The capabilities `server/discover` advertises: the declared capabilities minus + * the subscription-delivery flags (`prompts.listChanged`, `resources.listChanged`, + * `resources.subscribe`, `tools.listChanged`). + * + * Under the per-request protocol revisions those notifications are delivered + * through `subscriptions/listen`, which this SDK does not implement yet, so + * discovery must not advertise them: advertised capabilities reflect what RPC + * handlers actually honor. The flags still appear in the initialize result, where + * the stateful-era notification flow delivers them. + */ + // TODO(subscriptions/listen PR): stop withholding these flags once listen delivers them. + private _discoverCapabilities(): ServerCapabilities { + const advertised: ServerCapabilities = { ...this._capabilities }; + if (advertised.prompts) { + advertised.prompts = { ...advertised.prompts }; + delete advertised.prompts.listChanged; + } + if (advertised.resources) { + advertised.resources = { ...advertised.resources }; + delete advertised.resources.subscribe; + delete advertised.resources.listChanged; + } + if (advertised.tools) { + advertised.tools = { ...advertised.tools }; + delete advertised.tools.listChanged; + } + return advertised; + } + /** * After initialization has completed, this will be populated with the client's reported capabilities. + * + * Inside a request handler, prefer `ctx.client.capabilities`, which reads the same facts per request. */ getClientCapabilities(): ClientCapabilities | undefined { return this._clientCapabilities; @@ -382,20 +625,13 @@ export class Server extends Protocol { /** * After initialization has completed, this will be populated with information about the client's name and version. + * + * Inside a request handler, prefer `ctx.client.info`, which reads the same facts per request. */ getClientVersion(): Implementation | undefined { return this._clientVersion; } - /** - * After initialization has completed, this will be populated with the protocol version negotiated - * with the client (the version the server responded with during the initialize handshake), or - * `undefined` before initialization. - */ - getNegotiatedProtocolVersion(): string | undefined { - return this._negotiatedProtocolVersion; - } - /** * Returns the current server capabilities. */ @@ -600,17 +836,38 @@ export class Server extends Protocol { }); } + /** + * Asserts that a list_changed notification has a delivery channel. + * + * Under the per-request protocol revisions, list_changed notifications are + * delivered on `subscriptions/listen` streams; until listen is implemented, + * a server configured exclusively for per-request revisions has no channel + * that can carry an out-of-band notification (matching `server/discover`, + * which withholds the listChanged capability flags for the same reason). + * Servers listing at least one initialize-era version keep the existing + * connection-scoped delivery for that era, unchanged. + */ + // TODO(subscriptions PR): delete this guard when subscriptions/listen delivers list_changed. + private _assertListChangedDeliverable(): void { + if (!this._supportedProtocolVersions.some(version => isStatefulProtocolVersion(version))) { + throw new NotImplementedYetError('list_changed notifications are not supported for per-request protocol revisions yet'); + } + } + async sendResourceListChanged() { + this._assertListChangedDeliverable(); return this.notification({ method: 'notifications/resources/list_changed' }); } async sendToolListChanged() { + this._assertListChangedDeliverable(); return this.notification({ method: 'notifications/tools/list_changed' }); } async sendPromptListChanged() { + this._assertListChangedDeliverable(); return this.notification({ method: 'notifications/prompts/list_changed' }); } } diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index ac2dd3f784..749c7a0ff1 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -1,7 +1,16 @@ import type { Readable, Writable } from 'node:stream'; -import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; -import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; +import type { JSONRPCMessage, JSONRPCRequest, RequestId, StatelessHandlers, Transport } from '@modelcontextprotocol/core'; +import { + INTERNAL_ERROR, + isJSONRPCNotification, + isJSONRPCRequest, + isStatefulProtocolVersion, + PROTOCOL_VERSION_META_KEY, + ReadBuffer, + serializeMessage, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; import { process } from '@modelcontextprotocol/server/_shims'; /** @@ -20,12 +29,45 @@ export class StdioServerTransport implements Transport { private _readBuffer: ReadBuffer = new ReadBuffer(); private _started = false; private _closed = false; + private _supportedProtocolVersions: string[] = SUPPORTED_PROTOCOL_VERSIONS; + + /** + * Hook for the stateless (draft-protocol-version) request path. Set + * internally by `Server.connect()` so this transport can route requests + * claiming a draft protocol version to the server's stateless dispatch + * instead of the `onmessage` path. Optional on the {@linkcode Transport} + * contract; only concrete server transports read it. + * @internal + */ + private _statelessHandlers?: StatelessHandlers; + + /** + * One AbortController per in-flight stateless request, keyed by JSON-RPC + * id: a `notifications/cancelled` arriving for one of these ids aborts the + * matching dispatch (the per-request cancellation the spec requires on + * stdio, where there is no transport-level request lifetime to close). + * Entries are removed when the dispatch settles. + */ + private _statelessAbortControllers = new Map(); constructor( private _stdin: Readable = process.stdin, private _stdout: Writable = process.stdout ) {} + /** + * Sets the supported protocol versions for stateless routing. + * Called by the server during {@linkcode server/server.Server.connect | connect()} to pass its supported versions. + */ + setSupportedProtocolVersions(versions: string[]): void { + this._supportedProtocolVersions = versions; + } + + /** @internal */ + setStatelessHandlers(handlers: StatelessHandlers): void { + this._statelessHandlers = handlers; + } + onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; @@ -69,13 +111,113 @@ export class StdioServerTransport implements Transport { break; } - this.onmessage?.(message); + // stdio has no session header: a request's _meta version claim is the routing + // signal, dual-keyed on the server opting in to non-stateful versions. Requests only. + const handlers = this._statelessHandlers; + if (handlers && isJSONRPCRequest(message) && this.claimsRoutableStatelessVersion(message)) { + void this.dispatchStatelessRequest(message, handlers); + } else if (this.cancelsStatelessRequest(message)) { + // Consumed: the cancellation belongs to a stateless dispatch, not to + // the connection-scoped protocol instance behind onmessage. + } else { + this.onmessage?.(message); + } } catch (error) { this.onerror?.(error as Error); } } } + /** + * Whether `request` claims (via `params._meta`, see + * {@linkcode PROTOCOL_VERSION_META_KEY}) a non-stateful (per-request) + * protocol version AND this transport's server has opted in by listing at + * least one such version as supported — the same dual-key rule the + * Streamable HTTP transport applies to sessionless requests. Claims of + * versions the server does not list are still routed when it has opted in: + * the dispatch answers them with `-32004` (UnsupportedProtocolVersionError). + */ + private claimsRoutableStatelessVersion(request: JSONRPCRequest): boolean { + const version = request.params?._meta?.[PROTOCOL_VERSION_META_KEY]; + return ( + typeof version === 'string' && + !isStatefulProtocolVersion(version) && + this._supportedProtocolVersions.some(supported => !isStatefulProtocolVersion(supported)) + ); + } + + /** + * Whether `message` is a `notifications/cancelled` for an in-flight + * stateless dispatch. When it is, the matching dispatch is aborted and the + * notification is consumed — it must not reach `onmessage`, where the + * connection-scoped protocol instance would look the id up among its own + * (stateful-era) requests and find nothing. Cancellations for any other id + * are left for `onmessage` unchanged. + */ + private cancelsStatelessRequest(message: JSONRPCMessage): boolean { + if (!isJSONRPCNotification(message) || message.method !== 'notifications/cancelled') { + return false; + } + const requestId = (message.params as { requestId?: unknown } | undefined)?.requestId; + if (typeof requestId !== 'string' && typeof requestId !== 'number') { + return false; + } + const controller = this._statelessAbortControllers.get(requestId); + if (controller === undefined) { + return false; + } + controller.abort(); + return true; + } + + /** + * Serves one request routed to the stateless dispatch path: forwards it to + * the installed dispatch handler and writes the returned response to + * stdout. Request-scoped notifications the handler emits are written to + * stdout before the response. Detached from the read loop (request→response, + * never blocks `onmessage` traffic); never throws — `dispatch()` maps + * handler failures to error responses, so a rejection is an internal fault + * answered with a generic error that leaks nothing, the request id echoed. + * + * Cancellation: a `notifications/cancelled` for this request aborts the + * per-request signal, after which NO further frames are written for the + * request — neither notifications nor the (eventual) response. + */ + private async dispatchStatelessRequest(request: JSONRPCRequest, handlers: StatelessHandlers): Promise { + const abortController = new AbortController(); + this._statelessAbortControllers.set(request.id, abortController); + try { + const response = await handlers.dispatch(request, { + signal: abortController.signal, + sendNotification: async notification => { + if (abortController.signal.aborted) { + return; + } + await this.send(notification); + } + }); + if (!abortController.signal.aborted) { + await this.send(response); + } + } catch (error) { + this.onerror?.(error as Error); + if (abortController.signal.aborted) { + return; + } + try { + await this.send({ jsonrpc: '2.0', id: request.id, error: { code: INTERNAL_ERROR, message: 'Internal error' } }); + } catch (sendError) { + this.onerror?.(sendError as Error); + } + } finally { + // Guarded delete: if a (spec-violating) duplicate id arrived while this + // request was in flight, its newer entry must survive this cleanup. + if (this._statelessAbortControllers.get(request.id) === abortController) { + this._statelessAbortControllers.delete(request.id); + } + } + } + async close(): Promise { if (this._closed) { return; diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index fd3563a077..68b1b8c0d4 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -7,20 +7,54 @@ * For Node.js Express/HTTP compatibility, use {@linkcode @modelcontextprotocol/node!NodeStreamableHTTPServerTransport | NodeStreamableHTTPServerTransport} which wraps this transport. */ -import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; +import type { + AuthInfo, + JSONRPCMessage, + JSONRPCRequest, + JSONRPCResponse, + MessageExtraInfo, + RequestId, + StatelessHandlers, + Transport +} from '@modelcontextprotocol/core'; import { DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + INTERNAL_ERROR, + INVALID_PARAMS, + INVALID_REQUEST, isInitializeRequest, isJSONRPCErrorResponse, + isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, + isStatefulProtocolVersion, JSONRPCMessageSchema, + METHOD_NOT_FOUND, + PARSE_ERROR, + PROTOCOL_VERSION_META_KEY, + ProtocolErrorCode, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; export type StreamId = string; export type EventId = string; +/** + * Extracts the protocol version claimed by a parsed POST body's first + * request via `params._meta` (see {@linkcode PROTOCOL_VERSION_META_KEY}). + * The body is either pre-parsed by middleware (`options.parsedBody`) or, when + * routing needs the claim and no header carries one, parsed once by + * `handleRequest` itself and handed to the chosen path. + */ +function versionFromParsedBody(body: unknown): string | undefined { + const first = Array.isArray(body) ? body.find(message => isJSONRPCRequest(message)) : body; + if (!isJSONRPCRequest(first)) { + return undefined; + } + const version = first.params?._meta?.[PROTOCOL_VERSION_META_KEY]; + return typeof version === 'string' ? version : undefined; +} + /** * Interface for resumability support via event storage */ @@ -340,17 +374,106 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return undefined; } + /** + * Hook for the stateless (draft-protocol-version) request path. Set + * internally by `Server.connect()` so this transport can route requests + * claiming a draft protocol version to the server's stateless dispatch + * instead of the `onmessage` path. Optional on the {@linkcode Transport} + * contract; only this concrete class reads it. + * @internal + */ + private _statelessHandlers?: StatelessHandlers; + + /** @internal */ + setStatelessHandlers(handlers: StatelessHandlers): void { + this._statelessHandlers = handlers; + } + /** * Handles an incoming HTTP request, whether `GET`, `POST`, or `DELETE` * Returns a `Response` object (Web Standard) + * + * Routes between the stateful path (today's sessions/initialize flow) and + * the stateless dispatch path (per-request protocol revisions), in this + * order: + * + * 1. A request carrying an `Mcp-Session-Id` header always takes the + * stateful path, regardless of any claimed version — sessions are + * version-locked at initialize, so a version claim never bypasses + * session validation. + * 2. Otherwise the claimed version is resolved: the + * `MCP-Protocol-Version` header first, falling back to the body's first + * request `_meta` — pre-parsed by middleware when available, otherwise + * parsed here once (POST only, when the stateless path is eligible) and + * passed along so the body stream is never read twice. + * 3. A claimed non-stateful (per-request) version is handled on the + * stateless path when stateless handlers are installed AND the server + * has opted in by listing at least one non-stateful version in its + * supported list. Claims of versions the server does not list are + * answered there with `-32004` (UnsupportedProtocolVersionError). + * 4. Everything else takes the stateful path unchanged — including any + * claim on a server that has not opted in, which still gets the + * existing unsupported-version 400 from header validation there. */ async handleRequest(req: Request, options?: HandleRequestOptions): Promise { - // Validate request headers for DNS rebinding protection + // Validate request headers for DNS rebinding protection (applies to both paths) const validationError = this.validateRequestHeaders(req); if (validationError) { return validationError; } + if (req.headers.get('mcp-session-id') === null) { + const statelessHandlers = this._statelessHandlers; + const statelessEligible = + statelessHandlers !== undefined && this._supportedProtocolVersions.some(version => !isStatefulProtocolVersion(version)); + let claimedVersion = req.headers.get('mcp-protocol-version') ?? versionFromParsedBody(options?.parsedBody); + + // A request may claim its revision solely via `params._meta`. When no + // header is present and no middleware pre-parsed the body, read the + // body here once (POST only) so the claim is visible to routing — + // whichever path is chosen receives the parsed body and never re-reads + // the stream. The accept/content-type/parse validations below mirror + // the identical checks both POST paths perform first, so rejections + // are byte-identical to what the chosen path would have answered. + if (claimedVersion === undefined && statelessEligible && req.method === 'POST' && options?.parsedBody === undefined) { + const acceptHeader = req.headers.get('accept'); + if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); + return this.createJsonErrorResponse( + 406, + -32_000, + 'Not Acceptable: Client must accept both application/json and text/event-stream' + ); + } + const contentType = req.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); + return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); + } + let parsedBody: unknown; + try { + parsedBody = await req.json(); + } catch (error) { + this.onerror?.(error as Error); + return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON'); + } + options = { ...options, parsedBody }; + claimedVersion = versionFromParsedBody(parsedBody); + } + + if (claimedVersion !== undefined && !isStatefulProtocolVersion(claimedVersion) && statelessEligible && statelessHandlers) { + return this.handleStatelessRequest(req, statelessHandlers, options); + } + } + + return this.handleStatefulRequest(req, options); + } + + /** + * Today's request handling (`initialize`, sessions, `GET`/`DELETE`): the + * body `handleRequest` had before stateless routing was added, unchanged. + */ + private async handleStatefulRequest(req: Request, options?: HandleRequestOptions): Promise { switch (req.method) { case 'POST': { return this.handlePostRequest(req, options); @@ -367,6 +490,251 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { } } + /** + * Handles a request routed to the stateless dispatch path (per-request + * protocol revisions). Accepts exactly one JSON-RPC message per POST — the + * stateless path never accepts batches — and forwards single requests to + * the installed dispatch handler. Never touches session or stream state; + * safe to call concurrently on a shared transport instance, and never + * emits an `Mcp-Session-Id` header. + */ + private async handleStatelessRequest(req: Request, handlers: StatelessHandlers, options?: HandleRequestOptions): Promise { + // The MCP endpoint is POST-only under this revision (the standalone GET + // stream is replaced by subscriptions/listen; sessions — and DELETE — + // do not exist). + if (req.method !== 'POST') { + this.onerror?.(new Error('Method not allowed.')); + return Response.json( + { jsonrpc: '2.0', error: { code: -32_000, message: 'Method not allowed.' }, id: null }, + { status: 405, headers: { Allow: 'POST', 'Content-Type': 'application/json' } } + ); + } + + // Same content negotiation as the stateful POST path: the client must + // accept both shapes the server may answer with, and send JSON. + const acceptHeader = req.headers.get('accept'); + if (!acceptHeader?.includes('application/json') || !acceptHeader.includes('text/event-stream')) { + this.onerror?.(new Error('Not Acceptable: Client must accept both application/json and text/event-stream')); + return this.createJsonErrorResponse( + 406, + -32_000, + 'Not Acceptable: Client must accept both application/json and text/event-stream' + ); + } + const contentType = req.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + this.onerror?.(new Error('Unsupported Media Type: Content-Type must be application/json')); + return this.createJsonErrorResponse(415, -32_000, 'Unsupported Media Type: Content-Type must be application/json'); + } + + let rawMessage: unknown; + if (options?.parsedBody === undefined) { + try { + rawMessage = await req.json(); + } catch (error) { + this.onerror?.(error as Error); + return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON'); + } + } else { + rawMessage = options.parsedBody; + } + + if (Array.isArray(rawMessage)) { + this.onerror?.(new Error('Invalid Request: Batching is not supported on the stateless path')); + return this.createJsonErrorResponse(400, -32_600, 'Invalid Request: Batching is not supported on the stateless path'); + } + + let message: JSONRPCMessage; + try { + message = JSONRPCMessageSchema.parse(rawMessage); + } catch (error) { + this.onerror?.(error as Error); + return this.createJsonErrorResponse(400, -32_700, 'Parse error: Invalid JSON-RPC message'); + } + + if (isJSONRPCNotification(message)) { + // Accepted with no body. Each request is self-contained on this path + // (its own POST, its own response stream), so there is no cross-request + // work a client notification could affect; cancellation is expressed by + // closing the response stream, not notifications/cancelled. + return new Response(null, { status: 202 }); + } + + if (!isJSONRPCRequest(message)) { + // A JSON-RPC response body: nothing on this path awaits one. (Responses + // to server-initiated input requests arrive embedded in a retried + // request per SEP-2322, not as standalone response bodies.) + this.onerror?.(new Error('Invalid Request: no server-initiated request awaits a response on the stateless path')); + return this.createJsonErrorResponse( + 400, + -32_600, + 'Invalid Request: no server-initiated request awaits a response on the stateless path' + ); + } + + // The MCP-Protocol-Version header must match the version claimed in the + // request body's _meta; a disagreement is a HeaderMismatch (-32001). + const headerVersion = req.headers.get('mcp-protocol-version'); + const metaVersion = message.params?._meta?.[PROTOCOL_VERSION_META_KEY]; + if (headerVersion !== null && typeof metaVersion === 'string' && headerVersion !== metaVersion) { + const mismatch = `Header mismatch: MCP-Protocol-Version header value '${headerVersion}' does not match body value '${metaVersion}'`; + this.onerror?.(new Error(mismatch)); + return Response.json( + { jsonrpc: '2.0', id: message.id, error: { code: -32_001, message: mismatch } }, + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + return this.dispatchStatelessRequest(message, handlers, req, options); + } + + /** + * Dispatches one stateless request and shapes the HTTP response: + * + * - If the handler emits request-scoped notifications, an SSE stream is + * opened (HTTP 200) carrying them in order, terminated by the final + * JSON-RPC response. + * - Otherwise the JSON-RPC response is returned as a single JSON object + * with the HTTP status mapped from its outcome (see + * {@linkcode statusFromStatelessResponse}). + */ + private dispatchStatelessRequest( + request: JSONRPCRequest, + handlers: StatelessHandlers, + req: Request, + options?: HandleRequestOptions + ): Promise { + return new Promise(resolve => { + const encoder = new TextEncoder(); + let controller: ReadableStreamDefaultController | undefined; + // Set once the response has settled (JSON resolved, or the final SSE + // event written). Notifications arriving after that point come from a + // handler task that outlived its request: they are dropped rather than + // buffered into a stream nothing will ever read. + let settled = false; + let warnedLateNotification = false; + + const openStream = (): void => { + if (settled || controller !== undefined) { + return; + } + const readable = new ReadableStream({ + start: c => { + controller = c; + } + }); + resolve( + new Response(readable, { + status: 200, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + } + }) + ); + }; + + // Starting with Promise.resolve() puts any synchronous errors into the monad as well. + Promise.resolve() + .then(() => + handlers.dispatch(request, { + authInfo: options?.authInfo, + // Client disconnect = cancellation of the request under this revision. + signal: req.signal, + sendNotification: async notification => { + if (settled) { + if (!warnedLateNotification) { + warnedLateNotification = true; + this.onerror?.( + new Error( + 'Notification sent after the response settled; dropping it (a handler task outlived its request).' + ) + ); + } + return; + } + openStream(); + this.writeSSEEvent(controller!, encoder, notification); + } + }) + ) + .then( + response => { + settled = true; + if (controller !== undefined) { + this.writeSSEEvent(controller, encoder, response); + try { + controller.close(); + } catch { + // Stream already closed (e.g. client disconnected). + } + return; + } + resolve( + Response.json(response, { + status: this.statusFromStatelessResponse(response), + headers: { 'Content-Type': 'application/json' } + }) + ); + }, + (error: unknown) => { + settled = true; + // dispatch() maps handler failures to error responses; a rejection is + // an internal fault. The wire gets a generic message (nothing leaks). + this.onerror?.(error as Error); + const errorResponse: JSONRPCMessage = { + jsonrpc: '2.0', + id: request.id, + error: { code: INTERNAL_ERROR, message: 'Internal error' } + }; + if (controller !== undefined) { + this.writeSSEEvent(controller, encoder, errorResponse); + try { + controller.close(); + } catch { + // Stream already closed (e.g. client disconnected). + } + return; + } + resolve(Response.json(errorResponse, { status: 500, headers: { 'Content-Type': 'application/json' } })); + } + ); + }); + } + + /** + * Maps a stateless dispatch outcome to its HTTP status. Pre-dispatch + * rejections and negotiation failures surface as HTTP errors so callers + * (and intermediaries) can react without parsing the body; results and + * domain-level errors travel as HTTP 200 like on the stateful path. + */ + private statusFromStatelessResponse(response: JSONRPCResponse): number { + if (!isJSONRPCErrorResponse(response)) { + return 200; + } + switch (response.error.code) { + // Method not found: distinguishable from a legacy server's 404 by the body. + case METHOD_NOT_FOUND: { + return 404; + } + case PARSE_ERROR: + case INVALID_REQUEST: + case INVALID_PARAMS: + case -32_001: // HeaderMismatch + case ProtocolErrorCode.MissingRequiredClientCapability: + case ProtocolErrorCode.UnsupportedProtocolVersion: { + return 400; + } + case INTERNAL_ERROR: { + return 500; + } + default: { + return 200; + } + } + } + /** * Writes a priming event to establish resumption capability. * Only sends if `eventStore` is configured (opt-in for resumability) and diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0edcfd3af0..1626a1aebc 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -1,9 +1,12 @@ -import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/core'; +import type { JSONRPCMessage, JSONRPCRequest, ServerContext, StatelessHandlers, Transport } from '@modelcontextprotocol/core'; import { + DEFAULT_NEGOTIATED_PROTOCOL_VERSION, + DRAFT_PROTOCOL_VERSION, InitializeResultSchema, InMemoryTransport, isJSONRPCResultResponse, LATEST_PROTOCOL_VERSION, + NotImplementedYetError, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import { Server } from '../../src/server/server.js'; @@ -130,4 +133,130 @@ describe('Server', () => { await server.close(); }); }); + + 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] + } + ); + + const respondedVersion = await initializeServer(server, DRAFT_PROTOCOL_VERSION); + + 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, 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', () => { + // The post-initialize values (declared capabilities/info, negotiated version) are + // observable over the wire and covered by the handler-context e2e scenarios; only the + // pre-initialize state is pinned here. + it('pre-initialize: ping before the handshake gets {} capabilities, undefined info, and the default version', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + + let captured: ServerContext | undefined; + server.setRequestHandler('ping', async (_request, ctx) => { + captured = ctx; + return {}; + }); + + await clientTransport.start(); + const pingResponse = new Promise(resolve => { + clientTransport.onmessage = () => resolve(); + }); + // No initialize handshake first - only ping is legal pre-initialize. + await clientTransport.send({ jsonrpc: '2.0', id: 1, method: 'ping', params: {} } as JSONRPCMessage); + await pingResponse; + + expect(captured?.client.capabilities).toEqual({}); + expect(captured?.client.info).toBeUndefined(); + expect(captured?.mcpReq.protocolVersion).toBe(DEFAULT_NEGOTIATED_PROTOCOL_VERSION); + + await server.close(); + }); + }); + + describe('connect() installs the stateless dispatch seam', () => { + /** Minimal transport double offering the `setStatelessHandlers` seam. */ + function transportDouble(): { + transport: Transport; + calls: string[]; + handlers: () => StatelessHandlers | undefined; + } { + const calls: string[] = []; + let installed: StatelessHandlers | undefined; + const transport: Transport = { + start: async () => { + calls.push('start'); + }, + send: async () => {}, + close: async () => {}, + setStatelessHandlers: handlers => { + calls.push('setStatelessHandlers'); + installed = handlers; + } + }; + return { transport, calls, handlers: () => installed }; + } + + it('installs the dispatch handler before starting the transport', async () => { + const { transport, calls, handlers } = transportDouble(); + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + + await server.connect(transport); + + expect(calls).toEqual(['setStatelessHandlers', 'start']); + expect(handlers()).toBeDefined(); + + await server.close(); + }); + }); + + describe('list_changed emission under per-request revisions', () => { + // TODO(subscriptions PR): these notifications gain their per-request-era + // carrier (subscriptions/listen); the guard and this block go away then. + it('rejects list_changed emission on a server configured for per-request revisions only', async () => { + const server = new Server( + { name: 'test', version: '1.0.0' }, + { capabilities: { tools: {}, prompts: {}, resources: {} }, supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION] } + ); + + await expect(server.sendToolListChanged()).rejects.toThrow(NotImplementedYetError); + await expect(server.sendPromptListChanged()).rejects.toThrow(NotImplementedYetError); + await expect(server.sendResourceListChanged()).rejects.toThrow(NotImplementedYetError); + }); + + it('keeps list_changed emission for servers listing an initialize-era version', async () => { + // Default supported list (initialize-era versions present): the guard never + // fires — the emission proceeds to the existing not-connected failure mode. + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { tools: {} } }); + + await expect(server.sendToolListChanged()).rejects.toThrow(/[Nn]ot connected/); + }); + }); }); diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 92671cacd9..2f28ff3d4e 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -1,7 +1,14 @@ import { Readable, Writable } from 'node:stream'; import type { JSONRPCMessage } from '@modelcontextprotocol/core'; -import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; +import { + DRAFT_PROTOCOL_VERSION, + PROTOCOL_VERSION_META_KEY, + ReadBuffer, + serializeMessage, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/core'; +import { vi } from 'vitest'; import { StdioServerTransport } from '../../src/server/stdio.js'; @@ -179,3 +186,266 @@ test('should fire onerror before onclose on stdout error', async () => { expect(events).toEqual(['error', 'close']); }); + +// ───── stateless routing (draft protocol revisions) ───── + +/** A request claiming `version` per-request via `params._meta` — the stdio routing signal. */ +function versionClaimingRequest(id: number, version: string): JSONRPCMessage { + return { jsonrpc: '2.0', id, method: 'tools/list', params: { _meta: { [PROTOCOL_VERSION_META_KEY]: version } } }; +} + +const DRAFT_LISTED = [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION]; + +/** Waits for the next JSON-RPC message the transport writes to stdout. */ +async function nextStdoutMessage(): Promise { + return await vi.waitFor(() => { + const message = outputBuffer.readMessage(); + if (message === null) { + throw new Error('no message written to stdout yet'); + } + return message; + }); +} + +test('sends the dispatch result for a routed request on stdout', async () => { + const server = new StdioServerTransport(input, output); + const dispatch = vi.fn().mockResolvedValue({ jsonrpc: '2.0', id: 3, result: { ok: true } }); + server.setStatelessHandlers({ dispatch }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + const onmessage = vi.fn(); + server.onmessage = onmessage; + server.onerror = error => { + throw error; + }; + + await server.start(); + const request = versionClaimingRequest(3, DRAFT_PROTOCOL_VERSION); + input.push(serializeMessage(request)); + + expect(await nextStdoutMessage()).toEqual({ jsonrpc: '2.0', id: 3, result: { ok: true } }); + expect(dispatch).toHaveBeenCalledExactlyOnceWith(request, { + signal: expect.any(AbortSignal), + sendNotification: expect.any(Function) + }); + expect(onmessage).not.toHaveBeenCalled(); +}); + +test('writes notifications the dispatch emits to stdout before the response', async () => { + const server = new StdioServerTransport(input, output); + server.setStatelessHandlers({ + dispatch: async (request, ctx) => { + await ctx.sendNotification?.({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: 'tok', progress: 1 } + }); + return { jsonrpc: '2.0', id: request.id, result: {} }; + } + }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + server.onerror = error => { + throw error; + }; + + await server.start(); + input.push(serializeMessage(versionClaimingRequest(4, DRAFT_PROTOCOL_VERSION))); + + expect(await nextStdoutMessage()).toEqual({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: 'tok', progress: 1 } + }); + expect(await nextStdoutMessage()).toEqual({ jsonrpc: '2.0', id: 4, result: {} }); +}); + +test('leaves stateful-version, meta-less, and notification traffic on onmessage', async () => { + const server = new StdioServerTransport(input, output); + const dispatch = vi.fn(); + server.setStatelessHandlers({ dispatch }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + server.onerror = error => { + throw error; + }; + + const messages: JSONRPCMessage[] = [ + // A stateful-version claim never routes, even though the version is listed. + versionClaimingRequest(1, '2025-06-18'), + // No claim at all: today's traffic, untouched. + { jsonrpc: '2.0', id: 2, method: 'ping' }, + // Only requests route: a notification claiming a listed draft version stays on onmessage. + { + jsonrpc: '2.0', + method: 'notifications/initialized', + params: { _meta: { [PROTOCOL_VERSION_META_KEY]: DRAFT_PROTOCOL_VERSION } } + } + ]; + + const received: JSONRPCMessage[] = []; + server.onmessage = message => received.push(message); + + await server.start(); + for (const message of messages) { + input.push(serializeMessage(message)); + } + + await vi.waitFor(() => expect(received).toHaveLength(3)); + expect(received).toEqual(messages); + expect(dispatch).not.toHaveBeenCalled(); + expect(outputBuffer.readMessage()).toBeNull(); +}); + +test('a stateless-version claim falls through to onmessage when no handlers are installed', async () => { + const server = new StdioServerTransport(input, output); + server.setSupportedProtocolVersions(DRAFT_LISTED); + server.onerror = error => { + throw error; + }; + + const received: JSONRPCMessage[] = []; + server.onmessage = message => received.push(message); + + await server.start(); + const request = versionClaimingRequest(5, DRAFT_PROTOCOL_VERSION); + input.push(serializeMessage(request)); + + await vi.waitFor(() => expect(received).toEqual([request])); + expect(outputBuffer.readMessage()).toBeNull(); +}); + +test('a stateless-version claim falls through to onmessage when the server has not opted in', async () => { + const server = new StdioServerTransport(input, output); + const dispatch = vi.fn(); + server.setStatelessHandlers({ dispatch }); + // Default supported list: no non-stateful version listed, so the claim never routes. + server.onerror = error => { + throw error; + }; + + const received: JSONRPCMessage[] = []; + server.onmessage = message => received.push(message); + + await server.start(); + const request = versionClaimingRequest(6, DRAFT_PROTOCOL_VERSION); + input.push(serializeMessage(request)); + + await vi.waitFor(() => expect(received).toEqual([request])); + expect(dispatch).not.toHaveBeenCalled(); + expect(outputBuffer.readMessage()).toBeNull(); +}); + +test('an unlisted non-stateful claim still routes on an opted-in server', async () => { + // The opt-in is listing any non-stateful version; the dispatch then answers + // unlisted claims with -32004 (here doubled, so only routing is under test). + const server = new StdioServerTransport(input, output); + const dispatch = vi + .fn() + .mockResolvedValue({ jsonrpc: '2.0', id: 7, error: { code: -32_004, message: 'Unsupported protocol version' } }); + server.setStatelessHandlers({ dispatch }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + const onmessage = vi.fn(); + server.onmessage = onmessage; + server.onerror = error => { + throw error; + }; + + await server.start(); + const request = versionClaimingRequest(7, 'v999.0.0'); + input.push(serializeMessage(request)); + + expect(await nextStdoutMessage()).toEqual({ jsonrpc: '2.0', id: 7, error: { code: -32_004, message: 'Unsupported protocol version' } }); + expect(dispatch).toHaveBeenCalledExactlyOnceWith(request, { + signal: expect.any(AbortSignal), + sendNotification: expect.any(Function) + }); + expect(onmessage).not.toHaveBeenCalled(); +}); + +test('a dispatch rejection answers with a generic internal error (no leak)', async () => { + const server = new StdioServerTransport(input, output); + server.setStatelessHandlers({ + dispatch: () => { + throw new Error('secret internal detail'); + } + }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + const errors: Error[] = []; + server.onerror = error => errors.push(error); + + await server.start(); + input.push(serializeMessage(versionClaimingRequest(9, DRAFT_PROTOCOL_VERSION))); + + // The wire gets a generic message (no internal details leak); onerror gets the real one. + expect(await nextStdoutMessage()).toEqual({ jsonrpc: '2.0', id: 9, error: { code: -32_603, message: 'Internal error' } }); + expect(errors.map(error => error.message)).toEqual(['secret internal detail']); +}); + +// ───── stateless cancellation (notifications/cancelled for in-flight dispatches) ───── + +/** A `notifications/cancelled` for the given request id. */ +function cancelledNotification(requestId: number | string): JSONRPCMessage { + return { jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId } }; +} + +test('notifications/cancelled aborts an in-flight stateless dispatch and suppresses every later frame', async () => { + const server = new StdioServerTransport(input, output); + let dispatchCtxSignal: AbortSignal | undefined; + let release: () => void; + const released = new Promise(resolve => { + release = resolve; + }); + server.setStatelessHandlers({ + dispatch: async (request, ctx) => { + dispatchCtxSignal = ctx.signal; + await released; + // Late notification and response after the abort: neither may reach stdout. + await ctx.sendNotification?.({ jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 't', progress: 1 } }); + return { jsonrpc: '2.0', id: request.id, result: { late: true } }; + } + }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + const onmessage = vi.fn(); + server.onmessage = onmessage; + server.onerror = error => { + throw error; + }; + + await server.start(); + input.push(serializeMessage(versionClaimingRequest(11, DRAFT_PROTOCOL_VERSION))); + await vi.waitFor(() => expect(dispatchCtxSignal).toBeDefined()); + expect(dispatchCtxSignal!.aborted).toBe(false); + + input.push(serializeMessage(cancelledNotification(11))); + await vi.waitFor(() => expect(dispatchCtxSignal!.aborted).toBe(true)); + + release!(); + // Drain a macrotask so the dispatch settles, then assert silence. + await new Promise(resolve => setTimeout(resolve, 10)); + expect(outputBuffer.readMessage()).toBeNull(); + // The cancellation was consumed by the stateless path, never forwarded. + expect(onmessage).not.toHaveBeenCalled(); +}); + +test('notifications/cancelled for ids with no in-flight stateless dispatch stays on onmessage', async () => { + const server = new StdioServerTransport(input, output); + const dispatch = vi.fn().mockResolvedValue({ jsonrpc: '2.0', id: 12, result: {} }); + server.setStatelessHandlers({ dispatch }); + server.setSupportedProtocolVersions(DRAFT_LISTED); + const received: JSONRPCMessage[] = []; + server.onmessage = message => received.push(message); + server.onerror = error => { + throw error; + }; + + await server.start(); + // A completed stateless request: its map entry is gone by the time the cancel arrives. + input.push(serializeMessage(versionClaimingRequest(12, DRAFT_PROTOCOL_VERSION))); + expect(await nextStdoutMessage()).toEqual({ jsonrpc: '2.0', id: 12, result: {} }); + + const lateCancel = cancelledNotification(12); + const statefulCancel = cancelledNotification(99); + input.push(serializeMessage(lateCancel)); + input.push(serializeMessage(statefulCancel)); + + // Both cancellations belong to the connection-scoped protocol instance. + await vi.waitFor(() => expect(received).toEqual([lateCancel, statefulCancel])); +}); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index 7a23dd56bb..bd7ec8adc6 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import type { CallToolResult, JSONRPCErrorResponse, JSONRPCMessage } from '@modelcontextprotocol/core'; +import { DRAFT_PROTOCOL_VERSION, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/core'; import * as z from 'zod/v4'; import { McpServer } from '../../src/server/mcp.js'; @@ -957,6 +958,496 @@ describe('Zod v4', () => { }); }); + describe('HTTPServerTransport - Stateless routing (per-request protocol revisions)', () => { + const draftHeaders = { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION + }; + const toolsListDraft = { + jsonrpc: '2.0', + method: 'tools/list', + params: {}, + id: 'route-1' + } as JSONRPCMessage; + + /** The complete per-request `_meta` envelope this protocol revision requires. */ + const validEnvelope = () => ({ + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'test-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {} + }); + + /** A transport connected to a server that lists the draft protocol version as supported. */ + async function connectDraftServer(transport: WebStandardStreamableHTTPServerTransport): Promise { + const mcpServer = new McpServer( + { name: 'test-server', version: '1.0.0' }, + { + capabilities: {}, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] + } + ); + mcpServer.registerTool('noop', { inputSchema: z.object({}) }, async (): Promise => ({ content: [] })); + await mcpServer.connect(transport); + return mcpServer; + } + + it('non-POST methods on the stateless path get 405 with Allow: POST', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + for (const method of ['GET', 'DELETE'] as const) { + const response = await transport.handleRequest(new Request('http://localhost/mcp', { method, headers: draftHeaders })); + expect(response.status).toBe(405); + expect(response.headers.get('allow')).toBe('POST'); + } + + await transport.close(); + }); + + it('falls back to the parsed body _meta version claim when the header is absent', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + // The incomplete envelope proves the route: only the stateless path + // answers with the envelope -32602 (the stateful path would serve the + // request normally on this session-less transport). + const parsedBody = { + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION } }, + id: 'route-2' + }; + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify(parsedBody) + }), + { parsedBody } + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ id: 'route-2', error: { code: -32_602 } }); + + await transport.close(); + }); + + it('routes on the body _meta claim when the header is absent and the body is not pre-parsed', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + // Same incomplete-envelope trick as the pre-parsed test: only the + // stateless path answers -32602. Without routing-time body parsing this + // request would silently run on the stateful machinery. + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION } }, + id: 'route-raw-1' + }) + }) + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ id: 'route-raw-1', error: { code: -32_602 } }); + + await transport.close(); + }); + + it('serves a header-less raw-body claim end to end (the body is parsed once by routing)', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: validEnvelope() }, + id: 'route-raw-2' + }) + }) + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ id: 'route-raw-2', result: { tools: [{ name: 'noop' }] } }); + + await transport.close(); + }); + + it('routes the body _meta claim on a session-mode transport too (no session header present)', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => 'session-route-1' }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION } }, + id: 'route-raw-3' + }) + }) + ); + + // The stateless -32602, not the stateful "Server not initialized" rejection. + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ id: 'route-raw-3', error: { code: -32_602 } }); + + await transport.close(); + }); + + it('keeps header-less traffic with no body claim on the stateful path', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + // No version header, no _meta claim: legitimate 2025 traffic. The + // stateful machinery serves it (an envelope -32602 here would mean the + // routing sniff hijacked it). + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'tools/list', params: {}, id: 'route-raw-4' }) + }) + ); + + expect(response.status).toBe(200); + // The stateful request path answers over SSE. + expect(response.headers.get('content-type')).toBe('text/event-stream'); + const body = parseSSEData(await readSSEEvent(response)); + expect(body).toMatchObject({ id: 'route-raw-4', result: { tools: [{ name: 'noop' }] } }); + + await transport.close(); + }); + + it('serves a routed request with a complete envelope as a single JSON response', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: validEnvelope() }, + id: 'route-5' + }) + }) + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + // Sessionless invariant: the stateless path never emits a session header. + expect(response.headers.get('mcp-session-id')).toBeNull(); + expect(await response.json()).toMatchObject({ + jsonrpc: '2.0', + id: 'route-5', + result: { tools: [{ name: 'noop' }] } + }); + + await transport.close(); + }); + + it('opens an SSE stream when the handler emits request-scoped notifications', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] + }); + transport.setStatelessHandlers({ + dispatch: async (request, ctx) => { + await ctx.sendNotification?.({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: 'tok', progress: 1 } + }); + return { jsonrpc: '2.0', id: request.id, result: {} }; + } + }); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { method: 'POST', headers: draftHeaders, body: JSON.stringify(toolsListDraft) }) + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + + const events = (await response.text()) + .split('\n\n') + .filter(Boolean) + .map(event => + JSON.parse( + event + .split('\n') + .find(line => line.startsWith('data: '))! + .slice('data: '.length) + ) + ); + expect(events).toEqual([ + { jsonrpc: '2.0', method: 'notifications/progress', params: { progressToken: 'tok', progress: 1 } }, + { jsonrpc: '2.0', id: 'route-1', result: {} } + ]); + + await transport.close(); + }); + + it('drops notifications sent after the response settled instead of buffering into a dead stream', async () => { + const errors: Error[] = []; + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] + }); + transport.onerror = error => { + errors.push(error); + }; + let lateNotify!: () => Promise; + transport.setStatelessHandlers({ + dispatch: async (request, ctx) => { + // Capture the callback so a "leaked task" can fire it after the + // JSON response has already been produced. + lateNotify = () => + ctx.sendNotification!({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: 'late', progress: 1 } + }); + return { jsonrpc: '2.0', id: request.id, result: {} }; + } + }); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { method: 'POST', headers: draftHeaders, body: JSON.stringify(toolsListDraft) }) + ); + + // No notification before settling: a plain JSON response. + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('application/json'); + + // Late notifications are dropped (surfaced once via onerror), never buffered. + await lateNotify(); + await lateNotify(); + expect(errors.filter(error => error.message.includes('after the response settled'))).toHaveLength(1); + + await transport.close(); + }); + + it('maps JSON-RPC error codes to HTTP statuses on the stateless path', async () => { + const cases: Array<[number, number]> = [ + [-32_700, 400], // ParseError + [-32_600, 400], // InvalidRequest + [-32_602, 400], // InvalidParams + [-32_001, 400], // HeaderMismatch + [-32_003, 400], // MissingRequiredClientCapability + [-32_004, 400], // UnsupportedProtocolVersion + [-32_601, 404], // MethodNotFound + [-32_603, 500], // InternalError + [-32_002, 200], // domain-level error (ResourceNotFound): the HTTP exchange succeeded + [1234, 200] // application-defined error + ]; + + for (const [code, status] of cases) { + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] + }); + transport.setStatelessHandlers({ + dispatch: async request => ({ jsonrpc: '2.0', id: request.id, error: { code, message: 'mapped' } }) + }); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { method: 'POST', headers: draftHeaders, body: JSON.stringify(toolsListDraft) }) + ); + + expect(response.status, `code ${code}`).toBe(status); + expect(await response.json()).toMatchObject({ id: 'route-1', error: { code } }); + + await transport.close(); + } + }); + + it('rejects a header/_meta protocol version mismatch with 400 and -32001, id echoed', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'tools/list', + params: { _meta: { ...validEnvelope(), 'io.modelcontextprotocol/protocolVersion': 'v999.0.0' } }, + id: 'route-6' + }) + }) + ); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + jsonrpc: '2.0', + id: 'route-6', + error: { code: -32_001, message: expect.stringContaining('Header mismatch') } + }); + + await transport.close(); + }); + + it('takes the stateful path when no stateless handlers are installed, even with the draft version listed', async () => { + // Transport double for the seam-absent case: the draft version is listed via the + // constructor option but the transport is never connected, so no handlers exist. + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] + }); + + // A notification-only POST is answered 202 by the stateful path (the + // stateless path would reject it: it only dispatches single requests). + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }) + }) + ); + + expect(response.status).toBe(202); + + await transport.close(); + }); + + it('rejects batches on the stateless path', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify([toolsListDraft, { ...toolsListDraft, id: 'route-3' }]) + }) + ); + + expect(response.status).toBe(400); + expectErrorResponse(await response.json(), -32_600, /Batching is not supported/); + + await transport.close(); + }); + + it('answers a notification body on the stateless path with 202 and no body', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 'x' } }) + }) + ); + + expect(response.status).toBe(202); + expect(await response.text()).toBe(''); + + await transport.close(); + }); + + it('rejects a response body on the stateless path with 400', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify({ jsonrpc: '2.0', id: 9, result: {} }) + }) + ); + + expect(response.status).toBe(400); + expectErrorResponse(await response.json(), -32_600, /no server-initiated request awaits a response/); + + await transport.close(); + }); + + it('rejects a malformed JSON-RPC body on the stateless path with a parse error', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify({ jsonrpc: '2.0' }) + }) + ); + + expect(response.status).toBe(400); + expectErrorResponse(await response.json(), -32_700, /Invalid JSON-RPC message/); + + await transport.close(); + }); + + it('a dispatch rejection answers 500 with a generic message (no leak)', async () => { + // Fault-injecting handlers double: dispatch() maps handler errors to + // error responses itself, so the rejection branch needs direct injection. + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] + }); + transport.setStatelessHandlers({ + dispatch: () => { + throw new Error('secret internal detail'); + } + }); + const errors: Error[] = []; + transport.onerror = error => errors.push(error); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { method: 'POST', headers: draftHeaders, body: JSON.stringify(toolsListDraft) }) + ); + + // The wire gets a generic message (no internal details leak); onerror gets the real one. + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + jsonrpc: '2.0', + id: 'route-1', + error: { code: -32_603, message: 'Internal error' } + }); + expect(errors.map(error => error.message)).toEqual(['secret internal detail']); + + await transport.close(); + }); + + it('a raw non-JSON body on the stateless path gets a 400 parse error', async () => { + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await connectDraftServer(transport); + + const response = await transport.handleRequest( + new Request('http://localhost/mcp', { method: 'POST', headers: draftHeaders, body: 'this is not json' }) + ); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + jsonrpc: '2.0', + id: null, + error: { code: -32_700, message: 'Parse error: Invalid JSON' } + }); + + await transport.close(); + }); + }); + describe('close() re-entrancy guard', () => { it('should not recurse when onclose triggers a second close()', async () => { const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); diff --git a/scripts/fetch-spec-types.ts b/scripts/fetch-spec-types.ts index cd0e26f5f5..76c146e182 100644 --- a/scripts/fetch-spec-types.ts +++ b/scripts/fetch-spec-types.ts @@ -7,12 +7,23 @@ const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const PROJECT_ROOT = join(__dirname, '..'); +/** + * The protocol revisions the SDK keeps reference types for: + * - `2025-11-25`: the frozen, released schema. + * - `draft`: the in-progress schema for the next protocol revision. + * + * Each maps to `schema//schema.ts` upstream and is written to + * `packages/core/src/types/spec.types..ts`. + */ +const SUPPORTED_VERSIONS = ['2025-11-25', 'draft'] as const; +type SpecVersion = (typeof SUPPORTED_VERSIONS)[number]; + interface GitHubCommit { sha: string; } -async function fetchLatestSHA(): Promise { - const url = 'https://api.github.com/repos/modelcontextprotocol/modelcontextprotocol/commits?path=schema/draft/schema.ts&per_page=1'; +async function fetchLatestSHA(version: SpecVersion): Promise { + const url = `https://api.github.com/repos/modelcontextprotocol/modelcontextprotocol/commits?path=schema/${version}/schema.ts&per_page=1`; const response = await fetch(url); if (!response.ok) { @@ -27,8 +38,8 @@ async function fetchLatestSHA(): Promise { return commits[0].sha; } -async function fetchSpecTypes(sha: string): Promise { - const url = `https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/${sha}/schema/draft/schema.ts`; +async function fetchSpecTypes(version: SpecVersion, sha: string): Promise { + const url = `https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/${sha}/schema/${version}/schema.ts`; const response = await fetch(url); if (!response.ok) { @@ -38,49 +49,70 @@ async function fetchSpecTypes(sha: string): Promise { return await response.text(); } -async function main() { - try { - // Check if SHA is provided as command line argument - const providedSHA = process.argv[2]; - - let latestSHA: string; - if (providedSHA) { - console.log(`Using provided SHA: ${providedSHA}`); - latestSHA = providedSHA; - } else { - console.log('Fetching latest commit SHA...'); - latestSHA = await fetchLatestSHA(); - } +async function updateSpecTypes(version: SpecVersion, providedSHA?: string): Promise { + let sha: string; + if (providedSHA) { + console.log(`[${version}] Using provided SHA: ${providedSHA}`); + sha = providedSHA; + } else { + console.log(`[${version}] Fetching latest commit SHA...`); + sha = await fetchLatestSHA(version); + } - console.log(`Fetching spec.types.ts from commit: ${latestSHA}`); + console.log(`[${version}] Fetching schema.ts from commit: ${sha}`); - const specContent = await fetchSpecTypes(latestSHA); + const specContent = await fetchSpecTypes(version, sha); - // Read header template - const headerTemplate = `/** + // Read header template + const headerTemplate = `/** * This file is automatically generated from the Model Context Protocol specification. * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol - * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts + * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/{VERSION}/schema.ts * Last updated from commit: {SHA} * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. - * To update this file, run: pnpm run fetch:spec-types + * To update this file, run: pnpm run fetch:spec-types {VERSION} */`; - const header = headerTemplate.replace('{SHA}', latestSHA); + const header = headerTemplate.replaceAll('{VERSION}', version).replace('{SHA}', sha); + + // Combine header and content + const fullContent = header + specContent; + + // Format with prettier using the project's config so the output passes lint + const outputPath = join(PROJECT_ROOT, 'packages', 'core', 'src', 'types', `spec.types.${version}.ts`); + const prettierConfig = await prettier.resolveConfig(outputPath); + const formatted = await prettier.format(fullContent, { ...prettierConfig, filepath: outputPath }); - // Combine header and content - const fullContent = header + specContent; + writeFileSync(outputPath, formatted, 'utf-8'); - // Format with prettier using the project's config so the output passes lint - const outputPath = join(PROJECT_ROOT, 'packages', 'core', 'src', 'types', 'spec.types.ts'); - const prettierConfig = await prettier.resolveConfig(outputPath); - const formatted = await prettier.format(fullContent, { ...prettierConfig, filepath: outputPath }); + console.log(`[${version}] Successfully updated packages/core/src/types/spec.types.${version}.ts`); +} + +function isSupportedVersion(value: string): value is SpecVersion { + return (SUPPORTED_VERSIONS as readonly string[]).includes(value); +} + +async function main() { + try { + // Usage: fetch-spec-types.ts [version] [sha] + // With no version, all supported versions are fetched at their latest upstream SHA. + const providedVersion = process.argv[2]; + const providedSHA = process.argv[3]; + + if (providedVersion === undefined) { + for (const version of SUPPORTED_VERSIONS) { + await updateSpecTypes(version); + } + return; + } - writeFileSync(outputPath, formatted, 'utf-8'); + if (!isSupportedVersion(providedVersion)) { + throw new Error(`Unsupported version "${providedVersion}". Supported versions: ${SUPPORTED_VERSIONS.join(', ')}`); + } - console.log('Successfully updated packages/core/src/types/spec.types.ts'); + await updateSpecTypes(providedVersion, providedSHA); } catch (error) { console.error('Error:', error instanceof Error ? error.message : String(error)); process.exit(1); diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index f4b7ce4213..f4fa0dd1d4 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -12,9 +12,9 @@ client: # --- Draft-spec scenarios (in `--suite draft`, also part of `--suite all`) --- - # SEP-2575 (request metadata / _meta envelope): client does not populate the - # _meta envelope or the MCP-Protocol-Version header semantics yet. - - request-metadata + # SEP-2575 (request-metadata) passes since the client 2026 mode landed: connect() + # negotiates via server/discover (retrying once on -32004) and stamps the _meta + # envelope + MCP-Protocol-Version header on every request. # SEP-2322 (multi-round-trip requests): client does not echo requestState / # handle IncompleteResult yet. - sep-2322-client-request-state @@ -60,9 +60,9 @@ client: server: # --- Draft-spec scenarios (in `--suite draft`; the default `active` suite is green) --- - # SEP-2575 (stateless HTTP / _meta envelope): server has no stateless mode, - # _meta-derived capabilities, error-code mappings, or server/discover yet. - - server-stateless + # SEP-2575 (server-stateless) passes since server/discover landed: the + # subscriptions/listen and capability-gating (-32003) checks it also bundles + # report SKIPPED until those features exist, which does not fail the scenario. # SEP-2322 (multi-round-trip requests / IncompleteResult): not implemented; # most scenarios currently fail early with "Session ID required" because the # fixture only runs in stateful mode. @@ -81,8 +81,11 @@ server: - caching # SEP-2243 (HTTP header standardization): -32001 HeaderMismatch handling and # case-insensitive/whitespace-trimmed header validation not implemented. + # (http-custom-header-server-validation is no longer listed: its stateless + # tools/list setup probe succeeds now, and with no x-mcp-header diagnostic + # tool in the fixture every check reports SKIPPED — the scenario re-arms + # when SEP-2243 support adds the custom-header tool.) - http-header-validation - - http-custom-header-server-validation # WARNING-only entries: these scenarios emit no FAILURE checks, only SHOULD-level # WARNINGs, but the expected-failures evaluator counts WARNINGs as failures. # SEP-2164: server returns -32002 without the requested URI in error.data. diff --git a/test/conformance/src/everythingClient.ts b/test/conformance/src/everythingClient.ts index 05103eb26d..8dd54cc4d9 100644 --- a/test/conformance/src/everythingClient.ts +++ b/test/conformance/src/everythingClient.ts @@ -16,9 +16,11 @@ import { Client, ClientCredentialsProvider, CrossAppAccessProvider, + DRAFT_PROTOCOL_VERSION, PrivateKeyJwtProvider, requestJwtAuthorizationGrant, - StreamableHTTPClientTransport + StreamableHTTPClientTransport, + SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/client'; import * as z from 'zod/v4'; @@ -144,6 +146,40 @@ async function runToolsCallClient(serverUrl: string): Promise { registerScenario('initialize', runBasicClient); registerScenario('tools_call', runToolsCallClient); +// ============================================================================ +// Request metadata scenario (SEP-2575: per-request _meta envelope + header) +// ============================================================================ + +async function runRequestMetadataClient(serverUrl: string): Promise { + // Listing per-request (non-stateful) revisions is the 2026-era opt-in: connect() + // then negotiates via server/discover and stamps the _meta envelope + the + // MCP-Protocol-Version header on every request. 'DRAFT-2026-v1' is the label the + // pinned conformance CLI (0.2.0-alpha.1) uses for the draft revision; the scenario + // rejects the first request with -32004 listing it, exercising the version retry. + const client = new Client( + { name: 'conformance-request-metadata', version: '1.0.0' }, + { + capabilities: {}, + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION, 'DRAFT-2026-v1'] + } + ); + + const transport = new StreamableHTTPClientTransport(new URL(serverUrl)); + + await client.connect(transport); + logger.debug('Connected on the per-request era; negotiated version:', client.getNegotiatedProtocolVersion()); + + // A post-connect request, so the scenario observes the envelope and header on a + // normal (non-discover) request at the selected version. + const result = await client.callTool({ name: 'test-tool', arguments: {} }); + logger.debug('Tool call result:', JSON.stringify(result)); + + await transport.close(); + logger.debug('Connection closed successfully'); +} + +registerScenario('request-metadata', runRequestMetadataClient); + // ============================================================================ // Auth scenarios - well-behaved client // ============================================================================ diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index f3925aeea8..48c534cdbb 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -9,10 +9,18 @@ import { randomUUID } from 'node:crypto'; +import { isJSONRPCRequest, isStatefulProtocolVersion } from '@modelcontextprotocol/core'; import { localhostHostValidation } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import type { CallToolResult, EventId, EventStore, GetPromptResult, ReadResourceResult, StreamId } from '@modelcontextprotocol/server'; -import { isInitializeRequest, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; +import { + DRAFT_PROTOCOL_VERSION, + isInitializeRequest, + McpServer, + PROTOCOL_VERSION_META_KEY, + ResourceTemplate, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/server'; import cors from 'cors'; import type { Request, Response } from 'express'; import express from 'express'; @@ -64,7 +72,8 @@ const TEST_IMAGE_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQ // Sample base64 encoded minimal WAV file for testing const TEST_AUDIO_BASE64 = 'UklGRiYAAABXQVZFZm10IBAAAAABAAEAQB8AAAB9AAACABAAZGF0YQIAAAA='; -// Function to create a new MCP server instance (one per session) +// Function to create a new MCP server instance (one per session, or one per +// request on the stateless path) function createMcpServer() { const mcpServer = new McpServer( { @@ -85,7 +94,13 @@ function createMcpServer() { }, logging: {}, completions: {} - } + }, + // Opt in to the draft (per-request) protocol revision: requests claiming + // it are served on the stateless dispatch path (SEP-2575 scenarios). + // The pinned conformance CLI (0.2.0-alpha.1) labels the draft revision + // 'DRAFT-2026-v1' rather than the draft schema's protocol version, so + // both labels are listed until the pin catches up. + supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION, 'DRAFT-2026-v1'] } ); @@ -597,6 +612,54 @@ function createMcpServer() { } ); + // SEP-2575: stream-discipline diagnostic tool. Emits request-scoped + // notifications that ride the originating response stream before the final + // response, so the stateless scenario can verify the stream carries only + // notifications/* frames and the response — never an independent + // server→client request (real elicitation is a backchannel feature and has + // no place on the per-request path). + mcpServer.registerTool( + 'test_streaming_elicitation', + { + description: 'Tests that streamed responses carry only notifications and the final response (SEP-2575)', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken: 'test_streaming_elicitation', progress: 1, total: 2 } + }); + await ctx.mcpReq.notify({ + method: 'notifications/progress', + params: { progressToken: 'test_streaming_elicitation', progress: 2, total: 2 } + }); + return { + content: [{ type: 'text', text: 'Streaming complete: emitted 2 request-scoped notifications' }] + }; + } + ); + + // SEP-2575: per-request logging diagnostic tool. Emits log messages through + // ctx.mcpReq.log at several severities; on the per-request path the SDK + // delivers them only when the request carried an + // io.modelcontextprotocol/logLevel _meta claim, so a call without one must + // produce no notifications/message frames. + mcpServer.registerTool( + 'test_logging_tool', + { + description: 'Emits log messages at several severities via the per-request logging API (SEP-2575)', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + await ctx.mcpReq.log('debug', 'debug-severity diagnostic message', 'test_logging_tool'); + await ctx.mcpReq.log('info', 'info-severity diagnostic message', 'test_logging_tool'); + await ctx.mcpReq.log('error', 'error-severity diagnostic message', 'test_logging_tool'); + return { + content: [{ type: 'text', text: 'Logging complete: emitted 3 log messages' }] + }; + } + ); + // SEP-1613: JSON Schema 2020-12 conformance test tool mcpServer.registerTool( 'json_schema_2020_12_tool', @@ -889,11 +952,51 @@ app.use( }) ); -// Handle POST requests - stateful mode +/** + * The protocol version a sessionless POST claims: the MCP-Protocol-Version + * header, falling back to the parsed body's request `_meta` — the same + * resolution the SDK transport applies for stateless routing. + */ +function claimedProtocolVersion(req: Request): string | undefined { + const headerVersion = req.headers['mcp-protocol-version']; + if (typeof headerVersion === 'string') { + return headerVersion; + } + const body: unknown = req.body; + if (isJSONRPCRequest(body)) { + const metaVersion = body.params?._meta?.[PROTOCOL_VERSION_META_KEY]; + if (typeof metaVersion === 'string') { + return metaVersion; + } + } + return undefined; +} + +// Handle POST requests - stateful mode, plus the stateless (per-request) path app.post('/mcp', async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; try { + // Stateless (per-request protocol revision) path: instance per request via + // the same factory. The SDK transport performs the routing, envelope + // acceptance, and version negotiation; the hosting layer only picks a + // fresh server, exactly like the documented getServer()-per-request + // stateless deployment pattern. + if (!sessionId) { + const claimedVersion = claimedProtocolVersion(req); + if (claimedVersion !== undefined && !isStatefulProtocolVersion(claimedVersion)) { + const mcpServer = createMcpServer(); + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + res.on('close', () => { + void transport.close(); + void mcpServer.close(); + }); + await mcpServer.connect(transport); + await transport.handleRequest(req, res, req.body); + return; + } + } + let transport: NodeStreamableHTTPServerTransport; if (sessionId && transports[sessionId]) { diff --git a/test/e2e/fixtures/stdio-server.ts b/test/e2e/fixtures/stdio-server.ts index f9ead4ee6a..2db7302489 100644 --- a/test/e2e/fixtures/stdio-server.ts +++ b/test/e2e/fixtures/stdio-server.ts @@ -5,16 +5,25 @@ * single `echo` tool, writes a readiness marker line to stderr once it is * serving, and — when E2E_IGNORE_SIGTERM=1 — keeps running after stdin EOF and * swallows SIGTERM so the client transport's shutdown escalation - * (stdin EOF → SIGTERM → SIGKILL) is observable. + * (stdin EOF → SIGTERM → SIGKILL) is observable. When E2E_LIST_DRAFT_VERSION=1 + * the server lists the draft protocol revision in supportedProtocolVersions so + * the hosting:routing tests can exercise stdio's stateless routing. */ /* eslint-disable unicorn/no-process-exit -- standalone spawned executable; exit codes are the behavior under test */ -import { McpServer } from '@modelcontextprotocol/server'; +import { DRAFT_PROTOCOL_VERSION, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; import { z } from 'zod/v4'; -const server = new McpServer({ name: 'stdio-echo-server', version: '1.0.0' }); +const server = new McpServer( + { name: 'stdio-echo-server', version: '1.0.0' }, + // E2E_LIST_DRAFT_VERSION=1: opt the server in to the draft protocol revision so the + // hosting:routing tests can observe stdio's stateless routing (drafts never enter the default list). + process.env.E2E_LIST_DRAFT_VERSION === '1' + ? { supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] } + : {} +); server.registerTool( 'echo', @@ -39,6 +48,31 @@ server.registerTool( } ); +// slow tool: emits one progress notification (the started signal), then waits for +// per-request cancellation. When the request's abort signal fires it reports the +// observation on stderr — the only channel allowed to carry anything for the +// request after a cancellation ("no further frames" is the behavior under test) — +// and returns; the transport suppresses the late response. +server.registerTool( + 'slow', + { + description: 'Runs until the request is cancelled; reports the observed abort on stderr.', + inputSchema: z.object({}) + }, + async (_args, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'slow', progress: 0 } }); + await new Promise(resolve => { + if (ctx.mcpReq.signal.aborted) { + resolve(); + } else { + ctx.mcpReq.signal.addEventListener('abort', () => resolve(), { once: true }); + } + }); + process.stderr.write(`[stdio-server] aborted:${String(ctx.mcpReq.id)}:${String(ctx.mcpReq.signal.aborted)}\n`); + return { content: [{ type: 'text', text: 'late' }] }; + } +); + if (process.env.E2E_IGNORE_SIGTERM === '1') { // Misbehaving-server mode: keep alive after stdin EOF via interval (load-bearing — without it the child exits on stdin EOF and SIGTERM never arrives) and ignore SIGTERM, so only SIGKILL can end the process. setInterval(() => {}, 1000); diff --git a/test/e2e/helpers/index.ts b/test/e2e/helpers/index.ts index 0fe566be8c..b1bff3ddff 100644 --- a/test/e2e/helpers/index.ts +++ b/test/e2e/helpers/index.ts @@ -16,10 +16,17 @@ import { PassThrough } from 'node:stream'; import type { Client } from '@modelcontextprotocol/client'; import { SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import type { EventStore, JSONRPCMessage, McpServer, Server } from '@modelcontextprotocol/server'; -import { InMemoryTransport, ReadBuffer, serializeMessage, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import { + DRAFT_PROTOCOL_VERSION, + InMemoryTransport, + ReadBuffer, + serializeMessage, + SUPPORTED_PROTOCOL_VERSIONS, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; -import type { Transport } from '../types.js'; +import type { SpecVersion, Transport } from '../types.js'; import { startLegacySseHost } from './sse-host.js'; import type { SnifferOptions } from './wire-sniffer.js'; import { sniffTransport } from './wire-sniffer.js'; @@ -84,6 +91,46 @@ export async function wire(transport: Transport, makeServer: ServerFactory, clie } } +/** + * The per-cell force-version knob: the `supportedProtocolVersions` list that pins a + * client/server pair to the given spec version. `'2026-07-28'` returns only the draft + * revision, so the pair can only meet on the per-request era (no initialize fallback); + * `'2025-11-25'` returns the SDK default list (initialize-era negotiation). + */ +export function protocolVersionsFor(specVersion: SpecVersion): string[] { + return specVersion === '2026-07-28' ? [DRAFT_PROTOCOL_VERSION] : [...SUPPORTED_PROTOCOL_VERSIONS]; +} + +export interface ConnectTapEntry { + direction: 'client-to-server' | 'server-to-client'; + message: JSONRPCMessage; +} + +/** + * Patch `client.connect` so the transport `wire()` hands it is tapped from the very + * first message — {@link tapWire} can only attach after connect, too late to observe + * the version-negotiation traffic (discover probe / initialize handshake). The tap + * stays attached for the connection's lifetime, so post-connect requests are recorded + * too. Call before `wire()`. + */ +export function tapConnect(client: Client): ConnectTapEntry[] { + const log: ConnectTapEntry[] = []; + const originalConnect = client.connect.bind(client); + client.connect = (clientTransport, options) => { + const originalSend = clientTransport.send.bind(clientTransport); + clientTransport.send = (message, sendOptions) => { + log.push({ direction: 'client-to-server', message }); + return originalSend(message, sendOptions); + }; + // Protocol.connect chains a pre-set onmessage, so inbound messages are logged before the client reacts to them. + clientTransport.onmessage = message => { + log.push({ direction: 'server-to-client', message }); + }; + return originalConnect(clientTransport, options); + }; + return log; +} + /** * Tap a connected client's transport so every JSON-RPC message crossing the * wire is recorded. `sent` = client→server, `received` = server→client. @@ -220,9 +267,11 @@ export function hostStateless(makeServer: ServerFactory): { handleRequest: HttpH // format (uses the SDK's `serializeMessage`/`ReadBuffer`), but over PassThrough // streams instead of a spawned process. Tests that specifically exercise spawn, // env, signals, or stderr must use the real `StdioClientTransport`. +// Exported for bodies that wire stdio without `wire()` (e.g. connect-failure +// cases, where `wire()` would leak its half-built host on the throw). // ─────────────────────────────────────────────────────────────────────────────── -function stdioClientOverPipes(serverStdout: NodeJS.ReadableStream, serverStdin: NodeJS.WritableStream) { +export function stdioClientOverPipes(serverStdout: NodeJS.ReadableStream, serverStdin: NodeJS.WritableStream) { const buf = new ReadBuffer(); return { onmessage: undefined as ((m: JSONRPCMessage) => void) | undefined, diff --git a/test/e2e/helpers/verifies.ts b/test/e2e/helpers/verifies.ts index bfcdc47216..a4e8ec288d 100644 --- a/test/e2e/helpers/verifies.ts +++ b/test/e2e/helpers/verifies.ts @@ -19,7 +19,7 @@ import { describe, test } from 'vitest'; import { REQUIREMENTS } from '../requirements.js'; import type { TestArgs } from '../types.js'; -import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS } from '../types.js'; +import { ALL_SPEC_VERSIONS, ALL_TRANSPORTS, BASELINE_SPEC_VERSION } from '../types.js'; type TestBody = (args: TestArgs) => Promise; @@ -34,9 +34,12 @@ function registerOne(id: string, fn: TestBody, opts?: { title?: string }): void if (req.deferred) throw new Error(`verifies('${id}'): requirement is deferred — drop the deferral or the test`); const transports = req.transports ?? ALL_TRANSPORTS; + // A requirement without an explicit addedInSpecVersion registers at the baseline revision + // only: its body drives the SDK's default (initialize-era) negotiation, and a later-revision + // label would claim coverage the body does not exercise (see BASELINE_SPEC_VERSION). const versions = ALL_SPEC_VERSIONS.filter( v => - (req.addedInSpecVersion === undefined || v >= req.addedInSpecVersion) && + (req.addedInSpecVersion === undefined ? v === BASELINE_SPEC_VERSION : v >= req.addedInSpecVersion) && (req.removedInSpecVersion === undefined || v < req.removedInSpecVersion) ); const cells = versions.flatMap(v => transports.map(t => [t, v] as const)); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index ea471a21fc..91578ddc9e 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -114,6 +114,20 @@ export const REQUIREMENTS: Record = { transports: STATEFUL_TRANSPORTS, note: 'Under stateless hosting each request is served by a new server instance, so state set up earlier in the session cannot be observed.' }, + 'protocol:envelope:ctx-version-readable': { + source: 'sdk', + behavior: + 'A request handler can read the protocol version governing the current request from its context (ctx.mcpReq.protocolVersion); on a 2025 connection it equals the initialize-negotiated version.', + transports: STATEFUL_TRANSPORTS, + note: "Groundwork for SEP-2575's per-request envelope. Today the version is sourced from the initialize handshake, so a stateless host (fresh server per request, no retained handshake) would read the pre-initialize default rather than the negotiated version." + }, + 'protocol:envelope:ctx-capabilities-readable': { + source: 'sdk', + behavior: + "A server request handler can read the calling client's declared capabilities and implementation info from its context (ctx.client.capabilities / ctx.client.info); on a 2025 connection these equal what the client sent at initialize.", + transports: STATEFUL_TRANSPORTS, + note: 'Per-request counterpart of Server.getClientCapabilities()/getClientVersion(); under the 2026 revision (no handshake) the per-request form becomes the only way to read these. The structural supersession is recorded when 2026 connections become possible. Stateless hosting serves each request from a fresh server that never processed initialize, so the handshake-sourced facts are not observable there.' + }, // Protocol primitives: cancellation, timeout, progress, errors, _meta @@ -435,9 +449,12 @@ export const REQUIREMENTS: Record = { behavior: 'Registering a tool with a name already in use is rejected at registration time.' }, 'typescript:mcpserver:tool:extra': { + removedInSpecVersion: '2026-07-28', + supersededBy: 'typescript:mcpserver:tool:extra-sessionless', source: 'sdk', behavior: - 'Tool handlers receive RequestHandlerExtra with sessionId, requestId, signal, sendNotification, and (when applicable) authInfo and requestInfo.' + 'Tool handlers receive RequestHandlerExtra with sessionId, requestId, signal, sendNotification, and (when applicable) authInfo and requestInfo.', + note: 'Partial removal: only sessionId is dropped in 2026 (sessionless); requestId/signal/sendNotification/authInfo/requestInfo persist. The successor asserts the same shape minus sessionId.' }, 'mcpserver:tool:handle-update': { transports: STATEFUL_TRANSPORTS, @@ -1127,9 +1144,12 @@ export const REQUIREMENTS: Record = { behavior: "_meta returned in a handler's result is delivered intact to the requesting client." }, 'protocol:request-id:unique': { + removedInSpecVersion: '2026-07-28', + supersededBy: 'protocol:request-id:outstanding-scope', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#requests', behavior: - 'Every request sent on a session carries a unique, non-null string or integer id; ids are never reused within the session.' + 'Every request sent on a session carries a unique, non-null string or integer id; ids are never reused within the session.', + note: 'Partial removal: the non-null unique-id rule persists in 2026, but the scope narrows from "within the session" to "among outstanding requests" (draft basic/index — an id may be reused once its response has been received).' }, 'protocol:notifications:no-response': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic#notifications', @@ -1196,58 +1216,68 @@ export const REQUIREMENTS: Record = { // Hosting: session lifecycle 'hosting:session:cors-expose': { + removedInSpecVersion: '2026-07-28', source: 'sdk', behavior: 'CORS configuration exposes the Mcp-Session-Id header so browser clients can read it.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).' }, 'hosting:session:create': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'An initialize POST without a session id creates a session and returns Mcp-Session-Id in the response headers.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).' }, 'hosting:session:delete': { + removedInSpecVersion: '2026-07-28', + supersededBy: 'hosting:sessionless:delete-405', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'DELETE with a valid Mcp-Session-Id terminates the session.', transports: ['streamableHttp'], note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'hosting:session:id-charset': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'Generated Mcp-Session-Id values contain only visible ASCII characters.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).' }, 'hosting:session:isolation': { + removedInSpecVersion: '2026-07-28', source: 'sdk', behavior: 'Each session gets its own server instance; closing one session does not affect others.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).' }, 'hosting:session:missing-id': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'A non-initialize POST without Mcp-Session-Id in stateful mode returns 400.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).' }, 'hosting:session:reinitialize': { + removedInSpecVersion: '2026-07-28', source: 'sdk', behavior: 'A second initialize on an already-initialized session transport is rejected.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).' }, 'hosting:session:reuse': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: "A POST carrying a valid Mcp-Session-Id routes to that session's transport with state preserved.", transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).' }, 'hosting:session:unknown-id': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'A POST, GET, or DELETE with an unknown Mcp-Session-Id returns 404.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.', + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).', knownFailures: [ { note: "The SDK's documented hosting pattern rejects unknown session ids with 400 at the app level (see src/examples servers); the transport's own validateSession 404 is never reached, while the spec requires 404." @@ -1278,11 +1308,12 @@ export const REQUIREMENTS: Record = { note: 'This exercises the HTTP hosting layer and stateless mode; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'hosting:session:delete-cancels-inflight': { + removedInSpecVersion: '2026-07-28', source: 'sdk', behavior: "DELETE on a session aborts every in-flight request handler's RequestHandlerExtra.signal; their POST-initiated SSE streams close without a JSON-RPC response being written.", transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).' }, 'hosting:stateless:get-delete-405': { source: 'sdk', @@ -1396,46 +1427,53 @@ export const REQUIREMENTS: Record = { // Hosting: resumability 'typescript:hosting:resume:bad-event-id': { + removedInSpecVersion: '2026-07-28', source: 'sdk', behavior: 'Last-Event-ID that cannot be mapped to a stream returns 400; replay failure returns 500.', transports: ['streamableHttp'], - note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither.' + note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'hosting:resume:buffered-replay': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery', behavior: 'Notifications emitted while no client is connected are replayed in order on reconnect.', transports: ['streamableHttp'], - note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither.' + note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'hosting:resume:close-stream': { + removedInSpecVersion: '2026-07-28', source: 'sdk', behavior: 'Handlers can close an SSE stream cleanly when an event store is configured.', transports: ['streamableHttp'], - note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither.' + note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'hosting:resume:event-ids': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery', behavior: 'With an event store configured, every SSE event carries an id field.', transports: ['streamableHttp'], - note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither.' + note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'hosting:resume:priming': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server', behavior: 'With eventStore + new protocol, POST SSE streams begin with a priming event carrying the configured retry: interval.', transports: ['streamableHttp'], - note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither.' + note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'hosting:resume:replay': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery', behavior: 'GET with Last-Event-ID replays stored events for that stream after the given id.', transports: ['streamableHttp'], - note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither.' + note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'hosting:resume:stream-scoped': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery', behavior: 'Replay via Last-Event-ID returns only messages from the stream that event id belongs to.', transports: ['streamableHttp'], - note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither.' + note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, // Hosting: HTTP semantics @@ -1447,11 +1485,13 @@ export const REQUIREMENTS: Record = { note: 'These test the per-session host layer (via hostPerSession helper); stateless transport tests use hostStateless which has different request routing.' }, 'hosting:http:batch': { + removedInSpecVersion: '2026-07-28', + supersededBy: 'hosting:sessionless:no-batching', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server', behavior: 'POST body is a single JSON-RPC message; batched arrays are accepted only as an SDK back-compat affordance for pre-2025-06-18 clients (spec forbids batches).', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Partial removal: the single-message rule persists in 2026 (draft transports — POST body MUST be a single JSON-RPC message); only the legacy batch-array affordance is dropped. The 2026 successor asserts batch ⇒ 400.' }, 'hosting:http:content-type-415': { source: 'sdk', @@ -1536,18 +1576,320 @@ export const REQUIREMENTS: Record = { note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'hosting:http:standalone-sse': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server', behavior: 'GET opens a standalone SSE stream that receives server-initiated messages.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: the standalone GET SSE stream is removed; in 2026 the only server to client paths are request-scoped (MRTR) and subscriptions/listen (SEP-2575/2567).' }, 'hosting:http:standalone-sse-no-response': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server', behavior: 'The standalone GET SSE stream carries server requests and notifications but never a JSON-RPC response, except when resuming a prior request stream.', transports: ['streamableHttp'], + note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: the standalone GET SSE stream is removed; in 2026 the only server to client paths are request-scoped (MRTR) and subscriptions/listen (SEP-2575/2567).' + }, + // Hosting: stateless routing + dispatch (per-request protocol revisions, SEP-2575/SEP-2567) + + 'hosting:routing:session-id-never-stateless': { + addedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'A request carrying an Mcp-Session-Id header always takes the session path, regardless of the protocol version it claims: session validation still applies (valid session served as today, unknown session 404) and the request is never routed to the stateless dispatch path.', + transports: ['streamableHttp'], + note: 'Routing rule: the session header is checked before any version logic — sessions are version-locked at initialize, so a claimed draft version never bypasses session validation. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'hosting:routing:stateless-only-configured': { + addedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'A sessionless request claiming a non-stateful protocol version is handled on the stateless dispatch path only when the server has opted in by listing a non-stateful version in supportedProtocolVersions and a server is connected; otherwise existing behavior applies byte-identically (today: the unsupported-version 400 over HTTP, normal onmessage delivery over stdio).', + transports: ['streamableHttp', 'stdio'], + note: 'Routing rule: non-stateful versions stay out of the default supported list, so existing deployments see no behavior change. The streamableHttp cell exercises both halves against the WebStandard transport (the routed half is proven by the distinctive envelope -32602 of the stateless path); the stdio cell proves the not-listed half against a spawned fixture server (a draft _meta claim the server does not list is served on the existing path), with the routed half on stdio carried by protocol:stateless:request-served. Version resolution reads the first request of a batch body; batch rejection on the stateless path is pinned at the transport layer and its e2e row lands with the sessionless-invariants sweep.' + }, + 'protocol:stateless:envelope-required': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/index#meta', + behavior: + 'Under a per-request (non-stateful) protocol revision, a request whose _meta is missing or lacks any of io.modelcontextprotocol/protocolVersion, clientInfo, or clientCapabilities is rejected with -32602 (Invalid params), the request id echoed.', + transports: ['streamableHttp', 'stdio'], + note: 'Conformance: sep-2575-request-meta-invalid-{missing-meta,missing-protocol-version,missing-client-info,missing-client-capabilities} (server-stateless scenario). The streamableHttp cell drives raw fetch against a connected WebStandard transport and covers all four cases; the stdio cell drives hand-built messages against an in-process StdioServerTransport wired to a real server, covering the two cases that can route at all — on stdio the _meta protocolVersion claim is the routing signal, so a request without one is indistinguishable from stateful-era traffic and is served on the existing path.' + }, + 'protocol:stateless:version-unsupported': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle#protocol-version-negotiation', + behavior: + 'A request claiming a non-stateful protocol version the opted-in server does not support is answered with -32004 (UnsupportedProtocolVersionError) carrying data.supported (the full supported list of the server) and data.requested, the request id echoed; over HTTP the status is 400.', + transports: ['streamableHttp', 'stdio'], + note: 'Conformance: sep-2575-server-unsupported-version-error + sep-2575-http-server-unsupported-version-400 (server-stateless scenario).' + }, + 'protocol:stateless:removed-methods': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header', + behavior: + 'Under a per-request protocol revision the removed RPCs (initialize, ping, logging/setLevel, resources/subscribe, resources/unsubscribe) and unknown methods are answered with -32601 (Method not found), the request id echoed; over HTTP the status is 404. Handlers registered for stateful-era traffic never serve these methods on the stateless path.', + transports: ['streamableHttp', 'stdio'], + note: 'Conformance: sep-2575-http-server-method-not-found-404{,-initialize,-ping,-logging-setlevel,-resources-subscribe,-resources-unsubscribe} (server-stateless scenario).' + }, + 'protocol:stateless:request-served': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle', + behavior: + 'A request carrying the complete _meta envelope and a supported non-stateful protocol version is served end-to-end on the stateless dispatch path: registered handlers run and the result is returned with the request id, without any initialize handshake.', + transports: ['streamableHttp', 'stdio'], + note: 'The heart of SEP-2575: every request is self-contained. The streamableHttp cell drives raw fetch against a connected WebStandard transport; the stdio cell spawns the fixture server (E2E_LIST_DRAFT_VERSION=1) as a real child process and drives hand-built messages, proving the same without any process-level handshake.' + }, + 'protocol:stateless:ctx-meta-sourced': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle', + behavior: + 'On the stateless dispatch path the handler context is sourced from the _meta envelope of the request itself: ctx.mcpReq.protocolVersion is the claimed version and ctx.client.info/ctx.client.capabilities are the envelope clientInfo/clientCapabilities — never inherited from an initialize handshake or a previous request, and ctx.sessionId is undefined.', + transports: ['streamableHttp', 'stdio'], + note: 'Lifecycle rule: servers MUST NOT rely on prior requests to establish context (capabilities, protocol version, client identity). The streamableHttp body proves non-inheritance on a session-mode transport whose handshake state is populated by a real initialize before the stateless request arrives.' + }, + 'protocol:stateless:per-request-loglevel': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/utilities/logging#per-request-log-level', + behavior: + 'Under a per-request protocol revision, a request carrying io.modelcontextprotocol/logLevel in _meta receives notifications/message for handler log emissions at or above the claimed level on the originating response stream, in order, before the final response; emissions below the claimed level are not delivered. A request claiming an unrecognized logLevel value is rejected with -32602 (Invalid params), the request id echoed.', + transports: ['streamableHttp', 'stdio'], + note: "The logLevel _meta claim replaces logging/setLevel (a removed RPC on this path). The -32602 for unrecognized levels is the logging spec's Error Handling SHOULD, enforced by the same envelope acceptance that rejects malformed _meta. Conformance covers only the no-claim case (sep-2575-server-no-log-without-loglevel); the unrecognized-level rejection has no check — gap recorded in the build log." + }, + 'protocol:stateless:no-log-without-loglevel': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/utilities/logging#per-request-log-level', + behavior: + 'Under a per-request protocol revision, a request whose _meta carries no io.modelcontextprotocol/logLevel claim produces no notifications/message even when the handler emits log messages — including immediately after another request on the same connection claimed a level: the claim governs exactly its own request and is never stored.', + transports: ['streamableHttp', 'stdio'], + note: 'Conformance: sep-2575-server-no-log-without-loglevel (server-stateless scenario). Per-request isolation is pinned by claiming debug on a preceding request: over HTTP the unclaimed request is answered as plain application/json (the lazy SSE stream never opens because nothing was emitted); on stdio the message after the unclaimed request is its response directly.' + }, + 'hosting:http:version-header-meta-mismatch': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports#protocol-version-header', + behavior: + 'On Streamable HTTP, a request whose MCP-Protocol-Version header does not match the io.modelcontextprotocol/protocolVersion value in the body _meta is rejected with 400 Bad Request and a HeaderMismatch JSON-RPC error (-32001), the request id echoed.', + transports: ['streamableHttp'], + note: 'Conformance: sep-2575-http-server-header-mismatch-400 (server-stateless scenario). This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'hosting:http:stateless-notification-202': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports#sending-messages-1', + behavior: + 'On the stateless HTTP path, a POST whose body is a single JSON-RPC notification is answered with 202 Accepted and no body.', + transports: ['streamableHttp'], note: 'This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, + 'hosting:http:stateless-response-stream': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports#receiving-messages-1', + behavior: + 'On the stateless HTTP path the server answers a request either with a single application/json object, or — when the handler emits request-scoped notifications — with an SSE stream carrying those notifications in order followed by the final JSON-RPC response, which terminates the stream. Notifications always relate to the originating request and no Mcp-Session-Id header is ever emitted.', + transports: ['streamableHttp'], + note: 'Conformance (adjacent): sep-2575-http-server-no-independent-requests-on-stream. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + + // Sessionless invariants (per-request protocol revisions, SEP-2567) + + 'hosting:sessionless:get-405': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports#sending-messages-1', + behavior: + 'A GET claiming a per-request protocol revision is answered 405 Method Not Allowed (Allow: POST): the MCP endpoint is POST-only under these revisions — there is no standalone server-to-client stream to open. (The deleted standalone-GET-stream rule that response streams never carry independent server requests is absorbed here: no GET stream can exist at all.)', + transports: ['streamableHttp'], + note: 'Distinct from hosting:stateless:get-delete-405, which covers the 2025 stateless MODE (sessionIdGenerator: undefined, initialize-era traffic); this entry covers the routed per-request path, where the 405 is unconditional. The body also asserts the DELETE half and the batch rejection — it is shared with hosting:sessionless:delete-405 and hosting:sessionless:no-batching. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'hosting:sessionless:delete-405': { + addedInSpecVersion: '2026-07-28', + supersedes: ['hosting:session:delete'], + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports#sending-messages-1', + behavior: + 'Under a per-request protocol revision there is no session to terminate: a DELETE claiming such a revision is answered 405 Method Not Allowed.', + transports: ['streamableHttp'], + note: 'SEP-2567: sessions are deleted, so the DELETE leg of the endpoint loses its meaning. The cited body is shared with hosting:sessionless:get-405. This exercises the HTTP hosting layer; the matrix transport arg is ignored.' + }, + 'hosting:sessionless:no-batching': { + addedInSpecVersion: '2026-07-28', + supersedes: ['hosting:http:batch'], + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports#sending-messages-1', + behavior: + 'A POST claiming a per-request protocol revision whose body is a JSON-RPC batch array is rejected with 400 (-32600 Invalid Request): the 2025 batch back-compat affordance does not exist on this path — the body is exactly one message.', + transports: ['streamableHttp'], + note: 'SEP-2575 (R-2575-14). The cited body is shared with hosting:sessionless:get-405. This exercises the HTTP hosting layer; the matrix transport arg is ignored.' + }, + 'hosting:sessionless:no-session-header': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports', + behavior: + 'Per-request-revision traffic is sessionless on both sides: the server never emits an Mcp-Session-Id header on any stateless-path response — including on a transport configured with a sessionIdGenerator, whose 2025-era session behavior is unaffected — and requests are served without one.', + transports: ['streamableHttp'], + note: 'SEP-2567. The sessionIdGenerator half pins that a dual-stack deployment cannot leak session headers into per-request traffic (the config is simply inert there). The body is shared with client-transport:http:no-session-header, driving a real 2026-mode client against this server with a header-injecting fetch. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'client-transport:http:no-session-header': { + addedInSpecVersion: '2026-07-28', + supersedes: [ + 'client-transport:http:session-stored', + 'typescript:client-transport:http:session-id-property', + 'typescript:client-transport:http:session-id-option' + ], + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports', + behavior: + 'A client transport pinned to a per-request protocol revision never sends Mcp-Session-Id on any request and ignores one if a server (or intermediary) emits it: no storage, no replay, and .sessionId stays undefined.', + transports: ['streamableHttp'], + note: 'SEP-2567 backward compatibility. The superseded typescript: entries are the SDK API surfaces of session-id storage and replay. The cited body is shared with hosting:sessionless:no-session-header. This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored.' + }, + 'hosting:sessionless:per-request-auth': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle', + behavior: + 'On the stateless dispatch path, authorization context is per-request: ctx.http.authInfo carries exactly the auth info validated for the request being served, and a request without credentials sees none — never auth inherited from an earlier request on the same transport.', + transports: ['streamableHttp'], + note: 'SEP-2575: every request is independently authenticated; the SDK surface for that rule is the per-request authInfo plumbing (rejecting unauthenticated requests is the auth middleware layer, exercised by the hosting-auth tests). The body is shared with typescript:mcpserver:tool:extra-sessionless: the same ctx-reporting tool proves both the auth scoping and the sessionless handler-context shape. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'typescript:mcpserver:tool:extra-sessionless': { + addedInSpecVersion: '2026-07-28', + supersedes: ['typescript:mcpserver:tool:extra'], + source: 'sdk', + behavior: + 'On the per-request (sessionless) path, tool handlers receive the RequestHandlerExtra shape minus the session identifier: sessionId is undefined while requestId, signal, sendNotification, and (when applicable) authInfo persist.', + transports: ['streamableHttp'], + note: 'SEP-2567: the 2026 sibling of the RequestHandlerExtra shape requirement — only sessionId is dropped. The cited body is shared with hosting:sessionless:per-request-auth. The matrix transport arg is ignored.' + }, + 'protocol:stateless:list-connection-independent': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle', + behavior: + 'Under a per-request protocol revision, list results (tools/list) are connection-invariant: the same request through two independently connected transports (each serving a fresh server instance from the same factory) yields identical results.', + transports: ['streamableHttp'], + note: 'SEP-2567 (R-2567-1); also covers the no-connection-affinity rule (R-2575-8) — same requests over one connection or two produce identical results, which is exactly what the two-transport probe asserts. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'protocol:stateless:list-no-side-effects': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle', + behavior: + 'Under a per-request protocol revision, list results are unchanged by intervening calls on the same connection: tools/list before and after a tools/call yields identical results.', + transports: ['streamableHttp'], + note: 'SEP-2567 (R-2567-2). The body is shared with protocol:request-id:outstanding-scope: the second list reuses the JSON-RPC id of the first — already settled — list request, so the same probe pins the re-scoped id-uniqueness rule. This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + 'protocol:request-id:outstanding-scope': { + addedInSpecVersion: '2026-07-28', + supersedes: ['protocol:request-id:unique'], + source: 'https://modelcontextprotocol.io/specification/draft/basic#requests', + behavior: + 'Under a per-request protocol revision, request-id uniqueness is scoped to outstanding requests: ids never collide among in-flight requests, and a request reusing the id of an already-completed request is processed normally.', + transports: ['streamableHttp'], + note: 'SEP-2567 (R-2567-3): the session-scoped never-reuse rule narrows to in-flight requests only (the SDK client, with its monotonic counter, trivially satisfies it — the server-side acceptance of a reused settled id is the consequential half). The cited body is shared with protocol:stateless:list-no-side-effects. The matrix transport arg is ignored.' + }, + 'protocol:stateless:stdio-cancellation': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/utilities/cancellation', + behavior: + 'On the stdio stateless path, a notifications/cancelled naming an in-flight stateless request aborts that dispatch: the handler observes its per-request signal fire, and the server writes no further frames for that request id — neither notifications nor the late response. The cancellation is consumed by the stateless path (never delivered to the connection-scoped protocol instance), and unrelated traffic on the same pipe is unaffected.', + transports: ['stdio'], + note: 'SEP-2575 cancellation MUST for self-contained requests; over HTTP the equivalent is closing the response stream (the request signal is the HTTP request lifetime). Spawned-child variant only: the abort observation is reported on stderr, the one channel allowed to carry anything for the request after cancellation.' + }, + 'hosting:http:stateless-no-sse-event-ids': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/transports#receiving-messages-1', + behavior: + 'SSE responses on the stateless HTTP path carry no SSE id: lines, even when the transport is configured with an eventStore: per-request revisions have no stream resumability, so no resumption ids may be offered.', + transports: ['streamableHttp'], + note: 'SEP-2575 removes Last-Event-ID resumability for per-request revisions (SEP-2567 re-scopes event-id uniqueness in a way that contradicts the removal — flagged upstream; the SDK emits no ids on this path under either reading). This exercises the HTTP hosting layer; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + }, + + // Discovery (server/discover, draft spec) + + 'discover:result': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/discover#response', + behavior: + 'A server answers server/discover with supportedVersions (exactly the protocol versions it is configured to support — the same list an UnsupportedProtocolVersionError reports in error.data.supported), its capabilities, its serverInfo, and its instructions when configured.', + transports: ['streamableHttp', 'stdio'], + note: 'Conformance: sep-2575-server-implements-discover + sep-2575-server-unsupported-version-error (server-stateless scenario; the latter asserts error.data.supported is contained in the discover supportedVersions). Driven on the stateless dispatch path with the full _meta envelope: the streamableHttp cell drives raw Request/Response against a connected WebStandard transport, the stdio cell hand-built framed messages against an in-process StdioServerTransport.' + }, + 'discover:pre-initialize': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/server/discover#when-to-call', + behavior: + 'A server answers server/discover before any initialize handshake, including on the stateful-era path without a _meta envelope: the probe is the first request on the wire and no initialize precedes it.', + transports: ['inMemory', 'stdio'], + note: 'The spec back-compat probe: a client that supports both eras SHOULD send server/discover first and fall back to the initialize handshake when the server answers -32601. The server here is NOT opted into any non-stateful version, so the probe is served on the existing stateful-era path pre-handshake, like ping. Stateful streamable HTTP hosting rejects every POST before an initialize-established session, so the probe cannot reach the server there; the stateless HTTP path is covered by discover:result.' + }, + 'discover:subscription-capabilities-withheld': { + addedInSpecVersion: '2026-07-28', + source: 'sdk', + behavior: + 'server/discover advertises the declared capabilities minus the subscription-delivery flags (prompts.listChanged, resources.listChanged, resources.subscribe, tools.listChanged) while subscriptions/listen is unimplemented: discovery never advertises notification delivery no RPC can honor. The capabilities themselves (prompts/resources/tools presence) stay advertised, and the initialize result on the same server still carries the declared flags.', + transports: ['streamableHttp', 'stdio'], + note: 'Conformance: sep-2575-server-sends-{prompts,tools}-list-changed-on-subscription (server-stateless scenario) treat a discover result advertising listChanged without delivering on a listen stream as a SHOULD violation, so the flags are withheld from discover (not from the initialize result, whose stateful-era notification flow delivers them). When subscriptions/listen lands, the flags are advertised again and this entry is superseded.' + }, + + // Client 2026 mode: dual-era connect + per-request envelope stamping (draft spec) + + 'lifecycle:connect:per-request-era': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle#protocol-version-negotiation', + behavior: + 'A client that lists a per-request (non-stateful) protocol version connects to a server listing the same version without any initialize handshake: connect() sends server/discover, selects the mutual per-request version, populates the server facts (capabilities, serverInfo, instructions, negotiated version) from the discover result, and subsequent requests are served. No initialize request and no notifications/initialized appears on the wire.', + transports: ['stdio', 'streamableHttp', 'streamableHttpStateless'], + note: 'Listing a non-stateful version in supportedProtocolVersions is the opt-in on both sides — no separate flag. inMemory and sse are excluded: those transports have no per-request routing seam, so a server cannot serve the stateless dispatch path on them.' + }, + 'lifecycle:connect:per-request-era-fallback': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle#backward-compatibility-with-initialization-based-versions', + behavior: + "A client that lists a per-request protocol version still connects to servers that do not speak one: the server/discover probe is answered with -32601 (no discovery), rejected with an HTTP 400 carrying no correlatable JSON-RPC error (legacy header/session validation), or answered with initialize-era versions only — and in every case the client falls back to the initialize handshake, completing on the newest mutually-supported stateful version. After the fallback no _meta envelope is stamped on the connection's requests.", + note: 'The draft back-compat detection, exercised against a legacy-shaped server (no discover handler): stdio/inMemory/sse fall back on -32601, per-session streamable HTTP hosting on the 400 pre-session rejection, stateless hosting on the 400 header-validation rejection.' + }, + 'lifecycle:connect:per-request-version-retry': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle#protocol-version-negotiation', + behavior: + "When the server/discover probe is answered with -32004 (UnsupportedProtocolVersionError), the client retries exactly once with a mutually supported version from error.data.supported and completes the per-request-era connect at that version; the retried request's _meta (and HTTP header) claim the new version, and the client never falls back to initialize for a -32004.", + transports: ['stdio', 'streamableHttp', 'streamableHttpStateless'], + note: 'Conformance: sep-2575-client-retry-supported-version (request-metadata scenario). The -32004 is produced by the server-side stateless dispatch, which exists on the stdio and streamable HTTP routing seams only.' + }, + 'lifecycle:connect:discover-requires-opt-in': { + source: 'sdk', + behavior: + 'A client whose supportedProtocolVersions contains no per-request (non-stateful) version never probes: connect() sends initialize as its first request and no server/discover appears on the wire, even when the server lists a per-request version.', + note: 'Pins the opt-in boundary: by default (SUPPORTED_PROTOCOL_VERSIONS) the connect flow is byte-identical to the 2025 line.' + }, + 'protocol:envelope:client-stamps-requests': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/index#meta', + behavior: + 'On a per-request-era connection, every request the client sends — including the server/discover probe itself — carries the complete _meta envelope: io.modelcontextprotocol/protocolVersion (the governing version), io.modelcontextprotocol/clientInfo, and io.modelcontextprotocol/clientCapabilities, declared per request. On HTTP the MCP-Protocol-Version header is sent on every POST and matches the _meta claim. Caller-supplied _meta keys are preserved alongside the envelope.', + transports: ['stdio', 'streamableHttp', 'streamableHttpStateless'], + note: 'Conformance: sep-2575-client-populates-meta + sep-2575-http-client-sends-version-header + sep-2575-http-version-header-matches-meta (request-metadata scenario). The streamableHttp cells hand-wire a header-recording fetch around the hosting helpers so the header obligation is asserted alongside the body envelope.' + }, + + // Cross-era compatibility (2025 initialize era ↔ 2026 per-request era) + + 'lifecycle:compat:mixed-era-one-pipe': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle#backward-compatibility-with-initialization-based-versions', + behavior: + 'A dual-stack stdio server (listing both initialize-era and per-request protocol versions) serves both eras interleaved on a single pipe: an enveloped per-request request is served on the stateless dispatch path before any handshake, a legacy initialize then establishes a 2025-era session on the same pipe, un-enveloped requests are served on that session, and a further enveloped request is again served per-request — each request observing its own era in the handler context, with neither era disturbing the other.', + transports: ['stdio'], + note: "stdio routes per message on the _meta protocolVersion claim, so one long-lived process serves both eras concurrently — the draft back-compat story for spawned servers. HTTP needs no one-pipe equivalent: every POST routes independently (lifecycle:compat:era-parity drives both eras through one per-session deployment). The body is raw-wire (hand-framed messages against an in-process StdioServerTransport); the matrix transport arg is the cell's transport." + }, + 'lifecycle:compat:modern-only-client-hard-fail': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle#backward-compatibility-with-initialization-based-versions', + behavior: + 'A client listing only per-request (non-stateful) protocol versions fails connect() against a server that does not share one — with a clear error and never an initialize handshake: whether discovery is answered with initialize-era versions only, the probe is rejected with -32601 (no discovery), or it dies with an uncorrelatable HTTP 400, no initialize request ever appears on the wire.', + note: 'The hard-fail half of the back-compat detection: the initialize fallback requires the client to list a stateful version — connect() must not invent an initialize attempt with versions the server did not agree to. The -32004 path (server speaks a per-request revision, just not this one) is owned by lifecycle:connect:per-request-version-retry.' + }, + 'lifecycle:compat:era-parity': { + addedInSpecVersion: '2026-07-28', + source: 'https://modelcontextprotocol.io/specification/draft/basic/lifecycle#backward-compatibility-with-initialization-based-versions', + behavior: + 'One dual-stack server configuration yields identical feature results across eras: the same factory serving an initialize-era client (2025 session) and a per-request-era client (2026, no handshake) returns deep-equal tools/list and tools/call results — including structured output — while each connection negotiates its own era.', + transports: ['stdio', 'streamableHttp', 'streamableHttpStateless'], + note: 'The parity contract behind the dual-stack rollout: opting a deployment into a per-request revision must not change what 2025 clients receive, and 2026 clients must see the same feature surface. On the HTTP transports both clients are driven through the SAME hosting deployment (per-session hosting serves the initialize-era session and the sessionless per-request POSTs side by side — the dual-stack-server-answers-initialize coverage); on stdio each client gets its own pipe from the shared factory (the one-pipe interleaving is lifecycle:compat:mixed-era-one-pipe). inMemory and sse are excluded: no per-request routing seam exists on them.' + }, + 'hosting:express-app-helper': { transports: ['streamableHttp'], source: 'sdk', @@ -1587,10 +1929,11 @@ export const REQUIREMENTS: Record = { 'removed in v2: the bundled authorize and token endpoints no longer exist, so redirect_uri binding across authorize and token cannot be exercised.' }, 'hosting:session:post-termination-404': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'After a session is terminated, any further request carrying that session ID is answered with 404 Not Found.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.', + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).', knownFailures: [ { note: 'The documented per-session hosting pattern (hostPerSession) removes the transport from the session map on DELETE via onsessionclosed and answers any later request carrying the stale Mcp-Session-Id with 400 at the app level, so the spec-required 404 is never produced.' @@ -1640,17 +1983,19 @@ export const REQUIREMENTS: Record = { // Client transport: streamableHttp 'client-transport:http:404-surfaces': { + removedInSpecVersion: '2026-07-28', source: 'sdk', behavior: 'A 404 (session expired) on a request surfaces as an error to the caller.', transports: ['streamableHttp'], - note: 'Session-id continuity testing requires the per-session host (404 is session-not-found).' + note: 'Session-id continuity testing requires the per-session host (404 is session-not-found). Removed in 2026 with no replacement: session expiry and client-side session termination are 2025-only; a 2026 client never carries a session id (SEP-2567).' }, 'client-transport:http:session-404-reinitialize': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'A 404 in response to a request carrying a session ID makes the client start a new session with a fresh InitializeRequest and no session ID attached.', transports: ['streamableHttp'], - note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.', + note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: session expiry and client-side session termination are 2025-only; a 2026 client never carries a session id (SEP-2567).', knownFailures: [ { note: 'On a 404 for an existing session the transport throws StreamableHTTPError (streamableHttp.ts:551) and never re-initializes — no session recovery is attempted.' @@ -1658,10 +2003,11 @@ export const REQUIREMENTS: Record = { ] }, 'client-transport:http:accept-header-get': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server', behavior: 'The client GET to the MCP endpoint includes an Accept header listing text/event-stream.', transports: ['streamableHttp'], - note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: the standalone GET SSE stream is removed; in 2026 the only server to client paths are request-scoped (MRTR) and subscriptions/listen (SEP-2575/2567).' }, 'client-transport:http:accept-header-post': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server', @@ -1685,7 +2031,7 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'Caller-supplied headers are sent on every POST, GET, and DELETE to the MCP endpoint.', transports: ['streamableHttp'], - note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Not removed in 2026: caller-supplied headers survive; the 2026 endpoint is POST-only, so the GET and DELETE legs of this behavior become vacuous (SEP-2567/2575).' }, 'client-transport:http:json-response-parsed': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server', @@ -1718,17 +2064,19 @@ export const REQUIREMENTS: Record = { note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'client-transport:http:reconnect-get': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery', behavior: 'A standalone GET SSE stream that errors is reconnected with the Last-Event-ID of the last received event.', transports: ['streamableHttp'], - note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'client-transport:http:reconnect-post-priming': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server', behavior: 'A POST-initiated SSE stream that errors before delivering its response is reconnected only if a priming event (an event carrying an ID) was received on it.', transports: ['streamableHttp'], - note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'client-transport:http:reconnect-retry-value': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server', @@ -1737,28 +2085,33 @@ export const REQUIREMENTS: Record = { note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'client-transport:http:resume-stream-api': { + removedInSpecVersion: '2026-07-28', source: 'sdk', behavior: 'The client can capture a resumption token, reconnect with the same session id, and receive the notifications it missed.', transports: ['streamableHttp'], - note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'client-transport:http:session-stored': { + removedInSpecVersion: '2026-07-28', + supersededBy: 'client-transport:http:no-session-header', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'The Mcp-Session-Id returned by initialize is stored by the client transport and sent on every subsequent request.', transports: ['streamableHttp'], note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'client-transport:http:sse-405-tolerated': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#listening-for-messages-from-the-server', behavior: 'Opening the standalone GET SSE stream tolerates a 405 response without failing the connection.', transports: ['streamableHttp'], - note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: the standalone GET SSE stream is removed; in 2026 the only server to client paths are request-scoped (MRTR) and subscriptions/listen (SEP-2575/2567).' }, 'client-transport:http:terminate-405-ok': { + removedInSpecVersion: '2026-07-28', source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'Session termination succeeds without error if the server answers 405 (termination unsupported).', transports: ['streamableHttp'], - note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the StreamableHTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: session expiry and client-side session termination are 2025-only; a 2026 client never carries a session id (SEP-2567).' }, 'client-transport:http:body-stream-error-preserved': { source: 'sdk', @@ -2040,11 +2393,12 @@ export const REQUIREMENTS: Record = { note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' }, 'flow:multi-client:stateful-isolation': { + removedInSpecVersion: '2026-07-28', transports: ['streamableHttp'], source: 'sdk', behavior: 'Independent clients connected to one stateful server each receive a distinct session and only the notifications produced by their own requests.', - note: 'This is an HTTP-specific flow requiring session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This is an HTTP-specific flow requiring session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567); in 2026 isolation is structural — notifications are scoped to the response stream of the request that produced them.' }, 'flow:oauth:authorization-code-roundtrip': { transports: ['streamableHttp'], @@ -2054,17 +2408,19 @@ export const REQUIREMENTS: Record = { note: 'End-to-end authorization-code journey (401 → discovery → DCR → redirect → finishAuth → authorized reconnect); the individual mechanisms are covered by the client-auth:* requirements. This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'flow:resume:tool-call-resumption-token': { + removedInSpecVersion: '2026-07-28', transports: ['streamableHttp'], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#resumability-and-redelivery', behavior: 'A tool call interrupted mid-stream is transparently resumed by the client transport using the last-seen event id, delivering only the remaining notifications and the final result.', - note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither.' + note: 'Resumability requires a per-session transport with an EventStore and a standalone GET stream; stateless hosting has neither. Removed in 2026 with no replacement: resumable SSE streams via Last-Event-ID are not supported in 2026 (SEP-2575).' }, 'flow:session:terminate-then-reconnect': { + removedInSpecVersion: '2026-07-28', transports: ['streamableHttp'], source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#session-management', behavior: 'After terminating a session, a fresh connection obtains a new session id and operations succeed.', - note: 'This is an HTTP-specific flow requiring session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This is an HTTP-specific flow requiring session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567).' }, 'flow:tool-result:resource-link-follow': { transports: STATEFUL_TRANSPORTS, @@ -2325,6 +2681,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), whichever side lists newer revisions: a client listing the draft revision first still requests the newest stateful version in its initialize request, a server listing the draft revision first still answers with the newest mutually-supported stateful version, and no revision newer than 2025-11-25 appears anywhere in the initialize exchange.', + note: 'Protocol revisions after 2025-11-25 are stateless and negotiate per-request via server/discover (see lifecycle:connect:per-request-era). A client that lists one probes with server/discover before falling back to initialize, so the guard is observed per side against a counterpart that does not share a per-request version.' + }, 'lifecycle:capability:list-empty-when-not-advertised': { source: 'sdk', behavior: @@ -2354,18 +2716,22 @@ export const REQUIREMENTS: Record = { ] }, 'typescript:client-transport:http:session-id-property': { + removedInSpecVersion: '2026-07-28', + supersededBy: 'client-transport:http:no-session-header', source: 'sdk', behavior: 'StreamableHTTPClientTransport exposes the negotiated session id via a readable .sessionId property after initialization so consumers can persist and display it.', transports: ['streamableHttp'], - note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. A 2026 client stores no session id, so .sessionId stays undefined.' }, 'typescript:client-transport:http:session-id-option': { + removedInSpecVersion: '2026-07-28', + supersededBy: 'client-transport:http:no-session-header', source: 'sdk', behavior: 'A sessionId passed to the StreamableHTTPClientTransport constructor is sent as the Mcp-Session-Id header from the first request onwards.', transports: ['streamableHttp'], - note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the Streamable HTTP client transport directly; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. A 2026 client never sends Mcp-Session-Id, so the constructor option is inert.' }, 'client-transport:http:reconnect-failure-onerror': { source: 'sdk', @@ -2437,11 +2803,12 @@ export const REQUIREMENTS: Record = { 'Tool metadata supplied to registerTool (title, annotations, _meta, icons) is returned verbatim in tools/list results to connected clients.' }, 'hosting:session:lifecycle-callbacks': { + removedInSpecVersion: '2026-07-28', source: 'sdk', behavior: 'StreamableHTTPServerTransport invokes onsessioninitialized with the new session id after initialization and onsessionclosed when the client issues DELETE, allowing hosts to maintain a session-to-transport map.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' + note: 'This exercises the HTTP hosting layer and session management; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs. Removed in 2026 with no replacement: protocol sessions and the Mcp-Session-Id header are deleted (SEP-2567); the onsessioninitialized/onsessionclosed callbacks have no 2026 home.' }, // Legacy SSE 'transport:sse:server-transport': { diff --git a/test/e2e/scenarios/compat.test.ts b/test/e2e/scenarios/compat.test.ts new file mode 100644 index 0000000000..7c093cb185 --- /dev/null +++ b/test/e2e/scenarios/compat.test.ts @@ -0,0 +1,256 @@ +/** + * Cross-era compatibility bodies (2025 initialize era ↔ 2026 per-request era): + * mixed-era traffic interleaved on a single stdio pipe, the modern-only-client + * hard failure (connect() never invents an initialize attempt), and + * feature-result parity across the eras of one dual-stack server + * configuration. + */ + +import { PassThrough } from 'node:stream'; + +import { Client, SSEClientTransport, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/server'; +import { + DRAFT_PROTOCOL_VERSION, + InMemoryTransport, + isJSONRPCRequest, + LATEST_PROTOCOL_VERSION, + McpServer, + ReadBuffer, + serializeMessage, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { hostPerSession, hostStateless, stdioClientOverPipes, tapConnect, wire } from '../helpers/index.js'; +import { startLegacySseHost } from '../helpers/sse-host.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const DUAL_STACK = [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION]; +const META_VERSION = 'io.modelcontextprotocol/protocolVersion'; + +/** A dual-stack server with an era-reporting tool plus text and structured tools for parity probes. */ +function makeDualStackServer(): McpServer { + const s = new McpServer({ name: 'compat-server', version: '1.0.0' }, { supportedProtocolVersions: DUAL_STACK }); + s.registerTool('era', { inputSchema: z.object({}) }, (_args, ctx) => ({ + content: [{ type: 'text', text: ctx.mcpReq.protocolVersion }] + })); + s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + s.registerTool( + 'add', + { inputSchema: z.object({ a: z.number(), b: z.number() }), outputSchema: z.object({ sum: z.number() }) }, + ({ a, b }) => ({ content: [{ type: 'text', text: String(a + b) }], structuredContent: { sum: a + b } }) + ); + return s; +} + +verifies('lifecycle:compat:mixed-era-one-pipe', async () => { + const input = new PassThrough(); + const output = new PassThrough(); + const server = makeDualStackServer(); + await server.connect(new StdioServerTransport(input, output)); + + const buf = new ReadBuffer(); + const received: JSONRPCMessage[] = []; + output.on('data', chunk => { + buf.append(chunk as Buffer); + let message: JSONRPCMessage | null; + while ((message = buf.readMessage())) received.push(message); + }); + let read = 0; + const send = (message: JSONRPCMessage) => void input.push(serializeMessage(message)); + const next = async () => + await vi.waitFor(() => { + if (received.length <= read) throw new Error('no message yet'); + return received[read++]!; + }); + + const envelope = { + [META_VERSION]: DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'mixed-era-client', version: '0' }, + 'io.modelcontextprotocol/clientCapabilities': {} + }; + const eraCall = (id: number, meta?: Record): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: meta ? { name: 'era', arguments: {}, _meta: meta } : { name: 'era', arguments: {} } + }); + + try { + // 1. A per-request request is served before any handshake exists on the pipe. + send(eraCall(1, envelope)); + expect(await next()).toMatchObject({ id: 1, result: { content: [{ type: 'text', text: DRAFT_PROTOCOL_VERSION }] } }); + + // 2. A legacy initialize handshake on the SAME pipe establishes a 2025-era session. + send({ + jsonrpc: '2.0', + id: 2, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'mixed-era-legacy-client', version: '0' } + } + }); + expect(await next()).toMatchObject({ id: 2, result: { protocolVersion: LATEST_PROTOCOL_VERSION } }); + send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + // 3. An un-enveloped request rides that session and observes the initialize-era version. + send(eraCall(3)); + expect(await next()).toMatchObject({ id: 3, result: { content: [{ type: 'text', text: LATEST_PROTOCOL_VERSION }] } }); + + // 4. A further enveloped request is served per-request again — the session never bleeds in. + send(eraCall(4, envelope)); + expect(await next()).toMatchObject({ id: 4, result: { content: [{ type: 'text', text: DRAFT_PROTOCOL_VERSION }] } }); + } finally { + await server.close(); + } +}); + +verifies('lifecycle:compat:modern-only-client-hard-fail', async ({ transport }: TestArgs) => { + // Two server shapes that share no per-request version: an SDK server with the default + // (initialize-era) versions, whose built-in discovery answers with stateful versions only, + // and a true legacy shape with no discovery handler at all (-32601 / pre-dispatch HTTP 400). + for (const removeDiscover of [false, true]) { + const makeServer = () => { + const s = new McpServer({ name: 'initialize-era-server', version: '0' }); + s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + if (removeDiscover) { + s.server.removeRequestHandler('server/discover'); + } + return s; + }; + const client = new Client({ name: 'modern-only-client', version: '0' }, { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION] }); + const log = tapConnect(client); + const expectHardFail = async (connecting: Promise) => + await expect(connecting).rejects.toThrow(/No mutually supported protocol version|initialize cannot negotiate/); + + // Wired by hand rather than through wire(): connect() is expected to throw, and + // wire() would leak its half-built host on the way out. + switch (transport) { + case 'inMemory': { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + const server = makeServer(); + await server.connect(serverTx); + try { + await expectHardFail(client.connect(clientTx)); + } finally { + await server.close(); + } + break; + } + case 'stdio': { + const c2s = new PassThrough(); + const s2c = new PassThrough(); + const server = makeServer(); + await server.connect(new StdioServerTransport(c2s, s2c)); + try { + await expectHardFail(client.connect(stdioClientOverPipes(s2c, c2s))); + } finally { + await server.close(); + } + break; + } + case 'streamableHttp': + case 'streamableHttpStateless': { + const handle = transport === 'streamableHttpStateless' ? hostStateless(makeServer) : hostPerSession(makeServer); + const fetchFn = (url: URL | string, init?: RequestInit) => handle.handleRequest(new Request(url, init)); + try { + await expectHardFail( + client.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: fetchFn })) + ); + } finally { + await handle.close(); + } + break; + } + case 'sse': { + const host = await startLegacySseHost(makeServer); + try { + await expectHardFail(client.connect(new SSEClientTransport(host.url))); + } finally { + await host.close(); + } + break; + } + } + + // The hard-fail invariant: no initialize attempt was invented on the way down. + const methods = log + .filter(entry => entry.direction === 'client-to-server') + .map(entry => entry.message) + .filter(message => isJSONRPCRequest(message)) + .map(request => request.method); + expect(methods).not.toContain('initialize'); + } +}); + +verifies('lifecycle:compat:era-parity', async ({ transport }: TestArgs) => { + const legacy = new Client({ name: 'era-2025-client', version: '0' }); + const modern = new Client({ name: 'era-2026-client', version: '0' }, { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION] }); + + if (transport === 'stdio') { + // Each client gets its own pipe from the shared factory (the one-pipe interleaving + // is lifecycle:compat:mixed-era-one-pipe). + const legacyHost = await wire('stdio', makeDualStackServer, legacy); + const modernHost = await wire('stdio', makeDualStackServer, modern); + try { + await assertEraParity(legacy, modern, transport); + } finally { + await modernHost[Symbol.asyncDispose](); + await legacyHost[Symbol.asyncDispose](); + } + return; + } + + // One hosting deployment serves both eras: the initialize-era session and the + // sessionless per-request POSTs go through the same handle. + const handle = transport === 'streamableHttpStateless' ? hostStateless(makeDualStackServer) : hostPerSession(makeDualStackServer); + const fetchFn = (url: URL | string, init?: RequestInit) => handle.handleRequest(new Request(url, init)); + try { + await legacy.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: fetchFn })); + await modern.connect(new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: fetchFn })); + await assertEraParity(legacy, modern, transport); + } finally { + await legacy.close(); + await modern.close(); + await handle.close(); + } +}); + +/** Each connection negotiated its own era, and the feature results are era-invariant. */ +async function assertEraParity(legacy: Client, modern: Client, transport: TestArgs['transport']): Promise { + expect(legacy.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + expect(modern.getNegotiatedProtocolVersion()).toBe(DRAFT_PROTOCOL_VERSION); + + // The era is request-observable on the server. On 2025 stateless hosting the fresh + // per-request instance has no handshake, so the legacy connection's handlers see the + // SDK's pre-initialize default version — handshake-sourced ctx observability on that + // hosting pattern is out of scope here (see protocol:envelope:ctx-version-readable). + if (transport !== 'streamableHttpStateless') { + const legacyEra = await legacy.callTool({ name: 'era', arguments: {} }); + expect(legacyEra.content).toEqual([{ type: 'text', text: LATEST_PROTOCOL_VERSION }]); + } + const modernEra = await modern.callTool({ name: 'era', arguments: {} }); + expect(modernEra.content).toEqual([{ type: 'text', text: DRAFT_PROTOCOL_VERSION }]); + + // …while list and call results are identical across eras. + const [legacyList, modernList] = [await legacy.listTools(), await modern.listTools()]; + expect(modernList.tools).toEqual(legacyList.tools); + + const addArgs = { name: 'add', arguments: { a: 19, b: 23 } }; + const [legacyAdd, modernAdd] = [await legacy.callTool(addArgs), await modern.callTool(addArgs)]; + expect(modernAdd).toEqual(legacyAdd); + expect(modernAdd.structuredContent).toEqual({ sum: 42 }); + + const echoArgs = { name: 'echo', arguments: { text: 'parity' } }; + expect(await modern.callTool(echoArgs)).toEqual(await legacy.callTool(echoArgs)); +} diff --git a/test/e2e/scenarios/discover.test.ts b/test/e2e/scenarios/discover.test.ts new file mode 100644 index 0000000000..425227f8c6 --- /dev/null +++ b/test/e2e/scenarios/discover.test.ts @@ -0,0 +1,273 @@ +/** + * Self-contained test bodies for the server side of `server/discover` + * (draft-spec discovery): the built-in handler's response contents, its + * availability before any initialize handshake, and the withholding of + * subscription-delivery capability flags while `subscriptions/listen` is + * unimplemented. + * + * The streamableHttp cells drive raw Request/Response against WebStandard + * transports connected directly on the stateless dispatch path; the stdio + * cells drive hand-built newline-framed messages against an in-process + * {@link StdioServerTransport}; the inMemory cells drive the raw linked + * transport pair on the stateful-era path (no envelope), where discovery + * must be answered before the handshake. + */ + +import { PassThrough } from 'node:stream'; + +import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/server'; +import { + DRAFT_PROTOCOL_VERSION, + InMemoryTransport, + LATEST_PROTOCOL_VERSION, + McpServer, + ReadBuffer, + serializeMessage, + Server, + SUPPORTED_PROTOCOL_VERSIONS, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { expect, vi } from 'vitest'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** The slice of Server/McpServer the raw wires need. */ +interface ConnectableServer { + connect(transport: Transport): Promise; + close(): Promise; +} + +const baseHeaders = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' +}; + +const draftHeaders = { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION }; + +/** The complete per-request `_meta` envelope the draft protocol revision requires. */ +const envelope = (overrides?: Record) => ({ + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'discover-client', version: '1.0.0' }, + 'io.modelcontextprotocol/clientCapabilities': {}, + ...overrides +}); + +/** + * Connects the given server to a session-less WebStandard transport. + * JSON responses are enabled so the stateful-era contrast probes (which would + * default to SSE shaping) parse as plain bodies; the stateless dispatch path + * shapes by behavior, not by this option. + */ +async function connectHttp(server: ConnectableServer): Promise { + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); + await server.connect(tx); + return tx; +} + +const post = (tx: WebStandardStreamableHTTPServerTransport, body: unknown, headers: Record = draftHeaders) => + tx.handleRequest(new Request('http://in-process/mcp', { method: 'POST', headers, body: JSON.stringify(body) })); + +interface RawWire { + send: (message: JSONRPCMessage) => void; + next: () => Promise; + close: () => Promise; +} + +/** + * In-process stdio wiring: the given server connected to a StdioServerTransport + * over PassThrough pipes; messages are hand-built and framed exactly as on the + * real wire (the SDK's serializeMessage/ReadBuffer framing). + */ +async function connectStdio(server: ConnectableServer): Promise { + const input = new PassThrough(); + const output = new PassThrough(); + await server.connect(new StdioServerTransport(input, output)); + + const buf = new ReadBuffer(); + const received: JSONRPCMessage[] = []; + output.on('data', chunk => { + buf.append(chunk as Buffer); + let message: JSONRPCMessage | null; + while ((message = buf.readMessage())) received.push(message); + }); + + let read = 0; + return { + send: message => void input.push(serializeMessage(message)), + next: async () => + await vi.waitFor(() => { + if (received.length <= read) throw new Error('no message yet'); + return received[read++]!; + }), + close: () => server.close() + }; +} + +/** Raw driving of the client half of a linked in-memory transport pair. */ +async function connectInMemory(server: ConnectableServer): Promise { + const [clientTx, serverTx] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTx); + + const received: JSONRPCMessage[] = []; + clientTx.onmessage = message => void received.push(message); + await clientTx.start(); + + let read = 0; + return { + send: message => void clientTx.send(message), + next: async () => + await vi.waitFor(() => { + if (received.length <= read) throw new Error('no message yet'); + return received[read++]!; + }), + close: () => server.close() + }; +} + +/** Asserts the message is a result response echoing the request id and returns its result. */ +function expectResult(message: unknown, id: number): Record { + expect(message).toMatchObject({ jsonrpc: '2.0', id }); + const result = (message as { result?: unknown }).result; + if (result === undefined) { + throw new Error(`Expected a result response, got: ${JSON.stringify(message)}`); + } + return result as Record; +} + +verifies('discover:result', async ({ transport }: TestArgs) => { + // A strict subset of the SDK default plus the draft opt-in, so the response + // provably reflects the configuration rather than the built-in default. + const CONFIGURED_VERSIONS = [LATEST_PROTOCOL_VERSION, DRAFT_PROTOCOL_VERSION]; + const SERVER_INFO = { name: 'discover-result-server', version: '3.2.1' }; + const CAPABILITIES = { logging: {}, completions: {} }; + const INSTRUCTIONS = 'Discovery scenario server instructions.'; + const makeServer = () => + new Server(SERVER_INFO, { + capabilities: CAPABILITIES, + instructions: INSTRUCTIONS, + supportedProtocolVersions: CONFIGURED_VERSIONS + }); + + const discoverRequest: JSONRPCRequest = { jsonrpc: '2.0', id: 201, method: 'server/discover', params: { _meta: envelope() } }; + // The same probe claiming an unknown version: the -32004 error's + // data.supported must be the very list discover advertises. + const unsupportedRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: 301, + method: 'server/discover', + params: { _meta: envelope({ 'io.modelcontextprotocol/protocolVersion': 'v999.0.0' }) } + }; + + const assertDiscoverResult = (result: Record) => { + expect(result['supportedVersions']).toEqual(CONFIGURED_VERSIONS); + expect(result['capabilities']).toEqual(CAPABILITIES); + expect(result['serverInfo']).toEqual(SERVER_INFO); + expect(result['instructions']).toBe(INSTRUCTIONS); + }; + const expectedVersionError = { + jsonrpc: '2.0', + id: 301, + error: { code: -32_004, data: { supported: CONFIGURED_VERSIONS, requested: 'v999.0.0' } } + }; + + if (transport === 'stdio') { + const stdio = await connectStdio(makeServer()); + try { + stdio.send(discoverRequest); + assertDiscoverResult(expectResult(await stdio.next(), 201)); + stdio.send(unsupportedRequest); + expect(await stdio.next()).toMatchObject(expectedVersionError); + } finally { + await stdio.close(); + } + return; + } + + const tx = await connectHttp(makeServer()); + try { + const res = await post(tx, discoverRequest); + expect(res.status).toBe(200); + assertDiscoverResult(expectResult(await res.json(), 201)); + + // Header and _meta agree on the unknown version (a disagreement would be -32001). + const errorRes = await post(tx, unsupportedRequest, { ...baseHeaders, 'mcp-protocol-version': 'v999.0.0' }); + expect(errorRes.status).toBe(400); + expect(await errorRes.json()).toMatchObject(expectedVersionError); + } finally { + await tx.close(); + } +}); + +verifies('discover:pre-initialize', async ({ transport }: TestArgs) => { + // Default configuration: no draft opt-in, so the probe is served on the + // stateful-era path — before any initialize handshake, like ping. + const makeServer = () => new McpServer({ name: 'discover-preinit-server', version: '0.0.1' }); + // The pre-envelope probe form: no params at all. + const probe: JSONRPCRequest = { jsonrpc: '2.0', id: 1, method: 'server/discover' }; + + const wire = transport === 'stdio' ? await connectStdio(makeServer()) : await connectInMemory(makeServer()); + try { + wire.send(probe); + const result = expectResult(await wire.next(), 1); + expect(result['supportedVersions']).toEqual(SUPPORTED_PROTOCOL_VERSIONS); + expect(result['capabilities']).toEqual({}); + expect(result['serverInfo']).toEqual({ name: 'discover-preinit-server', version: '0.0.1' }); + expect('instructions' in result).toBe(false); + } finally { + await wire.close(); + } +}); + +verifies('discover:subscription-capabilities-withheld', async ({ transport }: TestArgs) => { + const DECLARED = { + logging: {}, + prompts: { listChanged: true }, + resources: { subscribe: true, listChanged: true }, + tools: { listChanged: true } + }; + const makeServer = () => + new Server( + { name: 'discover-flags-server', version: '1.0.0' }, + { capabilities: DECLARED, supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] } + ); + + const discoverRequest: JSONRPCRequest = { jsonrpc: '2.0', id: 7, method: 'server/discover', params: { _meta: envelope() } }; + // The subscription-delivery flags are withheld; the capabilities themselves stay advertised. + const expectedDiscoverCapabilities = { logging: {}, prompts: {}, resources: {}, tools: {} }; + // Contrast on the same server: the initialize result still carries the + // declared flags — the stateful-era notification flow delivers them. + const initializeRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: 8, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'c', version: '0' } } + }; + + if (transport === 'stdio') { + const stdio = await connectStdio(makeServer()); + try { + stdio.send(discoverRequest); + expect(expectResult(await stdio.next(), 7)['capabilities']).toEqual(expectedDiscoverCapabilities); + stdio.send(initializeRequest); + expect(expectResult(await stdio.next(), 8)['capabilities']).toEqual(DECLARED); + } finally { + await stdio.close(); + } + return; + } + + const tx = await connectHttp(makeServer()); + try { + const res = await post(tx, discoverRequest); + expect(res.status).toBe(200); + expect(expectResult(await res.json(), 7)['capabilities']).toEqual(expectedDiscoverCapabilities); + + const initializeRes = await post(tx, initializeRequest, baseHeaders); + expect(initializeRes.status).toBe(200); + expect(expectResult(await initializeRes.json(), 8)['capabilities']).toEqual(DECLARED); + } finally { + await tx.close(); + } +}); diff --git a/test/e2e/scenarios/dual-era-connect.test.ts b/test/e2e/scenarios/dual-era-connect.test.ts new file mode 100644 index 0000000000..1541fe5600 --- /dev/null +++ b/test/e2e/scenarios/dual-era-connect.test.ts @@ -0,0 +1,232 @@ +/** + * Self-contained test bodies for the client side of the per-request protocol + * era (SEP-2575 + SEP-2567): the discovery-based connect flow (no initialize + * handshake), the `_meta` envelope + version header stamped on every request, + * the -32004 version retry, the back-compat fallback to initialize against + * servers that do not speak a per-request version, and the opt-in boundary. + * + * Connect-time traffic is observed with {@link tapConnect} (attached before + * `wire()`); the HTTP stamping cells hand-wire a header-recording fetch around + * the hosting helpers so the `MCP-Protocol-Version` header obligation is + * asserted alongside the body envelope. + */ + +import type { JSONRPCMessage } from '@modelcontextprotocol/client'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { + DRAFT_PROTOCOL_VERSION, + isJSONRPCNotification, + isJSONRPCRequest, + LATEST_PROTOCOL_VERSION, + McpServer, + SUPPORTED_PROTOCOL_VERSIONS +} from '@modelcontextprotocol/server'; +import { expect } from 'vitest'; +import { z } from 'zod/v4'; + +import type { ConnectTapEntry } from '../helpers/index.js'; +import { hostPerSession, hostStateless, protocolVersionsFor, tapConnect, wire } from '../helpers/index.js'; +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +const META_VERSION = 'io.modelcontextprotocol/protocolVersion'; +const META_CLIENT_INFO = 'io.modelcontextprotocol/clientInfo'; +const META_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities'; + +/** The client→server requests recorded by {@link tapConnect}, in wire order. */ +function sentRequests(log: ConnectTapEntry[]) { + return log + .filter(entry => entry.direction === 'client-to-server') + .map(entry => entry.message) + .filter(message => isJSONRPCRequest(message)); +} + +/** A server opted in to the given versions, with a tool reporting the ctx protocol version. */ +function makeVersionReportingServer(supportedProtocolVersions: string[], instructions?: string): McpServer { + const s = new McpServer({ name: 'dual-era-server', version: '3.1.4' }, { supportedProtocolVersions, instructions }); + s.registerTool('report-version', { inputSchema: z.object({}) }, (_args, ctx) => ({ + content: [{ type: 'text', text: ctx.mcpReq.protocolVersion }] + })); + return s; +} + +verifies('lifecycle:connect:per-request-era', async ({ transport }: TestArgs) => { + const versions = protocolVersionsFor('2026-07-28'); + const INSTRUCTIONS = 'Per-request era server instructions.'; + const client = new Client({ name: 'dual-era-client', version: '0.0.1' }, { supportedProtocolVersions: versions }); + const log = tapConnect(client); + + await using _ = await wire(transport, () => makeVersionReportingServer(versions, INSTRUCTIONS), client); + + // Discovery negotiated the connection: no initialize handshake anywhere on the wire. + const methods = sentRequests(log).map(request => request.method); + expect(methods).toContain('server/discover'); + expect(methods).not.toContain('initialize'); + expect( + log.some( + entry => + isJSONRPCNotification(entry.message) && + (entry.message as JSONRPCMessage & { method: string }).method === 'notifications/initialized' + ) + ).toBe(false); + + // The server facts come from the discover result. + expect(client.getNegotiatedProtocolVersion()).toBe(DRAFT_PROTOCOL_VERSION); + expect(client.getServerVersion()).toEqual({ name: 'dual-era-server', version: '3.1.4' }); + expect(client.getInstructions()).toBe(INSTRUCTIONS); + // registerTool declares tools.listChanged, but discover withholds the subscription-delivery flags. + expect(client.getServerCapabilities()).toEqual({ tools: {} }); + + // Requests are served end-to-end, and the server reads the selected version from the request itself. + const result = await client.callTool({ name: 'report-version', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: DRAFT_PROTOCOL_VERSION }]); +}); + +verifies('protocol:envelope:client-stamps-requests', async ({ transport }: TestArgs) => { + const versions = protocolVersionsFor('2026-07-28'); + const client = new Client({ name: 'stamping-client', version: '2.7.1' }, { supportedProtocolVersions: versions }); + const expectEnvelope = (request: { method: string; params?: { _meta?: Record } }) => { + const meta = request.params?._meta; + expect(meta?.[META_VERSION], request.method).toBe(DRAFT_PROTOCOL_VERSION); + expect(meta?.[META_CLIENT_INFO], request.method).toEqual({ name: 'stamping-client', version: '2.7.1' }); + expect(meta?.[META_CAPABILITIES], request.method).toEqual({}); + }; + + if (transport === 'stdio') { + const log = tapConnect(client); + await using _ = await wire(transport, () => makeVersionReportingServer(versions), client); + + // Caller-supplied _meta keys survive alongside the envelope. + await client.callTool({ name: 'report-version', arguments: {}, _meta: { 'com.example/custom': 'kept' } }); + + const requests = sentRequests(log); + expect(requests.length).toBeGreaterThanOrEqual(2); // the probe + the tool call + for (const request of requests) expectEnvelope(request as Parameters[0]); + const toolCall = requests.find(request => request.method === 'tools/call'); + expect(toolCall?.params?._meta?.['com.example/custom']).toBe('kept'); + return; + } + + // HTTP cells: hand-wired hosting with a recording fetch, so the MCP-Protocol-Version + // header on every POST is observable next to the body it accompanies. + const handle = + transport === 'streamableHttpStateless' + ? hostStateless(() => makeVersionReportingServer(versions)) + : hostPerSession(() => makeVersionReportingServer(versions)); + const recorded: Array<{ headerVersion: string | null; body: JSONRPCMessage }> = []; + const fetchFn = async (url: URL | string, init?: RequestInit) => { + const request = new Request(url, init); + recorded.push({ + headerVersion: request.headers.get('mcp-protocol-version'), + body: JSON.parse(await request.clone().text()) as JSONRPCMessage + }); + return handle.handleRequest(request); + }; + + const tx = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: fetchFn }); + try { + await client.connect(tx); + await client.callTool({ name: 'report-version', arguments: {}, _meta: { 'com.example/custom': 'kept' } }); + + const requests = recorded.filter(({ body }) => isJSONRPCRequest(body)); + expect(requests.length).toBeGreaterThanOrEqual(2); // the probe + the tool call + for (const { headerVersion, body } of requests) { + const request = body as { method: string; params?: { _meta?: Record } }; + expectEnvelope(request); + // Every POST carries the header, and it matches the _meta claim. + expect(headerVersion, request.method).toBe(DRAFT_PROTOCOL_VERSION); + } + const toolCall = requests.find(({ body }) => (body as { method?: string }).method === 'tools/call'); + expect((toolCall?.body as { params?: { _meta?: Record } }).params?._meta?.['com.example/custom']).toBe('kept'); + } finally { + await client.close(); + await handle.close(); + } +}); + +verifies('lifecycle:connect:per-request-version-retry', async ({ transport }: TestArgs) => { + // The server supports a different per-request revision than the client's first choice; + // the client's second listed revision is the mutual one. + const RETRY_VERSION = '2026-e2e-retry'; + const serverVersions = [...SUPPORTED_PROTOCOL_VERSIONS, RETRY_VERSION]; + const client = new Client( + { name: 'retry-client', version: '0.0.1' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, RETRY_VERSION] } + ); + const log = tapConnect(client); + + await using _ = await wire(transport, () => makeVersionReportingServer(serverVersions), client); + + // Exactly one retry: two probes on the wire, first claiming the preferred version, + // the second the mutually supported one from error.data.supported. Never initialize. + const requests = sentRequests(log); + const probes = requests.filter(request => request.method === 'server/discover'); + expect(probes).toHaveLength(2); + expect(probes[0]!.params?._meta?.[META_VERSION]).toBe(DRAFT_PROTOCOL_VERSION); + expect(probes[1]!.params?._meta?.[META_VERSION]).toBe(RETRY_VERSION); + expect(requests.map(request => request.method)).not.toContain('initialize'); + + expect(client.getNegotiatedProtocolVersion()).toBe(RETRY_VERSION); + + // Subsequent requests claim the retried version, end to end. + const result = await client.callTool({ name: 'report-version', arguments: {} }); + expect(result.content).toEqual([{ type: 'text', text: RETRY_VERSION }]); + const toolCall = sentRequests(log).find(request => request.method === 'tools/call'); + expect(toolCall?.params?._meta?.[META_VERSION]).toBe(RETRY_VERSION); +}); + +verifies('lifecycle:connect:per-request-era-fallback', async ({ transport }: TestArgs) => { + // A legacy-shaped server: stateful versions only, and no server/discover handler at all. + // Depending on the transport the probe dies differently (-32601 from dispatch on + // stdio/inMemory/sse; HTTP 400 from session or header validation on streamable HTTP), + // and the client must fall back to initialize in every case. + const makeServer = () => { + const s = new McpServer({ name: 'legacy-server', version: '0.0.9' }); + s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + s.server.removeRequestHandler('server/discover'); + return s; + }; + const client = new Client( + { name: 'fallback-client', version: '0.0.1' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS] } + ); + const log = tapConnect(client); + + await using _ = await wire(transport, makeServer, client); + + // The probe went out first; the handshake followed and completed on the newest stateful version. + const methods = sentRequests(log).map(request => request.method); + expect(methods.indexOf('server/discover')).toBeGreaterThanOrEqual(0); + expect(methods.indexOf('initialize')).toBeGreaterThan(methods.indexOf('server/discover')); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + expect(client.getServerVersion()).toEqual({ name: 'legacy-server', version: '0.0.9' }); + + // The fallback connection is a plain 2025-era connection: no envelope on its requests. + const result = await client.callTool({ name: 'echo', arguments: { text: 'after fallback' } }); + expect(result.content).toEqual([{ type: 'text', text: 'after fallback' }]); + const toolCall = sentRequests(log).find(request => request.method === 'tools/call'); + expect(toolCall?.params?._meta?.[META_VERSION]).toBeUndefined(); + expect(toolCall?.params?._meta?.[META_CLIENT_INFO]).toBeUndefined(); + expect(toolCall?.params?._meta?.[META_CAPABILITIES]).toBeUndefined(); +}); + +verifies('lifecycle:connect:discover-requires-opt-in', async ({ transport }: TestArgs) => { + // The server lists the draft revision; the client does not. No probe may appear: + // the opt-in is the client's own version list, not the server's capabilities. + const makeServer = () => + new McpServer( + { name: 'opted-in-server', version: '0.0.1' }, + { supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] } + ); + const client = new Client({ name: 'default-client', version: '0.0.1' }); + const log = tapConnect(client); + + await using _ = await wire(transport, makeServer, client); + + const methods = sentRequests(log).map(request => request.method); + expect(methods).not.toContain('server/discover'); + expect(methods[0]).toBe('initialize'); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); +}); diff --git a/test/e2e/scenarios/handler-context.test.ts b/test/e2e/scenarios/handler-context.test.ts index 81c5a776ba..079bf2b701 100644 --- a/test/e2e/scenarios/handler-context.test.ts +++ b/test/e2e/scenarios/handler-context.test.ts @@ -1,8 +1,9 @@ /** * Self-contained test bodies for the ServerContext conveniences handed to * request handlers: `ctx.mcpReq.log()`, `ctx.mcpReq.elicitInput()`, - * `ctx.mcpReq.requestSampling()`, and — under HTTP hosting — `ctx.http.req` - * exposing the incoming request's Fetch Headers. + * `ctx.mcpReq.requestSampling()`, the per-request envelope facts + * (`ctx.mcpReq.protocolVersion`, `ctx.client.*`), and — under HTTP hosting — + * `ctx.http.req` exposing the incoming request's Fetch Headers. * * Each body builds its own server (via factory) and client, wires them with * {@link wire} (or hosts directly with {@link hostPerSession} where the HTTP @@ -10,8 +11,15 @@ */ import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; -import type { CreateMessageRequest, ElicitRequest, ElicitRequestFormParams, LoggingLevel } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; +import type { + ClientCapabilities, + CreateMessageRequest, + ElicitRequest, + ElicitRequestFormParams, + Implementation, + LoggingLevel +} from '@modelcontextprotocol/server'; +import { LATEST_PROTOCOL_VERSION, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server'; import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; @@ -19,6 +27,13 @@ import { hostPerSession, wire } from '../helpers/index.js'; import { verifies } from '../helpers/verifies.js'; import type { TestArgs } from '../types.js'; +/** A supported protocol version other than the latest, used to prove the version reported is the negotiated one. */ +const OLDER_SUPPORTED_VERSION = (() => { + const older = SUPPORTED_PROTOCOL_VERSIONS.find(v => v !== LATEST_PROTOCOL_VERSION); + if (older === undefined) throw new Error('expected SUPPORTED_PROTOCOL_VERSIONS to include a version other than the latest'); + return older; +})(); + verifies('mcpserver:context:log-from-handler', async ({ transport }: TestArgs) => { let releaseHandler!: () => void; const handlerGate = new Promise(resolve => { @@ -165,3 +180,135 @@ verifies('hosting:context:web-request-headers', async (_args: TestArgs) => { await mcpHost.close(); } }); + +verifies( + 'protocol:envelope:ctx-version-readable', + async ({ transport }: TestArgs) => { + let seenVersion: string | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('read-version', { inputSchema: z.object({}) }, (_args, ctx) => { + seenVersion = ctx.mcpReq.protocolVersion; + return { content: [{ type: 'text', text: ctx.mcpReq.protocolVersion }] }; + }); + return s; + }; + const client = new Client({ name: 'c', version: '0' }); + + await using _ = await wire(transport, makeServer, client); + const result = await client.callTool({ name: 'read-version', arguments: {} }); + + // On a 2025 connection the governing version is the one negotiated at initialize. + expect(seenVersion).toBe(client.getNegotiatedProtocolVersion()); + expect(result.content).toEqual([{ type: 'text', text: client.getNegotiatedProtocolVersion() }]); + }, + { title: 'server handler reads negotiated version (default)' } +); + +verifies( + 'protocol:envelope:ctx-version-readable', + async ({ transport }: TestArgs) => { + let seenVersion: string | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('read-version', { inputSchema: z.object({}) }, (_args, ctx) => { + seenVersion = ctx.mcpReq.protocolVersion; + return { content: [{ type: 'text', text: ctx.mcpReq.protocolVersion }] }; + }); + return s; + }; + // Pin the client to an older supported version so the governing version differs from the latest, + // proving the handler reads the actually-negotiated version rather than a constant. + const client = new Client({ name: 'c', version: '0' }, { supportedProtocolVersions: [OLDER_SUPPORTED_VERSION] }); + + await using _ = await wire(transport, makeServer, client); + await client.callTool({ name: 'read-version', arguments: {} }); + + expect(seenVersion).toBe(OLDER_SUPPORTED_VERSION); + expect(client.getNegotiatedProtocolVersion()).toBe(OLDER_SUPPORTED_VERSION); + }, + { title: 'server handler reads negotiated version (pinned older)' } +); + +verifies( + 'protocol:envelope:ctx-version-readable', + async ({ transport }: TestArgs) => { + // BaseContext is shared by both roles: a client-side handler can read the governing version too. + let seenByClientHandler: string | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('ask', { inputSchema: z.object({}) }, async (_args, ctx) => { + const r = await ctx.mcpReq.requestSampling({ + messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }], + maxTokens: 10 + }); + const text = !Array.isArray(r.content) && r.content.type === 'text' ? r.content.text : ''; + return { content: [{ type: 'text', text }] }; + }); + return s; + }; + const client = new Client({ name: 'c', version: '0' }, { capabilities: { sampling: {} } }); + client.setRequestHandler('sampling/createMessage', async (_req, ctx) => { + seenByClientHandler = ctx.mcpReq.protocolVersion; + return { model: 'stub', role: 'assistant', stopReason: 'endTurn', content: { type: 'text', text: 'ok' } }; + }); + + await using _ = await wire(transport, makeServer, client); + await client.callTool({ name: 'ask', arguments: {} }); + + expect(seenByClientHandler).toBe(client.getNegotiatedProtocolVersion()); + }, + { title: 'client handler reads governing version' } +); + +verifies( + 'protocol:envelope:ctx-capabilities-readable', + async ({ transport }: TestArgs) => { + let seen: { capabilities: ClientCapabilities; info: Implementation | undefined } | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('read-client', { inputSchema: z.object({}) }, (_args, ctx) => { + seen = { capabilities: ctx.client.capabilities, info: ctx.client.info }; + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return s; + }; + const declared: ClientCapabilities = { sampling: {}, roots: { listChanged: true } }; + const clientInfo: Implementation = { name: 'declaring-client', version: '4.5.6' }; + const client = new Client(clientInfo, { capabilities: declared }); + + await using _ = await wire(transport, makeServer, client); + await client.callTool({ name: 'read-client', arguments: {} }); + + // The handler sees exactly what the client declared at initialize. + expect(seen?.capabilities.sampling).toEqual({}); + expect(seen?.capabilities.roots).toEqual({ listChanged: true }); + expect(seen?.info).toEqual(clientInfo); + }, + { title: 'declared capabilities and info' } +); + +verifies( + 'protocol:envelope:ctx-capabilities-readable', + async ({ transport }: TestArgs) => { + let seen: { capabilities: ClientCapabilities; info: Implementation | undefined } | undefined; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('read-client', { inputSchema: z.object({}) }, (_args, ctx) => { + seen = { capabilities: ctx.client.capabilities, info: ctx.client.info }; + return { content: [{ type: 'text', text: 'ok' }] }; + }); + return s; + }; + // A client that declares no optional capabilities yields a `{}`-shaped object, not undefined. + const clientInfo: Implementation = { name: 'bare-client', version: '0.0.1' }; + const client = new Client(clientInfo); + + await using _ = await wire(transport, makeServer, client); + await client.callTool({ name: 'read-client', arguments: {} }); + + expect(seen?.capabilities).toEqual({}); + expect(seen?.info).toEqual(clientInfo); + }, + { title: 'no optional capabilities yields {} shape' } +); diff --git a/test/e2e/scenarios/hosting-routing.test.ts b/test/e2e/scenarios/hosting-routing.test.ts new file mode 100644 index 0000000000..38e8f9a77a --- /dev/null +++ b/test/e2e/scenarios/hosting-routing.test.ts @@ -0,0 +1,226 @@ +/** + * Self-contained test bodies for hosting:routing requirements. + * + * These pin the server transports' routing between the existing (stateful) + * path and the stateless dispatch path for per-request protocol revisions. + * The streamableHttp cells drive raw Request/Response against WebStandard + * transports connected directly (the routing decision is the transport's, not + * a hosting helper's); the stdio cell spawns the fixture server in + * `fixtures/stdio-server.ts` as a real child process and injects hand-built + * JSON-RPC messages via {@link StdioClientTransport} — on stdio there is no + * session header, so routing keys on the request's `_meta` version claim, + * dual-keyed with the server's opt-in. + * + * Routing rules under test: a session-bearing request always goes through + * session validation regardless of version headers; the stateless path is + * reachable only when the server has opted in by listing a non-stateful + * version in its supported list. + */ + +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { CallToolResultSchema, JSONRPCResultResponseSchema, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/server'; +import { + DRAFT_PROTOCOL_VERSION, + LATEST_PROTOCOL_VERSION, + McpServer, + SUPPORTED_PROTOCOL_VERSIONS, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Absolute path to the runnable stdio fixture server (executed with tsx). */ +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/stdio-server.ts', import.meta.url)); + +/** E2E package root — spawn cwd so the workspace-local `tsx` resolves and tsconfig paths map workspace packages to source. */ +const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); + +function echoServer(options?: { listDraftVersion?: boolean }): McpServer { + const s = new McpServer( + { name: 's', version: '0' }, + options?.listDraftVersion ? { supportedProtocolVersions: [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION] } : {} + ); + s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return s; +} + +const initializeRequest = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'initialize', + params: { protocolVersion: LATEST_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'probe', version: '0' } } +}); + +const initializeBody = () => JSON.stringify(initializeRequest(1)); + +const toolsListBody = (id: number | string) => JSON.stringify({ jsonrpc: '2.0', id, method: 'tools/list', params: {} }); + +const baseHeaders = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' +}; + +/** Hand-built echo tools/call whose `params._meta` claims the draft protocol version — the stdio routing signal. */ +const draftClaimingEchoCall = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'claimed' }, _meta: { [PROTOCOL_VERSION_META_KEY]: DRAFT_PROTOCOL_VERSION } } +}); + +/** Spawns the stdio fixture server (optionally listing the draft protocol version) and collects its messages. */ +function spawnStdioFixture(options?: { listDraftVersion?: boolean }): { transport: StdioClientTransport; received: JSONRPCMessage[] } { + const transport = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', FIXTURE_PATH], + cwd: E2E_ROOT, + ...(options?.listDraftVersion ? { env: { E2E_LIST_DRAFT_VERSION: '1' } } : {}) + }); + const received: JSONRPCMessage[] = []; + transport.onmessage = message => void received.push(message); + return { transport, received }; +} + +/** + * stdio half of stateless-only-configured: on a fixture server that does NOT + * list any non-stateful version, a request claiming one via `_meta` is NOT + * routed — it is served on the existing path exactly as today (dual key: the + * claim alone never routes). The routed half on stdio is pinned by the + * protocol:stateless:request-served stdio body. + */ +async function statelessOnlyConfiguredStdio(): Promise { + const { transport, received } = spawnStdioFixture(); + try { + await transport.start(); + + await transport.send(initializeRequest(1)); + // Generous first wait: tsx compiles the fixture inside the freshly spawned child before it can answer. + await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 10_000, interval: 25 }); + await transport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + await transport.send(draftClaimingEchoCall(2)); + await vi.waitFor(() => expect(received).toHaveLength(2), { timeout: 5000, interval: 25 }); + + // Existing behavior, untouched: a tools/call result — never a stateless-path error. + const response = JSONRPCResultResponseSchema.parse(received[1]); + expect(response.id).toBe(2); + expect(CallToolResultSchema.parse(response.result).content).toEqual([{ type: 'text', text: 'claimed' }]); + } finally { + await transport.close(); + } +} + +verifies('hosting:routing:session-id-never-stateless', async (_args: TestArgs) => { + // Direct transport so the routing decision under test is the SDK's, not a hosting helper's. + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); + await echoServer({ listDraftVersion: true }).connect(tx); + + try { + const initRes = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': LATEST_PROTOCOL_VERSION }, + body: initializeBody() + }) + ); + expect(initRes.status).toBe(200); + const sessionId = initRes.headers.get('mcp-session-id')!; + + // Valid session + draft version header: served on the session path as today (SSE result), + // even though the draft version alone would route stateless on this transport. + const valid = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION, 'mcp-session-id': sessionId }, + body: toolsListBody(2) + }) + ); + expect(valid.status).toBe(200); + expect(valid.headers.get('content-type')).toMatch(/text\/event-stream/); + + // Actually served on the session path, not merely accepted: the SSE stream carries + // the tools/list result for request id 2 — a real response, never a stateless-path + // rejection (the session-path request has no _meta envelope, which the stateless + // path would refuse with -32602). + const reader = valid.body!.getReader(); + const { value: firstEvent } = await reader.read(); + const dataLine = new TextDecoder() + .decode(firstEvent) + .split('\n') + .find(line => line.startsWith('data: ')); + expect(dataLine).toBeDefined(); + expect(JSON.parse(dataLine!.slice('data: '.length))).toMatchObject({ + jsonrpc: '2.0', + id: 2, + result: { tools: [{ name: 'echo' }] } + }); + + // Unknown session + draft version header: session validation answers (404), + // never the stateless path. + const unknown = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION, 'mcp-session-id': 'no-such-session' }, + body: toolsListBody(3) + }) + ); + expect(unknown.status).toBe(404); + expect(await unknown.json()).toMatchObject({ jsonrpc: '2.0', error: { code: -32_001, message: 'Session not found' } }); + } finally { + await tx.close(); + } +}); + +verifies('hosting:routing:stateless-only-configured', async ({ transport }: TestArgs) => { + if (transport === 'stdio') { + await statelessOnlyConfiguredStdio(); + return; + } + + const draftRequest = () => + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION }, + body: toolsListBody(1) + }); + + // Draft version claim on a server NOT listing the draft: today's unsupported-version 400, byte-identical. + const txDefault = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await echoServer().connect(txDefault); + try { + const res = await txDefault.handleRequest(draftRequest()); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ + jsonrpc: '2.0', + id: null, + error: { + code: -32_000, + message: `Bad Request: Unsupported protocol version: ${DRAFT_PROTOCOL_VERSION} (supported versions: ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')})` + } + }); + } finally { + await txDefault.close(); + } + + // Same claim with the draft listed and a server connected: handled on the stateless + // path — proven by its distinctive envelope rejection (-32602 for the missing _meta + // envelope; the stateful path would have served tools/list normally here). + const txDraft = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await echoServer({ listDraftVersion: true }).connect(txDraft); + try { + const res = await txDraft.handleRequest(draftRequest()); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ jsonrpc: '2.0', id: 1, error: { code: -32_602 } }); + } finally { + await txDraft.close(); + } +}); diff --git a/test/e2e/scenarios/lifecycle.test.ts b/test/e2e/scenarios/lifecycle.test.ts index 2fba4a84e8..bdb9914afd 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, isJSONRPCNotification, isJSONRPCRequest, isJSONRPCResultResponse, @@ -544,6 +545,69 @@ verifies('lifecycle:version:no-overlap-rejects', async ({ transport }: TestArgs) expect(client.getServerVersion()).toBeUndefined(); }); +verifies('lifecycle:version:initialize-stateful-versions-only', async ({ transport }: TestArgs) => { + /** Finds the initialize request/response pair in the log and asserts both sides carry `version` and never the draft revision. */ + const expectInitializeExchangeAt = (log: HandshakeLogEntry[], version: string) => { + 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(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(version); + + // The guard itself: nothing newer than 2025-11-25 anywhere in the initialize exchange. + expect(JSON.stringify(initRequest.message)).not.toContain(DRAFT_PROTOCOL_VERSION); + expect(JSON.stringify(initResponse.message)).not.toContain(DRAFT_PROTOCOL_VERSION); + }; + + { + // Client side: the draft revision listed first never enters the initialize request. The + // server shares no per-request version, so the client's discover probe falls back to the + // handshake (the probe itself legitimately claims the draft revision — see + // lifecycle:connect:per-request-era-fallback). + const client = new Client( + { name: 'stateful-only-client', version: '0.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION] } + ); + const log = tapHandshake(client); + + await using _ = await wire(transport, () => new McpServer({ name: 'stateful-only-server', version: '0.0.0' }), client); + + expectInitializeExchangeAt(log, LATEST_PROTOCOL_VERSION); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + expect(client.getServerVersion()).toEqual({ name: 'stateful-only-server', version: '0.0.0' }); + } + + { + // Server side: a server listing the draft revision first still answers initialize with + // the newest mutually-supported stateful version. The client is not opted in, so no + // probe precedes the handshake. + const makeServer = () => + new McpServer( + { name: 'draft-listing-server', version: '0.0.0' }, + { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION] } + ); + const client = new Client({ name: 'stateful-only-client', version: '0.0.0' }); + const log = tapHandshake(client); + + await using _ = await wire(transport, makeServer, client); + + expect( + log.some(e => e.direction === 'client-to-server' && isJSONRPCRequest(e.message) && e.message.method === 'server/discover') + ).toBe(false); + expectInitializeExchangeAt(log, LATEST_PROTOCOL_VERSION); + expect(client.getNegotiatedProtocolVersion()).toBe(LATEST_PROTOCOL_VERSION); + } +}); + verifies('lifecycle:capability:list-empty-when-not-advertised', async ({ transport }: TestArgs) => { // Bare McpServer with no registrations advertises no tools/prompts/resources capabilities. const client = minimalClient(); diff --git a/test/e2e/scenarios/sessionless.test.ts b/test/e2e/scenarios/sessionless.test.ts new file mode 100644 index 0000000000..e1ae3b5adb --- /dev/null +++ b/test/e2e/scenarios/sessionless.test.ts @@ -0,0 +1,341 @@ +/** + * Self-contained test bodies for the sessionless invariants of the per-request + * protocol revisions (SEP-2567, with SEP-2575's endpoint rules): the POST-only + * endpoint (GET/DELETE → 405, batch → 400), the session-header ban on both + * sides, per-request authorization context, list-result connection invariance + * and side-effect freedom, the re-scoped request-id uniqueness, stdio + * cancellation of in-flight stateless requests, and the absence of SSE + * resumption ids on stateless response streams. + * + * The streamableHttp cells drive raw Request/Response against WebStandard + * transports connected directly; the session-header pair drives a real + * 2026-mode client through a recording/header-injecting fetch; the stdio + * cancellation cell spawns the fixture server as a real child process. + */ + +import { randomUUID } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { CallToolResultSchema, JSONRPCResultResponseSchema } from '@modelcontextprotocol/core'; +import type { AuthInfo, JSONRPCMessage, JSONRPCRequest } from '@modelcontextprotocol/server'; +import { + DRAFT_PROTOCOL_VERSION, + McpServer, + SUPPORTED_PROTOCOL_VERSIONS, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Absolute path to the runnable stdio fixture server (executed with tsx). */ +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/stdio-server.ts', import.meta.url)); + +/** E2E package root — spawn cwd so the workspace-local `tsx` resolves and tsconfig paths map workspace packages to source. */ +const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); + +const DRAFT_LISTED = [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION]; + +const baseHeaders = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' +}; + +const draftHeaders = { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION }; + +/** The complete per-request `_meta` envelope this protocol revision requires. */ +const envelope = (overrides?: Record) => ({ + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'sessionless-client', version: '9.9.9' }, + 'io.modelcontextprotocol/clientCapabilities': {}, + ...overrides +}); + +/** An enveloped tools/call request. */ +const toolCall = (id: number | string, name: string, args: Record = {}): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name, arguments: args, _meta: envelope() } +}); + +const post = (tx: WebStandardStreamableHTTPServerTransport, body: unknown, headers: Record = draftHeaders) => + tx.handleRequest(new Request('http://in-process/mcp', { method: 'POST', headers, body: JSON.stringify(body) })); + +verifies(['hosting:sessionless:get-405', 'hosting:sessionless:delete-405', 'hosting:sessionless:no-batching'], async (_args: TestArgs) => { + // One opted-in server behind a session-less WebStandard transport: the + // endpoint discipline of the per-request revisions is POST-only with + // exactly one JSON-RPC message per body. + const server = new McpServer({ name: 's', version: '0' }, { supportedProtocolVersions: DRAFT_LISTED }); + server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(tx); + try { + for (const method of ['GET', 'DELETE']) { + const res = await tx.handleRequest( + new Request('http://in-process/mcp', { + method, + headers: { accept: 'application/json, text/event-stream', 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION } + }) + ); + expect(res.status, method).toBe(405); + expect(res.headers.get('allow'), method).toBe('POST'); + expect(res.headers.get('mcp-session-id'), method).toBeNull(); + } + + // A batch body — two individually well-formed, fully enveloped requests — + // is rejected for being a batch, with the request ids unreadable by + // construction (a batch has no single id): -32600, id null, HTTP 400. + const batch = await post(tx, [toolCall(61, 'echo', { text: 'one' }), toolCall(62, 'echo', { text: 'two' })]); + expect(batch.status).toBe(400); + expect(await batch.json()).toMatchObject({ jsonrpc: '2.0', id: null, error: { code: -32_600 } }); + + // The same message outside the array is served — the rejection above is + // about batching, not about the request contents. + const single = await post(tx, toolCall(63, 'echo', { text: 'one' })); + expect(single.status).toBe(200); + expect(await single.json()).toMatchObject({ jsonrpc: '2.0', id: 63 }); + } finally { + await tx.close(); + } +}); + +verifies(['hosting:sessionless:no-session-header', 'client-transport:http:no-session-header'], async (_args: TestArgs) => { + // A dual-stack server WITH a sessionIdGenerator: the per-request path must + // not engage it. The recording fetch captures every request's headers and + // the server's raw response headers, then hands the client a tampered + // response carrying an injected Mcp-Session-Id — which a 2026-mode client + // must ignore entirely (no storage, no replay). + const server = new McpServer({ name: 's', version: '0' }, { supportedProtocolVersions: DRAFT_LISTED }); + server.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); + await server.connect(tx); + + const requestSessionHeaders: Array = []; + const rawResponseSessionHeaders: Array = []; + const fetchFn = async (url: URL | string, init?: RequestInit): Promise => { + const request = new Request(url, init); + requestSessionHeaders.push(request.headers.get('mcp-session-id')); + const response = await tx.handleRequest(request); + rawResponseSessionHeaders.push(response.headers.get('mcp-session-id')); + const tampered = new Headers(response.headers); + tampered.set('mcp-session-id', 'injected-by-test'); + return new Response(response.body, { status: response.status, statusText: response.statusText, headers: tampered }); + }; + + const client = new Client({ name: 'sessionless-client', version: '0.0.1' }, { supportedProtocolVersions: [DRAFT_PROTOCOL_VERSION] }); + const clientTx = new StreamableHTTPClientTransport(new URL('http://in-process/mcp'), { fetch: fetchFn }); + try { + await client.connect(clientTx); + await client.callTool({ name: 'echo', arguments: { text: 'one' } }); + // The injected header was seen by now; a further request proves no replay. + await client.callTool({ name: 'echo', arguments: { text: 'two' } }); + + expect(requestSessionHeaders.length).toBeGreaterThanOrEqual(3); // discover + two calls + expect(requestSessionHeaders).toEqual(requestSessionHeaders.map(() => null)); + // The server side never emitted one either, sessionIdGenerator notwithstanding. + expect(rawResponseSessionHeaders).toEqual(rawResponseSessionHeaders.map(() => null)); + expect(clientTx.sessionId).toBeUndefined(); + } finally { + await client.close(); + await tx.close(); + } +}); + +verifies(['hosting:sessionless:per-request-auth', 'typescript:mcpserver:tool:extra-sessionless'], async (_args: TestArgs) => { + const server = new McpServer({ name: 's', version: '0' }, { supportedProtocolVersions: DRAFT_LISTED }); + server.registerTool('report-extra', { inputSchema: z.object({}) }, (_a, ctx) => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + sessionId: ctx.sessionId ?? null, + requestId: ctx.mcpReq.id, + hasSignal: ctx.mcpReq.signal instanceof AbortSignal, + hasNotify: typeof ctx.mcpReq.notify === 'function', + authInfo: ctx.http?.authInfo ? { token: ctx.http.authInfo.token, clientId: ctx.http.authInfo.clientId } : null + }) + } + ] + })); + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(tx); + + const reported = async (id: number, authInfo?: AuthInfo) => { + const res = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: draftHeaders, + body: JSON.stringify(toolCall(id, 'report-extra')) + }), + authInfo ? { authInfo } : undefined + ); + expect(res.status).toBe(200); + const body = JSONRPCResultResponseSchema.parse(await res.json()); + expect(body.id).toBe(id); + const content = CallToolResultSchema.parse(body.result).content; + return JSON.parse((content[0] as { text: string }).text) as Record; + }; + + try { + // First request carries validated auth; the handler context exposes exactly it. + const first = await reported(71, { token: 'token-71', clientId: 'client-71', scopes: ['mcp'] }); + expect(first).toEqual({ + sessionId: null, + requestId: 71, + hasSignal: true, + hasNotify: true, + authInfo: { token: 'token-71', clientId: 'client-71' } + }); + + // Second request on the SAME transport carries none — and sees none: + // authorization context is per-request, never inherited. + const second = await reported(72); + expect(second).toEqual({ sessionId: null, requestId: 72, hasSignal: true, hasNotify: true, authInfo: null }); + } finally { + await tx.close(); + } +}); + +verifies( + ['protocol:stateless:list-connection-independent', 'protocol:stateless:list-no-side-effects', 'protocol:request-id:outstanding-scope'], + async (_args: TestArgs) => { + // Instance-per-request hosting: each transport serves a fresh server from + // the same factory — two transports are two independent connections. + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }, { supportedProtocolVersions: DRAFT_LISTED }); + s.registerTool('alpha', { description: 'first tool', inputSchema: z.object({}) }, () => ({ + content: [{ type: 'text', text: 'alpha' }] + })); + s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + return s; + }; + const connect = async () => { + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await makeServer().connect(tx); + return tx; + }; + const listTools = async (tx: WebStandardStreamableHTTPServerTransport, id: number) => { + const res = await post(tx, { jsonrpc: '2.0', id, method: 'tools/list', params: { _meta: envelope() } }); + expect(res.status).toBe(200); + const body = JSONRPCResultResponseSchema.parse(await res.json()); + expect(body.id).toBe(id); + return (body.result as { tools: unknown[] }).tools; + }; + + const tx1 = await connect(); + const tx2 = await connect(); + try { + // Connection invariance: the same list through two independent connections. + const first = await listTools(tx1, 11); + const onOtherConnection = await listTools(tx2, 11); + expect(onOtherConnection).toEqual(first); + + // An intervening call on connection 1... + const call = await post(tx1, toolCall(12, 'echo', { text: 'between lists' })); + expect(call.status).toBe(200); + + // ...leaves the list unchanged — asked for with the SAME JSON-RPC id as + // the first (already settled) list request: under the re-scoped + // uniqueness rule a completed id may be reused, and the request is + // served normally. + const again = await listTools(tx1, 11); + expect(again).toEqual(first); + } finally { + await tx1.close(); + await tx2.close(); + } + } +); + +verifies('protocol:stateless:stdio-cancellation', async (_args: TestArgs) => { + // Real child process: the in-flight stateless request is cancelled via + // notifications/cancelled, the handler observes its per-request signal + // fire (reported on stderr — the only channel allowed to carry anything + // for the request after cancellation), and no further stdout frame ever + // appears for that id while unrelated traffic keeps flowing. + const tx = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', FIXTURE_PATH], + cwd: E2E_ROOT, + env: { E2E_LIST_DRAFT_VERSION: '1' }, + stderr: 'pipe' + }); + const received: JSONRPCMessage[] = []; + tx.onmessage = message => void received.push(message); + let stderrText = ''; + try { + await tx.start(); + tx.stderr?.on('data', chunk => { + stderrText += String(chunk); + }); + + await tx.send(toolCall(77, 'slow')); + // The tool signals it is running by emitting one progress notification. + // Generous wait: tsx compiles the fixture inside the freshly spawned child first. + await vi.waitFor(() => expect(received.some(m => 'method' in m && m.method === 'notifications/progress')).toBe(true), { + timeout: 10_000, + interval: 25 + }); + + await tx.send({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 77 } }); + // The handler observed ctx.mcpReq.signal.aborted === true. + await vi.waitFor(() => expect(stderrText).toContain('aborted:77:true'), { timeout: 5000, interval: 25 }); + + // Unrelated traffic still round-trips on the same pipe — and because stdout + // frames are ordered, this response arriving proves the (suppressed) late + // frames of request 77 never made it out. + await tx.send(toolCall(78, 'echo', { text: 'after-cancel' })); + await vi.waitFor(() => expect(received.some(m => 'id' in m && m.id === 78)).toBe(true), { timeout: 5000, interval: 25 }); + expect(received.filter(m => 'id' in m && m.id === 77)).toEqual([]); + } finally { + await tx.close(); + } +}); + +verifies('hosting:http:stateless-no-sse-event-ids', async (_args: TestArgs) => { + // The trap under test: an eventStore IS configured, so the 2025 session + // path would offer resumption ids — the stateless path must not. + const events: string[] = []; + const server = new McpServer({ name: 's', version: '0' }, { supportedProtocolVersions: DRAFT_LISTED }); + server.registerTool('progressing', { inputSchema: z.object({}) }, async (_a, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'tok', progress: 1 } }); + return { content: [{ type: 'text', text: 'streamed' }] }; + }); + const tx = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + eventStore: { + storeEvent: async (streamId, _message) => { + events.push(streamId); + return `event-${events.length}`; + }, + replayEventsAfter: async () => 'unused' + } + }); + await server.connect(tx); + try { + const res = await post(tx, toolCall('sse-1', 'progressing')); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/event-stream'); + + const raw = await res.text(); + // The stream carried the notification and the response... + expect(raw).toContain('notifications/progress'); + expect(raw).toContain('"sse-1"'); + // ...but not a single SSE id: line, and nothing was offered for replay. + expect(raw).not.toMatch(/^id:/m); + expect(events).toEqual([]); + } finally { + await tx.close(); + } +}); diff --git a/test/e2e/scenarios/stateless-dispatch.test.ts b/test/e2e/scenarios/stateless-dispatch.test.ts new file mode 100644 index 0000000000..c42076a3d5 --- /dev/null +++ b/test/e2e/scenarios/stateless-dispatch.test.ts @@ -0,0 +1,589 @@ +/** + * Self-contained test bodies for the stateless dispatch path (per-request + * protocol revisions, SEP-2575 + SEP-2567): envelope acceptance, version + * negotiation errors, the removed-RPC gate, end-to-end service without an + * initialize handshake, the `_meta`-sourced handler context, per-request + * logging (the `logLevel` `_meta` claim), and the HTTP response shaping + * (JSON vs SSE, 202 for notifications, header/`_meta` version mismatch). + * + * The streamableHttp cells drive raw Request/Response against WebStandard + * transports connected directly (matching how the conformance harness drives + * a server); the stdio cells drive hand-built newline-framed messages against + * an in-process {@link StdioServerTransport} wired to a real server — except + * protocol:stateless:request-served, whose stdio half spawns the fixture + * server as a real child process. + */ + +import { randomUUID } from 'node:crypto'; +import { PassThrough } from 'node:stream'; +import { fileURLToPath } from 'node:url'; + +import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; +import { CallToolResultSchema, JSONRPCResultResponseSchema } from '@modelcontextprotocol/core'; +import type { JSONRPCMessage, JSONRPCRequest, LoggingLevel } from '@modelcontextprotocol/server'; +import { + DRAFT_PROTOCOL_VERSION, + LATEST_PROTOCOL_VERSION, + McpServer, + ReadBuffer, + serializeMessage, + SUPPORTED_PROTOCOL_VERSIONS, + WebStandardStreamableHTTPServerTransport +} from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; +import { expect, vi } from 'vitest'; +import { z } from 'zod/v4'; + +import { verifies } from '../helpers/verifies.js'; +import type { TestArgs } from '../types.js'; + +/** Absolute path to the runnable stdio fixture server (executed with tsx). */ +const FIXTURE_PATH = fileURLToPath(new URL('../fixtures/stdio-server.ts', import.meta.url)); + +/** E2E package root — spawn cwd so the workspace-local `tsx` resolves and tsconfig paths map workspace packages to source. */ +const E2E_ROOT = fileURLToPath(new URL('../', import.meta.url)); + +const DRAFT_LISTED = [...SUPPORTED_PROTOCOL_VERSIONS, DRAFT_PROTOCOL_VERSION]; + +const baseHeaders = { + 'content-type': 'application/json', + accept: 'application/json, text/event-stream' +}; + +const draftHeaders = { ...baseHeaders, 'mcp-protocol-version': DRAFT_PROTOCOL_VERSION }; + +/** The complete per-request `_meta` envelope this protocol revision requires. */ +const envelope = (overrides?: Record) => ({ + 'io.modelcontextprotocol/protocolVersion': DRAFT_PROTOCOL_VERSION, + 'io.modelcontextprotocol/clientInfo': { name: 'stateless-client', version: '9.9.9' }, + 'io.modelcontextprotocol/clientCapabilities': {}, + ...overrides +}); + +/** Every RFC 5424 severity, lowest to highest. */ +const ALL_LEVELS: LoggingLevel[] = ['debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency']; + +/** A server that has opted in to the draft revision, with an echo tool, a ctx-reporting tool, and a log-emitting tool. */ +function statelessServer(): McpServer { + const s = new McpServer({ name: 's', version: '0' }, { capabilities: { logging: {} }, supportedProtocolVersions: DRAFT_LISTED }); + s.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ + content: [{ type: 'text', text }] + })); + s.registerTool('whoami', { inputSchema: z.object({}) }, (_args, ctx) => ({ + content: [ + { + type: 'text', + text: JSON.stringify({ + protocolVersion: ctx.mcpReq.protocolVersion, + clientInfo: ctx.client.info ?? null, + clientCapabilities: ctx.client.capabilities, + sessionId: ctx.sessionId ?? null + }) + } + ] + })); + s.registerTool('log-sweep', { inputSchema: z.object({}) }, async (_args, ctx) => { + for (const level of ALL_LEVELS) { + await ctx.mcpReq.log(level, `level-${level}`, 'sweep'); + } + return { content: [{ type: 'text', text: 'swept' }] }; + }); + return s; +} + +/** Connects a fresh stateless server to a session-less WebStandard transport. */ +async function connectHttp(): Promise { + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await statelessServer().connect(tx); + return tx; +} + +const post = (tx: WebStandardStreamableHTTPServerTransport, body: unknown, headers: Record = draftHeaders) => + tx.handleRequest(new Request('http://in-process/mcp', { method: 'POST', headers, body: JSON.stringify(body) })); + +/** Parses the data lines of a complete SSE body into JSON-RPC messages. */ +const parseSseEvents = (sseBody: string): JSONRPCMessage[] => + sseBody + .split('\n\n') + .filter(Boolean) + .map( + event => + JSON.parse( + event + .split('\n') + .find(line => line.startsWith('data: '))! + .slice('data: '.length) + ) as JSONRPCMessage + ); + +/** + * In-process stdio wiring: a real server connected to a StdioServerTransport + * over PassThrough pipes; messages are hand-built and framed exactly as on the + * real wire (the SDK's serializeMessage/ReadBuffer framing). + */ +async function connectStdio(): Promise<{ + send: (message: JSONRPCMessage) => void; + next: () => Promise; + close: () => Promise; +}> { + const input = new PassThrough(); + const output = new PassThrough(); + const server = statelessServer(); + await server.connect(new StdioServerTransport(input, output)); + + const buf = new ReadBuffer(); + const received: JSONRPCMessage[] = []; + output.on('data', chunk => { + buf.append(chunk as Buffer); + let message: JSONRPCMessage | null; + while ((message = buf.readMessage())) received.push(message); + }); + + let read = 0; + return { + send: message => void input.push(serializeMessage(message)), + next: async () => + await vi.waitFor(() => { + if (received.length <= read) throw new Error('no message yet'); + return received[read++]!; + }), + close: () => server.close() + }; +} + +verifies('protocol:stateless:envelope-required', async ({ transport }: TestArgs) => { + const cases: Array<{ id: number; meta?: Record }> = [ + { id: 101 }, // _meta missing entirely + { id: 102, meta: envelope({ 'io.modelcontextprotocol/protocolVersion': undefined }) }, + { id: 103, meta: envelope({ 'io.modelcontextprotocol/clientInfo': undefined }) }, + { id: 104, meta: envelope({ 'io.modelcontextprotocol/clientCapabilities': undefined }) } + ]; + const request = ({ id, meta }: { id: number; meta?: Record }): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/list', + params: meta ? { _meta: meta } : {} + }); + + if (transport === 'stdio') { + // On stdio the _meta protocolVersion claim IS the routing signal, so only + // requests that carry it can reach the stateless path at all: a request + // with no _meta (or no version) is indistinguishable from stateful-era + // traffic and is served on the existing path. The missing-meta and + // missing-version rejections are HTTP-only (the header claims the era). + const routableCases = cases.filter(testCase => typeof testCase.meta?.['io.modelcontextprotocol/protocolVersion'] === 'string'); + expect(routableCases.map(c => c.id)).toEqual([103, 104]); + const stdio = await connectStdio(); + try { + for (const testCase of routableCases) { + stdio.send(request(testCase)); + expect(await stdio.next()).toMatchObject({ jsonrpc: '2.0', id: testCase.id, error: { code: -32_602 } }); + } + } finally { + await stdio.close(); + } + return; + } + + const tx = await connectHttp(); + try { + for (const testCase of cases) { + const res = await post(tx, request(testCase)); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ jsonrpc: '2.0', id: testCase.id, error: { code: -32_602 } }); + } + } finally { + await tx.close(); + } +}); + +verifies('protocol:stateless:version-unsupported', async ({ transport }: TestArgs) => { + const request: JSONRPCRequest = { + jsonrpc: '2.0', + id: 301, + method: 'tools/list', + params: { _meta: envelope({ 'io.modelcontextprotocol/protocolVersion': 'v999.0.0' }) } + }; + const expected = { + jsonrpc: '2.0', + id: 301, + error: { code: -32_004, data: { supported: DRAFT_LISTED, requested: 'v999.0.0' } } + }; + + if (transport === 'stdio') { + const stdio = await connectStdio(); + try { + stdio.send(request); + expect(await stdio.next()).toMatchObject(expected); + } finally { + await stdio.close(); + } + return; + } + + const tx = await connectHttp(); + try { + // Header and _meta agree on the unsupported version (a disagreement would be -32001). + const res = await post(tx, request, { ...baseHeaders, 'mcp-protocol-version': 'v999.0.0' }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject(expected); + } finally { + await tx.close(); + } +}); + +verifies('protocol:stateless:removed-methods', async ({ transport }: TestArgs) => { + // initialize and ping have live handlers on every server (the stateful path + // serves them), logging/setLevel has one because the fixture declares the + // logging capability — so the -32601 here proves the gate, not a gap. + const methods = ['initialize', 'ping', 'logging/setLevel', 'resources/subscribe', 'resources/unsubscribe', 'unknown/method']; + const request = (id: number, method: string): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method, + params: { _meta: envelope() } + }); + + if (transport === 'stdio') { + const stdio = await connectStdio(); + try { + for (const [index, method] of methods.entries()) { + stdio.send(request(500 + index, method)); + expect(await stdio.next()).toMatchObject({ + jsonrpc: '2.0', + id: 500 + index, + error: { code: -32_601, message: 'Method not found' } + }); + } + } finally { + await stdio.close(); + } + return; + } + + const tx = await connectHttp(); + try { + for (const [index, method] of methods.entries()) { + const res = await post(tx, request(500 + index, method)); + expect(res.status, method).toBe(404); + expect(await res.json()).toMatchObject({ + jsonrpc: '2.0', + id: 500 + index, + error: { code: -32_601, message: 'Method not found' } + }); + } + } finally { + await tx.close(); + } +}); + +verifies('protocol:stateless:request-served', async ({ transport }: TestArgs) => { + const echoCall = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'self-contained' }, _meta: envelope() } + }); + + if (transport === 'stdio') { + // Real child process, no initialize handshake — the first message the + // server ever sees is the enveloped request, and it is served. + const tx = new StdioClientTransport({ + command: process.execPath, + args: ['--import', 'tsx', FIXTURE_PATH], + cwd: E2E_ROOT, + env: { E2E_LIST_DRAFT_VERSION: '1' } + }); + const received: JSONRPCMessage[] = []; + tx.onmessage = message => void received.push(message); + try { + await tx.start(); + await tx.send(echoCall(7)); + // Generous wait: tsx compiles the fixture inside the freshly spawned child before it can answer. + await vi.waitFor(() => expect(received).toHaveLength(1), { timeout: 10_000, interval: 25 }); + const response = JSONRPCResultResponseSchema.parse(received[0]); + expect(response.id).toBe(7); + expect(CallToolResultSchema.parse(response.result).content).toEqual([{ type: 'text', text: 'self-contained' }]); + } finally { + await tx.close(); + } + return; + } + + const tx = await connectHttp(); + try { + const res = await post(tx, echoCall(7)); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('application/json'); + const body = JSONRPCResultResponseSchema.parse(await res.json()); + expect(body.id).toBe(7); + expect(CallToolResultSchema.parse(body.result).content).toEqual([{ type: 'text', text: 'self-contained' }]); + } finally { + await tx.close(); + } +}); + +verifies('protocol:stateless:ctx-meta-sourced', async ({ transport }: TestArgs) => { + const whoamiCall = (id: number): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + name: 'whoami', + arguments: {}, + _meta: envelope({ 'io.modelcontextprotocol/clientCapabilities': { sampling: {} } }) + } + }); + const expectMetaSourced = (result: unknown) => { + const content = CallToolResultSchema.parse(result).content; + expect(content[0]?.type).toBe('text'); + expect(JSON.parse((content[0] as { text: string }).text)).toEqual({ + protocolVersion: DRAFT_PROTOCOL_VERSION, + clientInfo: { name: 'stateless-client', version: '9.9.9' }, + clientCapabilities: { sampling: {} }, + sessionId: null + }); + }; + const initializeRequest: JSONRPCRequest = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: { roots: {} }, + clientInfo: { name: 'handshake-client', version: '0' } + } + }; + + if (transport === 'stdio') { + const stdio = await connectStdio(); + try { + // Populate the handshake state first, so non-inheritance is observable. + stdio.send(initializeRequest); + expect(await stdio.next()).toMatchObject({ jsonrpc: '2.0', id: 1 }); + stdio.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + stdio.send(whoamiCall(2)); + const response = JSONRPCResultResponseSchema.parse(await stdio.next()); + expectMetaSourced(response.result); + } finally { + await stdio.close(); + } + return; + } + + // Session-mode transport: a real initialize populates the handshake state + // (handshake-client, roots capability) before the stateless request arrives — + // the handler must see the envelope facts, not the handshake's. + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: randomUUID }); + await statelessServer().connect(tx); + try { + const initRes = await tx.handleRequest( + new Request('http://in-process/mcp', { + method: 'POST', + headers: { ...baseHeaders, 'mcp-protocol-version': LATEST_PROTOCOL_VERSION }, + body: JSON.stringify(initializeRequest) + }) + ); + expect(initRes.status).toBe(200); + + const res = await post(tx, whoamiCall(2)); + expect(res.status).toBe(200); + const body = JSONRPCResultResponseSchema.parse(await res.json()); + expectMetaSourced(body.result); + } finally { + await tx.close(); + } +}); + +/** A tools/call of the log-sweep tool, optionally claiming a per-request log level. */ +const sweepCall = (id: number, logLevel?: string): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { + name: 'log-sweep', + arguments: {}, + _meta: envelope(logLevel === undefined ? {} : { 'io.modelcontextprotocol/logLevel': logLevel }) + } +}); + +verifies('protocol:stateless:per-request-loglevel', async ({ transport }: TestArgs) => { + // The handler emits all eight severities; a 'warning' claim must deliver + // exactly the five at or above it, in order, before the final response. + const atOrAboveWarning = ALL_LEVELS.slice(ALL_LEVELS.indexOf('warning')); + + if (transport === 'stdio') { + const stdio = await connectStdio(); + try { + stdio.send(sweepCall(801, 'warning')); + for (const level of atOrAboveWarning) { + expect(await stdio.next()).toMatchObject({ + method: 'notifications/message', + params: { level, logger: 'sweep', data: `level-${level}` } + }); + } + expect(await stdio.next()).toMatchObject({ jsonrpc: '2.0', id: 801 }); + + // An unrecognized level value is an envelope violation: -32602, id echoed. + stdio.send(sweepCall(802, 'verbose')); + expect(await stdio.next()).toMatchObject({ jsonrpc: '2.0', id: 802, error: { code: -32_602 } }); + } finally { + await stdio.close(); + } + return; + } + + const tx = await connectHttp(); + try { + const res = await post(tx, sweepCall(801, 'warning')); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('text/event-stream'); + const events = parseSseEvents(await res.text()); + expect(events).toHaveLength(atOrAboveWarning.length + 1); + for (const [index, level] of atOrAboveWarning.entries()) { + expect(events[index]).toMatchObject({ + method: 'notifications/message', + params: { level, logger: 'sweep', data: `level-${level}` } + }); + } + expect(events.at(-1)).toMatchObject({ jsonrpc: '2.0', id: 801 }); + + // An unrecognized level value is an envelope violation: -32602, id echoed. + const invalid = await post(tx, sweepCall(802, 'verbose')); + expect(invalid.status).toBe(400); + expect(await invalid.json()).toMatchObject({ jsonrpc: '2.0', id: 802, error: { code: -32_602 } }); + } finally { + await tx.close(); + } +}); + +verifies('protocol:stateless:no-log-without-loglevel', async ({ transport }: TestArgs) => { + if (transport === 'stdio') { + const stdio = await connectStdio(); + try { + // A preceding request claims debug — every severity is delivered for IT... + stdio.send(sweepCall(901, 'debug')); + for (const level of ALL_LEVELS) { + expect(await stdio.next()).toMatchObject({ method: 'notifications/message', params: { level } }); + } + expect(await stdio.next()).toMatchObject({ jsonrpc: '2.0', id: 901 }); + + // ...and is never stored: the unclaimed request's next frame is its + // result directly — no notifications/message leaked from the claim. + stdio.send(sweepCall(902)); + const response = JSONRPCResultResponseSchema.parse(await stdio.next()); + expect(response.id).toBe(902); + } finally { + await stdio.close(); + } + return; + } + + const tx = await connectHttp(); + try { + // A preceding request claims debug — every severity is delivered for IT... + const claimed = await post(tx, sweepCall(901, 'debug')); + expect(claimed.status).toBe(200); + expect(claimed.headers.get('content-type')).toBe('text/event-stream'); + const claimedEvents = parseSseEvents(await claimed.text()); + expect(claimedEvents.filter(message => 'method' in message && message.method === 'notifications/message')).toHaveLength( + ALL_LEVELS.length + ); + + // ...and is never stored: with no claim nothing is emitted, so the lazy + // SSE stream never opens and the answer is a single application/json + // object — no notifications/message anywhere. + const bare = await post(tx, sweepCall(902)); + expect(bare.status).toBe(200); + expect(bare.headers.get('content-type')).toContain('application/json'); + const body = JSONRPCResultResponseSchema.parse(await bare.json()); + expect(body.id).toBe(902); + } finally { + await tx.close(); + } +}); + +verifies('hosting:http:version-header-meta-mismatch', async (_args: TestArgs) => { + const tx = await connectHttp(); + try { + const res = await post(tx, { + jsonrpc: '2.0', + id: 'mismatch-1', + method: 'tools/list', + params: { _meta: envelope({ 'io.modelcontextprotocol/protocolVersion': 'v999.0.0' }) } + }); + expect(res.status).toBe(400); + expect(await res.json()).toMatchObject({ + jsonrpc: '2.0', + id: 'mismatch-1', + error: { code: -32_001, message: expect.stringContaining('Header mismatch') } + }); + } finally { + await tx.close(); + } +}); + +verifies('hosting:http:stateless-notification-202', async (_args: TestArgs) => { + const tx = await connectHttp(); + try { + const res = await post(tx, { + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 'x', _meta: envelope() } + }); + expect(res.status).toBe(202); + expect(await res.text()).toBe(''); + } finally { + await tx.close(); + } +}); + +verifies('hosting:http:stateless-response-stream', async (_args: TestArgs) => { + const makeServer = () => { + const s = statelessServer(); + s.registerTool('progressing', { inputSchema: z.object({}) }, async (_args, ctx) => { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'tok', progress: 1, total: 2 } }); + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'tok', progress: 2, total: 2 } }); + return { content: [{ type: 'text', text: 'streamed' }] }; + }); + return s; + }; + const tx = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await makeServer().connect(tx); + + try { + // A handler that emits request-scoped notifications: SSE, notifications in + // order, terminated by the final response. Never a session header. + const sse = await post(tx, { + jsonrpc: '2.0', + id: 'stream-1', + method: 'tools/call', + params: { name: 'progressing', arguments: {}, _meta: envelope() } + }); + expect(sse.status).toBe(200); + expect(sse.headers.get('content-type')).toBe('text/event-stream'); + expect(sse.headers.get('mcp-session-id')).toBeNull(); + + const events = parseSseEvents(await sse.text()); + expect(events).toHaveLength(3); + expect(events[0]).toMatchObject({ method: 'notifications/progress', params: { progress: 1 } }); + expect(events[1]).toMatchObject({ method: 'notifications/progress', params: { progress: 2 } }); + expect(events[2]).toMatchObject({ jsonrpc: '2.0', id: 'stream-1', result: {} }); + // The stream ended with the response: res.text() returning proves termination. + + // A handler that emits nothing: a single application/json object. + const json = await post(tx, { + jsonrpc: '2.0', + id: 'stream-2', + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'plain' }, _meta: envelope() } + }); + expect(json.status).toBe(200); + expect(json.headers.get('content-type')).toContain('application/json'); + expect(json.headers.get('mcp-session-id')).toBeNull(); + expect(await json.json()).toMatchObject({ jsonrpc: '2.0', id: 'stream-2' }); + } finally { + await tx.close(); + } +}); diff --git a/test/e2e/types.ts b/test/e2e/types.ts index c7ff6bdd80..bf011aeb70 100644 --- a/test/e2e/types.ts +++ b/test/e2e/types.ts @@ -14,7 +14,18 @@ export const KNOWN_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const; export type SpecVersion = (typeof KNOWN_SPEC_VERSIONS)[number]; /** The spec versions cells are registered for (the active matrix axis). */ -export const ALL_SPEC_VERSIONS = ['2025-11-25'] as const satisfies readonly SpecVersion[]; +export const ALL_SPEC_VERSIONS = ['2025-11-25', '2026-07-28'] as const satisfies readonly SpecVersion[]; + +/** + * The revision the SDK's default (initialize-era) negotiation lands on. Bodies that do not + * consume the `protocolVersion` axis exercise exactly this revision, so requirements without + * an explicit `addedInSpecVersion` register cells here only — labelling a default-negotiation + * run with a later revision would claim coverage the body does not exercise. Requirements + * explicitly added in a later revision register across the bounds their fields admit (their + * bodies pin that revision's behavior). The release that flips the SDK default revisits this + * restriction together with the bodies it exists for. + */ +export const BASELINE_SPEC_VERSION = '2025-11-25' as const satisfies SpecVersion; /** * Arguments every test body receives. Expand with new matrix axes here so