From ef120b2be0c3c3d80468c3d4a9f79be30bb0c0a3 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:07:06 +0100 Subject: [PATCH 1/8] chore: add missing changesets for CommonJS builds and codemod iterations (#2412) --- .changeset/cjs-dual-builds.md | 13 +++++++++++++ .changeset/codemod-iterations-5.md | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 .changeset/cjs-dual-builds.md create mode 100644 .changeset/codemod-iterations-5.md diff --git a/.changeset/cjs-dual-builds.md b/.changeset/cjs-dual-builds.md new file mode 100644 index 0000000000..e85f418af3 --- /dev/null +++ b/.changeset/cjs-dual-builds.md @@ -0,0 +1,13 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +'@modelcontextprotocol/server-legacy': minor +'@modelcontextprotocol/codemod': minor +'@modelcontextprotocol/express': minor +'@modelcontextprotocol/hono': minor +'@modelcontextprotocol/fastify': minor +'@modelcontextprotocol/node': minor +--- + +Ship CommonJS builds alongside ESM for all v2 packages, so `require()` consumers and CJS-only toolchains can use the SDK without a bundler shim. diff --git a/.changeset/codemod-iterations-5.md b/.changeset/codemod-iterations-5.md new file mode 100644 index 0000000000..fe6f5e8499 --- /dev/null +++ b/.changeset/codemod-iterations-5.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +v1-to-v2 migration fixes from continued real-world migrations (codemod iterations 5). From 448ba0ff54b10a63260bc5684d459e4cea9df8d2 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:20:43 +0100 Subject: [PATCH 2/8] chore: drop duplicate CommonJS changeset (#2414) --- .changeset/cjs-dual-builds.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .changeset/cjs-dual-builds.md diff --git a/.changeset/cjs-dual-builds.md b/.changeset/cjs-dual-builds.md deleted file mode 100644 index e85f418af3..0000000000 --- a/.changeset/cjs-dual-builds.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@modelcontextprotocol/server': minor -'@modelcontextprotocol/client': minor -'@modelcontextprotocol/core': minor -'@modelcontextprotocol/server-legacy': minor -'@modelcontextprotocol/codemod': minor -'@modelcontextprotocol/express': minor -'@modelcontextprotocol/hono': minor -'@modelcontextprotocol/fastify': minor -'@modelcontextprotocol/node': minor ---- - -Ship CommonJS builds alongside ESM for all v2 packages, so `require()` consumers and CJS-only toolchains can use the SDK without a bundler shim. From eacf2e5dbeb1fa65492750c15fdc55cafdf28686 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:12:49 +0100 Subject: [PATCH 3/8] Add --protocol-version flag to cli-client example (#2406) --- examples/cli-client/README.md | 19 ++++++------ examples/cli-client/cli.ts | 36 ++++++++++++---------- examples/cli-client/host/host.ts | 43 ++++++++++++++++++++++++--- examples/cli-client/test/host.test.ts | 23 ++++++++++++++ 4 files changed, 92 insertions(+), 29 deletions(-) diff --git a/examples/cli-client/README.md b/examples/cli-client/README.md index c9eca01ccb..c75d086e12 100644 --- a/examples/cli-client/README.md +++ b/examples/cli-client/README.md @@ -48,7 +48,7 @@ pnpm --filter @mcp-examples/todos-server start:http ANTHROPIC_API_KEY=sk-… pnpm --filter @mcp-examples/cli-client start -- --server http://127.0.0.1:3000/mcp --provider anthropic ``` -The status line shows what was negotiated — `connected to "todos" (2026-07-28, 8 tools, 2 resources, 2 prompts)`. Add `--legacy` in terminal B to force the 2025-era handshake against the same server and watch the legacy arms of every feature run instead (`connected to "todos" (2025-11-25, …)`). +The status line shows what was negotiated — `connected to "todos" (2026-07-28, 8 tools, 2 resources, 2 prompts)`. Add `--legacy` in terminal B to force the 2025-era handshake against the same server and watch the legacy arms of every feature run instead (`connected to "todos" (2025-11-25, …)`). To hold the connection to one exact revision, use `--protocol-version 2025-06-18` (or any supported revision) — the connection fails rather than settle on anything else. A tour that touches everything, in one sitting: @@ -111,14 +111,15 @@ For a persistent setup, copy `config.example.json` to `config.json` (or pass `-- ## All flags ```text ---server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) ---config mcpServers config file (default: ./config.json, falling back to spawning todos-server) ---provider scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted) ---model pin a model id (default: the provider's latest mid-tier model) ---root workspace root exposed to servers via roots/list (repeatable; default: cwd) ---callback-port fixed loopback port for the OAuth callback (default: a free port) ---legacy use the 2025 initialize handshake instead of probing for 2026-07-28 --h, --help show usage +--server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) +--config mcpServers config file (default: ./config.json, falling back to spawning todos-server) +--provider scripted | anthropic | openai | gemini (default: first one with a key in the env, else scripted) +--model pin a model id (default: the provider's latest mid-tier model) +--root workspace root exposed to servers via roots/list (repeatable; default: cwd) +--callback-port fixed loopback port for the OAuth callback (default: a free port) +--legacy use the 2025 initialize handshake instead of probing for 2026-07-28 +--protocol-version negotiate exactly this revision: 2025-era values (e.g. 2025-06-18) via the legacy handshake, 2026-07-28+ via a modern pin +-h, --help show usage ``` ## How this example is tested diff --git a/examples/cli-client/cli.ts b/examples/cli-client/cli.ts index 84b13df8ab..5b29c5a848 100644 --- a/examples/cli-client/cli.ts +++ b/examples/cli-client/cli.ts @@ -24,14 +24,15 @@ import type { LLMProvider } from './providers/provider'; import { ScriptedProvider } from './providers/scripted'; const USAGE = `usage: tsx cli.ts [options] - --server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) - --config mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server) - --provider scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted) - --model pin a model id (default: the provider's latest mid-tier model) - --root workspace root exposed to servers via roots/list (repeatable; default: cwd) - --callback-port fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH) - --legacy use the 2025 initialize handshake instead of probing for 2026-07-28 - --help this help`; + --server connect to just this server: an http(s) URL (OAuth on demand) or a stdio command line (repeatable) + --config mcpServers config file (default: ./config.json, falling back to spawning the sibling todos-server) + --provider scripted | anthropic | openai | gemini (default: first one with an API key in the env, else scripted) + --model pin a model id (default: the provider's latest mid-tier model) + --root workspace root exposed to servers via roots/list (repeatable; default: cwd) + --callback-port fixed loopback port for the OAuth callback (default: a free port; set this when port-forwarding over SSH) + --legacy use the 2025 initialize handshake instead of probing for 2026-07-28 + --protocol-version negotiate exactly this revision: 2025-era values (e.g. 2025-06-18) via the legacy handshake, 2026-07-28+ via a modern pin + --help this help`; function pickProvider(name: string | undefined, model: string | undefined): LLMProvider { const chosen = @@ -73,6 +74,7 @@ const { values } = parseArgs({ root: { type: 'string', multiple: true }, 'callback-port': { type: 'string' }, legacy: { type: 'boolean' }, + 'protocol-version': { type: 'string' }, help: { type: 'boolean', short: 'h' } } }); @@ -111,15 +113,17 @@ for (const [serverName, entry] of Object.entries(config.mcpServers)) { ui.status(` ${serverName} → ${'url' in entry ? entry.url : [entry.command, ...(entry.args ?? [])].join(' ')}`); } -const host = new McpHost({ - ui, - provider, - roots: values.root ?? [process.cwd()], - legacy: values.legacy ?? false, - oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined -}); -hostRef.current = host; +let host: McpHost; try { + host = new McpHost({ + ui, + provider, + roots: values.root ?? [process.cwd()], + legacy: values.legacy ?? false, + protocolVersion: values['protocol-version'], + oauthCallbackPort: values['callback-port'] ? Number.parseInt(values['callback-port'], 10) : undefined + }); + hostRef.current = host; await host.connect(config); } catch (error) { ui.print(error instanceof Error ? error.message : String(error)); diff --git a/examples/cli-client/host/host.ts b/examples/cli-client/host/host.ts index 15d179b217..49165c9554 100644 --- a/examples/cli-client/host/host.ts +++ b/examples/cli-client/host/host.ts @@ -10,7 +10,8 @@ import type { Prompt, Resource, ResourceTemplateType, - Tool + Tool, + VersionNegotiationOptions } from '@modelcontextprotocol/client'; import { Client, @@ -18,6 +19,7 @@ import { ProtocolError, SdkError, StreamableHTTPClientTransport, + SUPPORTED_PROTOCOL_VERSIONS, UnauthorizedError } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; @@ -61,10 +63,43 @@ export interface McpHostOptions { roots?: string[]; /** Use the 2025 `initialize` handshake instead of probing for 2026-07-28. */ legacy?: boolean; + /** + * Negotiate exactly this protocol revision: a known 2025-era value runs the legacy + * handshake offering only that revision; anything else is pinned via the modern + * handshake, which fails loudly unless the server offers it. + */ + protocolVersion?: string; /** Fixed loopback port for the OAuth callback (default: an OS-assigned free port). Useful over SSH port-forwarding. */ oauthCallbackPort?: number; } +/** The version-negotiation slice of the SDK client options every connection this host makes shares. */ +export interface VersionOptions { + versionNegotiation: VersionNegotiationOptions; + supportedProtocolVersions?: string[]; +} + +/** + * Map the era toggle and optional pinned revision onto the SDK's negotiation options. + * A known 2025-era revision runs the legacy handshake offering exactly that revision + * (the client rejects a server that answers with any other version); everything else + * becomes a modern pin, and the SDK's own typed error covers strings that are neither. + */ +export function resolveVersionOptions(legacy: boolean, protocolVersion?: string): VersionOptions { + if (protocolVersion === undefined) { + return { versionNegotiation: { mode: legacy ? 'legacy' : 'auto' } }; + } + if (SUPPORTED_PROTOCOL_VERSIONS.includes(protocolVersion)) { + return { versionNegotiation: { mode: 'legacy' }, supportedProtocolVersions: [protocolVersion] }; + } + if (legacy) { + throw new Error( + `--legacy conflicts with --protocol-version ${protocolVersion}: the 2025 handshake can only negotiate ${SUPPORTED_PROTOCOL_VERSIONS.join(', ')}` + ); + } + return { versionNegotiation: { mode: { pin: protocolVersion } } }; +} + function unwrapUnauthorized(error: unknown): UnauthorizedError | undefined { if (error instanceof UnauthorizedError) return error; // Under versionNegotiation 'auto', a connect-time 401 surfaces as @@ -94,7 +129,7 @@ function samplingContentToParts(content: CreateMessageRequest['params']['message export class McpHost { private readonly ui: HostUI; private readonly provider: LLMProvider; - private readonly legacy: boolean; + private readonly versionOptions: VersionOptions; private roots: string[]; private readonly watches: McpSubscription[] = []; private readonly oauthCallbackPort?: number; @@ -103,7 +138,7 @@ export class McpHost { constructor(options: McpHostOptions) { this.ui = options.ui; this.provider = options.provider; - this.legacy = options.legacy ?? false; + this.versionOptions = resolveVersionOptions(options.legacy ?? false, options.protocolVersion); this.oauthCallbackPort = options.oauthCallbackPort; this.roots = (options.roots ?? [process.cwd()]).map(root => path.resolve(root)); } @@ -309,7 +344,7 @@ export class McpHost { private buildClient(name: string): Client { const client = new Client(CLIENT_INFO, { - versionNegotiation: { mode: this.legacy ? 'legacy' : 'auto' }, + ...this.versionOptions, capabilities: { // Both elicitation modes are declared because the handler below implements both. elicitation: { form: {}, url: {} }, diff --git a/examples/cli-client/test/host.test.ts b/examples/cli-client/test/host.test.ts index 428b79a820..8ff28d1e02 100644 --- a/examples/cli-client/test/host.test.ts +++ b/examples/cli-client/test/host.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { configFromTargets, interpolateEnv, isHttpServer, parseConfig } from '../host/config'; import { contentBlockToParts, resourceToContextText, stripAnsi, toolResultToParts, truncate } from '../host/content'; +import { resolveVersionOptions } from '../host/host'; import { namespaceTool, routeNamespacedTool, sanitizeServerName } from '../host/naming'; describe('tool namespacing and routing', () => { @@ -94,3 +95,25 @@ describe('config parsing', () => { expect(() => configFromTargets([])).toThrow(); }); }); + +describe('protocol version selection', () => { + it('defaults to auto probing, --legacy to the plain 2025 handshake', () => { + expect(resolveVersionOptions(false)).toEqual({ versionNegotiation: { mode: 'auto' } }); + expect(resolveVersionOptions(true)).toEqual({ versionNegotiation: { mode: 'legacy' } }); + }); + + it('runs a known 2025-era revision through the legacy handshake, offering only that revision', () => { + const expected = { versionNegotiation: { mode: 'legacy' }, supportedProtocolVersions: ['2025-06-18'] }; + expect(resolveVersionOptions(false, '2025-06-18')).toEqual(expected); + // --legacy alongside a 2025-era revision is redundant but consistent. + expect(resolveVersionOptions(true, '2025-06-18')).toEqual(expected); + }); + + it('pins anything newer via the modern handshake', () => { + expect(resolveVersionOptions(false, '2026-07-28')).toEqual({ versionNegotiation: { mode: { pin: '2026-07-28' } } }); + }); + + it('rejects --legacy combined with a revision the 2025 handshake cannot reach', () => { + expect(() => resolveVersionOptions(true, '2026-07-28')).toThrow(/--legacy conflicts with --protocol-version/); + }); +}); From 1ea960b5bbf1d2081d61575467690b8f38f96c1f Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:16:52 +0100 Subject: [PATCH 4/8] chore(examples): bump better-auth to ^1.6.2 (#2415) --- examples/shared/package.json | 2 +- pnpm-lock.yaml | 190 ++++++++++++++++++----------------- 2 files changed, 100 insertions(+), 92 deletions(-) diff --git a/examples/shared/package.json b/examples/shared/package.json index 71bc9c34a1..95205b6135 100644 --- a/examples/shared/package.json +++ b/examples/shared/package.json @@ -34,7 +34,7 @@ "dependencies": { "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/express": "workspace:^", - "better-auth": "^1.4.17", + "better-auth": "^1.6.2", "better-sqlite3": "^12.6.2", "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9979795255..496cfefe2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -966,8 +966,8 @@ importers: specifier: workspace:^ version: link:../../packages/server better-auth: - specifier: ^1.4.17 - version: 1.5.6(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)) + specifier: ^1.6.2 + version: 1.6.20(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)) better-sqlite3: specifier: ^12.6.2 version: 12.8.0 @@ -2188,62 +2188,64 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@better-auth/core@1.5.6': - resolution: {integrity: sha512-Ez9DZdIMFyxHremmoLz1emFPGNQomDC1jqqBPnZ6Ci+6TiGN3R9w/Y03cJn6I8r1ycKgOzeVMZtJ/erOZ27Gsw==} + '@better-auth/core@1.6.20': + resolution: {integrity: sha512-y73I1xNXuNYiHBFduWGRcJ2ro2rNuVDEYkgVMJtIaRXtbosdXHs9gfyQrHecgeHMHKx1SYSBT/CExak0vVMTng==} peerDependencies: - '@better-auth/utils': 0.3.1 - '@better-fetch/fetch': 1.1.21 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 '@cloudflare/workers-types': '>=4' '@opentelemetry/api': ^1.9.0 - better-call: 1.3.2 + better-call: 1.3.6 jose: ^6.1.0 - kysely: ^0.28.5 + kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 peerDependenciesMeta: '@cloudflare/workers-types': optional: true + '@opentelemetry/api': + optional: true - '@better-auth/drizzle-adapter@1.5.6': - resolution: {integrity: sha512-VfFFmaoFw3ug12SiSuIwzrMoHyIVmkMGWm9gZ4sXdYYVX4HboCL4m3fjzOhppcmK5OGatRuU+N1UX6wxCITcXw==} + '@better-auth/drizzle-adapter@1.6.20': + resolution: {integrity: sha512-hJHfCdAiZrC7EmZAt3NAiGgcNo9Y5Qz3PLL+a9rODXaAJGCMvzUJniqef9wHuJBwU0SWW+2f4wXe8xQmaC/IKQ==} peerDependencies: - '@better-auth/core': 1.5.6 - '@better-auth/utils': ^0.3.0 - drizzle-orm: '>=0.41.0' + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.5.6': - resolution: {integrity: sha512-Fnf+h8WVKtw6lEOmVmiVVzDf3shJtM60AYf9XTnbdCeUd6MxN/KnaJZpkgtYnRs7a+nwtkVB+fg4lGETebGFXQ==} + '@better-auth/kysely-adapter@1.6.20': + resolution: {integrity: sha512-Uvpmgbx5y8JqXroVanNzDdKzOl3HojoTz+/X6MR6zOUr25IzlYz660mjnu0rxKiIF55kD3CroqFsDzjNUw7ERw==} peerDependencies: - '@better-auth/core': 1.5.6 - '@better-auth/utils': ^0.3.0 - kysely: ^0.27.0 || ^0.28.0 + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.5.6': - resolution: {integrity: sha512-rS7ZsrIl5uvloUgNN0u9LOZJMMXnsZXVdUZ3MrTBKWM2KpoJjzPr9yN3Szyma5+0V7SltnzSGHPkYj2bEzzmlA==} + '@better-auth/memory-adapter@1.6.20': + resolution: {integrity: sha512-J5Ni0LlFijbzXlwu2rFHaD8zEFocmajyzWkRnHsq8LhV/Dk4iWQwwnqzLrPoDQEj8roECAUF03hrIeMzqWRqJQ==} peerDependencies: - '@better-auth/core': 1.5.6 - '@better-auth/utils': ^0.3.0 + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.5.6': - resolution: {integrity: sha512-6+M3MS2mor8fTUV3EI1FBLP0cs6QfbN+Ovx9+XxR/GdfKIBoNFzmPEPRbdGt+ft6PvrITsUm+T70+kkHgVSP6w==} + '@better-auth/mongo-adapter@1.6.20': + resolution: {integrity: sha512-ClDBJf6h4g85WJswxwQwxLaiyRU67Gmz/uaIf19tY1gqlLJDykSGjmqRNSBMG5rWABNzcNqbO4KG31rYUldbIw==} peerDependencies: - '@better-auth/core': 1.5.6 - '@better-auth/utils': ^0.3.0 + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.5.6': - resolution: {integrity: sha512-UxY9vQJs1Tt+O+T2YQnseDMlWmUSQvFZSBb5YiFRg7zcm+TEzujh4iX2/csA0YiZptLheovIuVWTP9nriewEBA==} + '@better-auth/prisma-adapter@1.6.20': + resolution: {integrity: sha512-WhYdhSGuVSfu1peCSf2snmmVzfWjRaEvbSrsNCusiwGE9l94HlES4mjSPM48fed24hL7yg4j1dYK/yjEt87FpQ==} peerDependencies: - '@better-auth/core': 1.5.6 - '@better-auth/utils': ^0.3.0 + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 peerDependenciesMeta: @@ -2252,16 +2254,18 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.5.6': - resolution: {integrity: sha512-yXC7NSxnIFlxDkGdpD7KA+J9nqIQAPCJKe77GoaC5bWoe/DALo1MYorZfTgOafS7wrslNtsPT4feV/LJi1ubqQ==} + '@better-auth/telemetry@1.6.20': + resolution: {integrity: sha512-3BhbY3naQDERvdJvJ7fGszVY6rpsVfc6c9uyBVZlC1coVEF/rkM0rIcjtMVI1GUH7vWy1wjR6qF5vQnMun3XNQ==} peerDependencies: - '@better-auth/core': 1.5.6 + '@better-auth/core': ^1.6.20 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 - '@better-auth/utils@0.3.1': - resolution: {integrity: sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg==} + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} - '@better-fetch/fetch@1.1.21': - resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} '@cfworker/json-schema@4.1.1': resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==} @@ -4243,8 +4247,8 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} - better-auth@1.5.6: - resolution: {integrity: sha512-QSpJTqaT1XVfWRQe/fm3PgeuwOIlz1nWX/Dx7nsHStJ382bLzmDbQk2u7IT0IJ6wS5SRxfqEE1Ev9TXontgyAQ==} + better-auth@1.6.20: + resolution: {integrity: sha512-fSpGHGRKiGRiYVd3QTQtuVZ8oxpiSe/7ip0Rpvt/Sy8zQbEbVKUPMOhE0gLXg+FjqTUsIo7582hxUYxtEcqUpA==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -4253,7 +4257,7 @@ packages: '@tanstack/solid-start': ^1.0.0 better-sqlite3: ^12.0.0 drizzle-kit: '>=0.31.4' - drizzle-orm: '>=0.41.0' + drizzle-orm: ^0.45.2 mongodb: ^6.0.0 || ^7.0.0 mysql2: ^3.0.0 next: ^14.0.0 || ^15.0.0 || ^16.0.0 @@ -4305,8 +4309,8 @@ packages: vue: optional: true - better-call@1.3.2: - resolution: {integrity: sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw==} + better-call@1.3.6: + resolution: {integrity: sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -5466,9 +5470,9 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} - kysely@0.28.14: - resolution: {integrity: sha512-SU3lgh0rPvq7upc6vvdVrCsSMUG1h3ChvHVOY7wJ2fw4C9QEB7X3d5eyYEyULUX7UQtxZJtZXGuT6U2US72UYA==} - engines: {node: '>=20.0.0'} + kysely@0.29.2: + resolution: {integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==} + engines: {node: '>=22.0.0'} lefthook-darwin-arm64@2.1.4: resolution: {integrity: sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw==} @@ -7005,55 +7009,58 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)': + '@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0)': dependencies: - '@better-auth/utils': 0.3.1 - '@better-fetch/fetch': 1.1.21 - '@opentelemetry/api': 1.9.1 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 '@opentelemetry/semantic-conventions': 1.40.0 '@standard-schema/spec': 1.1.0 - better-call: 1.3.2(zod@4.3.6) + better-call: 1.3.6(zod@4.3.6) jose: 6.2.2 - kysely: 0.28.14 + kysely: 0.29.2 nanostores: 1.2.0 zod: 4.3.6 + optionalDependencies: + '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + '@better-auth/drizzle-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/utils': 0.4.2 - '@better-auth/kysely-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14)': + '@better-auth/kysely-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/utils': 0.4.2 optionalDependencies: - kysely: 0.28.14 + kysely: 0.29.2 - '@better-auth/memory-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + '@better-auth/memory-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + '@better-auth/mongo-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)': + '@better-auth/prisma-adapter@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) - '@better-auth/utils': 0.3.1 + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))': + '@better-auth/telemetry@1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) - '@better-auth/utils': 0.3.1 - '@better-fetch/fetch': 1.1.21 + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 - '@better-auth/utils@0.3.1': {} + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.0.1 - '@better-fetch/fetch@1.1.21': {} + '@better-fetch/fetch@1.3.1': {} '@cfworker/json-schema@4.1.1': {} @@ -7890,7 +7897,8 @@ snapshots: dependencies: '@octokit/openapi-types': 27.0.0 - '@opentelemetry/api@1.9.1': {} + '@opentelemetry/api@1.9.1': + optional: true '@opentelemetry/semantic-conventions@1.40.0': {} @@ -8803,23 +8811,23 @@ snapshots: before-after-hook@4.0.0: {} - better-auth@1.5.6(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)): - dependencies: - '@better-auth/core': 1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0) - '@better-auth/drizzle-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) - '@better-auth/kysely-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(kysely@0.28.14) - '@better-auth/memory-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) - '@better-auth/mongo-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) - '@better-auth/prisma-adapter': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0))(@better-auth/utils@0.3.1) - '@better-auth/telemetry': 1.5.6(@better-auth/core@1.5.6(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@opentelemetry/api@1.9.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.14)(nanostores@1.2.0)) - '@better-auth/utils': 0.3.1 - '@better-fetch/fetch': 1.1.21 + better-auth@1.6.20(@opentelemetry/api@1.9.1)(better-sqlite3@12.8.0)(vitest@4.1.2(@opentelemetry/api@1.9.1)(@types/node@25.5.0)(vite@7.3.0(@types/node@25.5.0)(tsx@4.21.0)(yaml@2.8.3)))(vue@3.5.38(typescript@5.9.3)): + dependencies: + '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0) + '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) + '@better-auth/memory-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.3.6))(jose@6.2.2)(kysely@0.29.2)(nanostores@1.2.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.1.1 '@noble/hashes': 2.0.1 - better-call: 1.3.2(zod@4.3.6) + better-call: 1.3.6(zod@4.3.6) defu: 6.1.4 jose: 6.2.2 - kysely: 0.28.14 + kysely: 0.29.2 nanostores: 1.2.0 zod: 4.3.6 optionalDependencies: @@ -8830,10 +8838,10 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.2(zod@4.3.6): + better-call@1.3.6(zod@4.3.6): dependencies: - '@better-auth/utils': 0.3.1 - '@better-fetch/fetch': 1.1.21 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 rou3: 0.7.12 set-cookie-parser: 3.1.0 optionalDependencies: @@ -10172,7 +10180,7 @@ snapshots: kleur@4.1.5: {} - kysely@0.28.14: {} + kysely@0.29.2: {} lefthook-darwin-arm64@2.1.4: optional: true From 3c7ddafa05d8f17fb52168bf4638f09251c3d0ff Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:17:08 +0100 Subject: [PATCH 5/8] test(conformance): bump referee to 0.2.0-alpha.9; arm SEP-2575 diagnostic fixtures; fix post-dispatch -32021 HTTP status (#2399) --- .changeset/post-dispatch-32021-http-400.md | 6 ++ docs/migration/support-2026-07-28.md | 12 ++++ .../src/shared/inboundClassification.ts | 23 ++++++-- .../test/shared/errorHttpStatusMatrix.test.ts | 41 +++++++++---- .../shared/inboundLadderCellSheet.test.ts | 45 ++------------- .../server/src/server/createMcpHandler.ts | 8 ++- .../server/src/server/perRequestTransport.ts | 33 ++++++++--- .../test/server/perRequestTransport.test.ts | 38 +++++++++++++ pnpm-lock.yaml | 10 ++-- .../expected-failures.2026-07-28.yaml | 7 ++- test/conformance/expected-failures.yaml | 4 +- test/conformance/package.json | 2 +- test/conformance/src/everythingServer.ts | 57 +++++++++++++++++++ 13 files changed, 211 insertions(+), 75 deletions(-) create mode 100644 .changeset/post-dispatch-32021-http-400.md diff --git a/.changeset/post-dispatch-32021-http-400.md b/.changeset/post-dispatch-32021-http-400.md new file mode 100644 index 0000000000..5f51fe0d62 --- /dev/null +++ b/.changeset/post-dispatch-32021-http-400.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/core-internal': patch +--- + +Return HTTP 400 for a `MissingRequiredClientCapabilityError` (`-32021`) produced after dispatch. The spec mandates `400 Bad Request` for this error with no condition on where it arose, but only the pre-dispatch capability gate honored that; the post-handler emission — the `input_required` gate rejecting an embedded request whose required capability the caller did not declare — surfaced in-band on HTTP 200. The JSON-RPC error body is unchanged, every other error code (including a handler relaying a downstream peer's `-32020`/`-32022`) keeps the origin-keyed in-band behavior, and the mapping only applies while the response is uncommitted: an exchange that already streamed — or one hosted with `responseMode: 'sse'`, which opens its stream at dispatch end — keeps its committed 200 and carries the error in-stream. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 28e5e5c1ea..a9d603d11a 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -157,6 +157,18 @@ fetch-shaped handler. > `toNodeHandler(handler)` and add the `@modelcontextprotocol/node` import. > `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from > `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`. +> +> Also: a `MissingRequiredClientCapabilityError` (`-32021`) produced **after** dispatch +> — the `input_required` gate refusing an embedded request whose capability the caller +> did not declare — now answers HTTP **400** (earlier alphas surfaced it in-band on +> 200). The spec mandates 400 for this error wherever it arises; the JSON-RPC body is +> unchanged. This applies to a handler-thrown `-32021` too: a proxy relaying a +> downstream server's `-32021` should translate it (its `requiredCapabilities` +> describes the downstream hop's envelope) rather than rethrow the bare error. Every +> other handler-produced code (including a relayed `-32020`/`-32022`) +> keeps the in-band 200, and an exchange whose response stream is already open — the +> handler streamed first, or `responseMode: 'sse'` — keeps its committed 200 and +> carries the error in-stream. ### Server over stdio / long-lived connections: `serveStdio` diff --git a/packages/core-internal/src/shared/inboundClassification.ts b/packages/core-internal/src/shared/inboundClassification.ts index bffaae0645..35f694cae1 100644 --- a/packages/core-internal/src/shared/inboundClassification.ts +++ b/packages/core-internal/src/shared/inboundClassification.ts @@ -371,7 +371,9 @@ export const INBOUND_VALIDATION_LADDER: readonly InboundValidationRungDescriptor * the ladder (or a pre-handler protocol gate) produced. Errors produced by * request handlers — whatever their code — stay in-band on HTTP 200, and are * never mapped to an HTTP status by this table; in particular `-32603` and - * domain-specific codes never become a blanket 500. + * domain-specific codes never become a blanket 500. The single exception is + * `MissingRequiredClientCapability` (-32021) — see + * {@linkcode httpStatusForErrorCode}. * * `-32602` (invalid params) deliberately has NO entry: the only invalid-params * rejection that maps to HTTP 400 is the classifier's own envelope rung @@ -390,11 +392,24 @@ export const LADDER_ERROR_HTTP_STATUS: Readonly> = { /** * The HTTP status to answer a JSON-RPC error with, keyed on the error's * origin. `in-band` errors (anything produced by a request handler) are - * always HTTP 200 — the JSON-RPC error response is the payload, not an HTTP - * failure. `ladder` errors map through {@linkcode LADDER_ERROR_HTTP_STATUS}. + * HTTP 200 — the JSON-RPC error response is the payload, not an HTTP + * failure — with ONE exception: `MissingRequiredClientCapability` (-32021), + * whose 400 the spec mandates on the error itself with no origin condition, + * and which the SDK genuinely produces after dispatch (the `input_required` + * capability gate). A handler relaying some downstream peer's `-32020`/`-32022` + * is NOT that peer's spec error and stays in-band like every other handler + * code. `ladder` errors map through {@linkcode LADDER_ERROR_HTTP_STATUS}. + * + * The per-request transport intentionally does NOT delegate to this function: + * its `?? 400` ladder fallback is only correct for entry-gate codes known to + * the table, and would wrongly map dispatch-window errors outside it (a + * window `-32602` must stay in-band on 200). The transport indexes the table + * directly; keep the two in agreement when editing either. */ export function httpStatusForErrorCode(code: number, origin: 'ladder' | 'in-band'): number { - if (origin === 'in-band') return 200; + if (origin === 'in-band') { + return code === ProtocolErrorCode.MissingRequiredClientCapability ? 400 : 200; + } return LADDER_ERROR_HTTP_STATUS[code] ?? 400; } diff --git a/packages/core-internal/test/shared/errorHttpStatusMatrix.test.ts b/packages/core-internal/test/shared/errorHttpStatusMatrix.test.ts index d053f9eae8..db5711775a 100644 --- a/packages/core-internal/test/shared/errorHttpStatusMatrix.test.ts +++ b/packages/core-internal/test/shared/errorHttpStatusMatrix.test.ts @@ -6,9 +6,13 @@ * * - errors produced by the validation ladder or a pre-handler protocol gate * map through the table (`-32601` → 404; the small mandated 400 set); - * - everything a request handler produces — whatever its code, including - * `-32603`, `-32602` and domain-specific codes — stays in-band on HTTP 200, - * never a blanket 500; + * - everything a request handler produces — including `-32603`, `-32602` and + * domain-specific codes — stays in-band on HTTP 200, never a blanket 500; + * - EXCEPT `-32021` (MissingRequiredClientCapability): the spec mandates its + * 400 per-error with no origin condition, and the `input_required` + * capability gate genuinely emits it after dispatch — so it alone is + * status-mapped wherever it arises. A handler relaying a downstream peer's + * `-32020`/`-32022` is not that peer's spec error and stays in-band; * - `-32602` deliberately has no table entry: the classifier's envelope rung * carries its own HTTP 400 and is the only invalid-params rejection that * maps to 400. @@ -45,7 +49,7 @@ describe('the status matrix — pinned cells', () => { expect(httpStatusForErrorCode(row.code, 'ladder')).toBe(row.status); }); - test('every code stays in-band on HTTP 200 when handler-originated — including internal errors and domain codes', () => { + test('every code except -32021 stays in-band on HTTP 200 when handler-originated — including internal errors and domain codes', () => { const handlerCodes = [ ProtocolErrorCode.InternalError, ProtocolErrorCode.InvalidParams, @@ -61,6 +65,16 @@ describe('the status matrix — pinned cells', () => { } }); + test('-32021 is the single code-keyed exception: its spec-mandated 400 applies wherever it arises', () => { + expect(httpStatusForErrorCode(ProtocolErrorCode.MissingRequiredClientCapability, 'in-band')).toBe(400); + expect(httpStatusForErrorCode(ProtocolErrorCode.MissingRequiredClientCapability, 'ladder')).toBe(400); + // The relay contract for the OTHER two spec-defined HTTP errors is + // origin-keyed: a handler-relayed -32020/-32022 is not this server's + // spec error and stays in-band. + expect(httpStatusForErrorCode(HEADER_MISMATCH_ERROR_CODE, 'in-band')).toBe(200); + expect(httpStatusForErrorCode(ProtocolErrorCode.UnsupportedProtocolVersion, 'in-band')).toBe(200); + }); + test('-32603 never becomes a blanket 500: handler-originated internal errors are in-band', () => { expect(LADDER_ERROR_HTTP_STATUS[ProtocolErrorCode.InternalError]).toBeUndefined(); expect(httpStatusForErrorCode(ProtocolErrorCode.InternalError, 'in-band')).toBe(200); @@ -71,12 +85,19 @@ describe('the status matrix — pinned cells', () => { expect(httpStatusForErrorCode(ProtocolErrorCode.InvalidParams, 'in-band')).toBe(200); }); - test('the table is exactly the mandated set (no silent growth)', () => { - expect( - Object.keys(LADDER_ERROR_HTTP_STATUS) - .map(Number) - .sort((a, b) => a - b) - ).toEqual([-32_700, -32_601, -32_600, -32_022, -32_021, -32_020].sort((a, b) => a - b)); + test('the table is exactly the mandated set, keys and values (no silent growth)', () => { + // The parse-error and invalid-request rows joined the table when the + // status matrix was completed alongside the cache fill / capability + // gate work; they were previously carried only by the classifier's own + // httpStatus on the rejection outcomes (same 400, now table-visible). + expect(LADDER_ERROR_HTTP_STATUS).toEqual({ + [-32_700]: 400, + [-32_601]: 404, + [-32_600]: 400, + [-32_022]: 400, + [-32_021]: 400, + [-32_020]: 400 + }); }); }); diff --git a/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts b/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts index 78613d4940..ace5d9d2c3 100644 --- a/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts +++ b/packages/core-internal/test/shared/inboundLadderCellSheet.test.ts @@ -20,13 +20,7 @@ import { describe, expect, test } from 'vitest'; import type { InboundHttpRequest, InboundLadderRejection } from '../../src/shared/inboundClassification'; -import { - classifyInboundRequest, - httpStatusForErrorCode, - INBOUND_VALIDATION_LADDER, - LADDER_ERROR_HTTP_STATUS, - modernOnlyStrictRejection -} from '../../src/shared/inboundClassification'; +import { classifyInboundRequest, INBOUND_VALIDATION_LADDER, modernOnlyStrictRejection } from '../../src/shared/inboundClassification'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../src/types/constants'; const MODERN_REVISION = '2026-07-28'; @@ -397,37 +391,6 @@ describe('the validation ladder as data', () => { }); }); -describe('HTTP status mapping for ladder-originated errors (origin-keyed)', () => { - test('the table maps exactly the ladder-originated codes', () => { - // The parse-error and invalid-request rows joined the table when the - // status matrix was completed alongside the cache fill / capability - // gate work; they were previously carried only by the classifier's own - // httpStatus on the rejection outcomes (same 400, now table-visible). - expect(LADDER_ERROR_HTTP_STATUS).toEqual({ - [-32_700]: 400, - [-32_601]: 404, - [-32_600]: 400, - [-32_022]: 400, - [-32_021]: 400, - [-32_020]: 400 - }); - }); - - test('the table never maps invalid params: the classifier envelope short-circuit is the only -32602 -> 400 source', () => { - expect(Object.keys(LADDER_ERROR_HTTP_STATUS)).not.toContain(String(-32_602)); - expect(httpStatusForErrorCode(-32_602, 'in-band')).toBe(200); - }); - - test('handler-originated errors stay in-band on HTTP 200, whatever their code', () => { - for (const code of [-32_603, -32_602, -32_601, -32_022, -32_002, -32_000, 1234]) { - expect(httpStatusForErrorCode(code, 'in-band')).toBe(200); - } - }); - - test('ladder-originated codes map to their HTTP statuses', () => { - expect(httpStatusForErrorCode(-32_601, 'ladder')).toBe(404); - expect(httpStatusForErrorCode(-32_022, 'ladder')).toBe(400); - expect(httpStatusForErrorCode(-32_021, 'ladder')).toBe(400); - expect(httpStatusForErrorCode(-32_020, 'ladder')).toBe(400); - }); -}); +// The error→HTTP-status policy (LADDER_ERROR_HTTP_STATUS / httpStatusForErrorCode) +// is pinned in errorHttpStatusMatrix.test.ts — the single test surface for that +// module. This file pins the classifier's cells only. diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 609772bde5..3f7632d570 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -669,9 +669,11 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // Pre-dispatch capability gate: a request to a method whose processing // structurally requires a client capability the request's validated // envelope did not declare is refused here, before any instance is - // constructed or dispatched. Answering at the entry pins the - // spec-mandated HTTP 400 for this error; a handler-time emission would - // surface in-band on HTTP 200. + // constructed or dispatched. Answering at the entry short-circuits + // before factory construction and pins the spec-mandated HTTP 400 for + // this error unconditionally; a handler-time -32021 (the + // input_required gate) also maps to 400 at the per-request transport, + // but only while no response has been committed. if (route.messageKind === 'request') { const required = requiredClientCapabilitiesForRequest(route.message.method); if (required !== undefined) { diff --git a/packages/server/src/server/perRequestTransport.ts b/packages/server/src/server/perRequestTransport.ts index c9d24c6f63..4d96bc71c7 100644 --- a/packages/server/src/server/perRequestTransport.ts +++ b/packages/server/src/server/perRequestTransport.ts @@ -27,10 +27,14 @@ * stream, and request-header validation (which belongs to middleware). The * exchange is single-use; serving another request requires a new transport * (and, in the per-request serving model, a fresh server instance). + * + * Consumers wiring this transport into a custom entry (instead of + * `createMcpHandler`) inherit the spec's per-error HTTP mandate with it: a + * terminal `MissingRequiredClientCapabilityError` (-32021) MUST answer 400, + * which this transport applies whenever the response is still uncommitted. */ import type { AuthInfo, - JSONRPCErrorResponse, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, @@ -45,6 +49,7 @@ import { isJSONRPCRequest, isJSONRPCResultResponse, LADDER_ERROR_HTTP_STATUS, + ProtocolErrorCode, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; @@ -252,13 +257,27 @@ export class PerRequestHTTPServerTransport implements Transport { // validation ladder, the era registry gate and handoff check, a // missing handler — are answered with the mapped HTTP status from // the ladder table. Handler-produced errors, whatever their code, - // stay in-band on HTTP 200. Ladder rejections keep that mapped - // status in every response mode (the SSE upgrade is deferred to - // the first actual send), so a forced-`sse` exchange still - // answers pre-dispatch rejections as plain HTTP errors. + // stay in-band on HTTP 200 — except + // MissingRequiredClientCapability (-32021), whose 400 the spec + // mandates per-error with no origin condition and whose only + // SDK-produced post-window source (the input_required capability + // gate) cannot fire any earlier — a handler-minted -32021 gets + // the same 400; a handler RELAYING a downstream peer's + // -32020/-32022 is not that peer's spec error and stays in-band. + // Must agree with httpStatusForErrorCode (core-internal), which + // is deliberately NOT called here: its `?? 400` ladder fallback + // would wrongly map window codes outside the table. + // The mapping applies only while no response has been committed: + // once the stream is open — the handler streamed first, or the + // exchange is forced-`sse` (which settles its 200 at dispatch + // end) — the status is on the wire and the error rides the + // stream. Pre-dispatch ladder rejections always precede the + // forced-`sse` upgrade, so they keep their mapped status in every + // response mode. + const errorCode = isJSONRPCErrorResponse(message) ? message.error.code : undefined; const ladderStatus = - this._dispatchWindowOpen && isJSONRPCErrorResponse(message) - ? LADDER_ERROR_HTTP_STATUS[(message as JSONRPCErrorResponse).error.code] + errorCode !== undefined && (this._dispatchWindowOpen || errorCode === ProtocolErrorCode.MissingRequiredClientCapability) + ? LADDER_ERROR_HTTP_STATUS[errorCode] : undefined; if (ladderStatus !== undefined && this._sse === undefined) { this.settleResponse(Response.json(message, { status: ladderStatus, headers: { 'Content-Type': 'application/json' } })); diff --git a/packages/server/test/server/perRequestTransport.test.ts b/packages/server/test/server/perRequestTransport.test.ts index 035e81b83b..364955615a 100644 --- a/packages/server/test/server/perRequestTransport.test.ts +++ b/packages/server/test/server/perRequestTransport.test.ts @@ -199,6 +199,9 @@ describe('HTTP status mapping', () => { }); it('keeps a handler-thrown unsupported-protocol-version error in-band on HTTP 200', async () => { + // A handler relaying a downstream peer's -32022 is not THIS server + // rejecting the caller's version; like the -32601 relay above it must + // not be re-mapped just because the ladder table maps that code. const { server } = modernServer({ toolsCallHandler: async () => { throw new ProtocolError(-32_022, 'Unsupported protocol version: 2099-01-01'); @@ -210,6 +213,41 @@ describe('HTTP status mapping', () => { expect(errorOf(await response.json())?.code).toBe(-32_022); }); + it('maps a post-dispatch -32021 (MissingRequiredClientCapability) to HTTP 400: the spec mandates that status per-error', async () => { + const { server } = modernServer({ + toolsCallHandler: async () => { + throw new ProtocolError(-32_021, 'Missing required client capabilities: sampling', { + requiredCapabilities: { sampling: {} } + }); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(400); + // The spec shape: `requiredCapabilities` is a ClientCapabilities + // OBJECT, never an array. + expect(errorOf(await response.json())).toMatchObject({ code: -32_021, data: { requiredCapabilities: { sampling: {} } } }); + }); + + it('leaves a post-dispatch -32021 on the already-open HTTP 200 stream when the handler streamed first', async () => { + // Once the lazy SSE upgrade has happened, the 200 is committed — and + // the error must still REACH the client as the stream's terminal + // frame rather than being swallowed by the status-mapping arm. + const { server } = modernServer({ + toolsCallHandler: async ctx => { + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'capability-test', progress: 1 } }); + throw new ProtocolError(-32_021, 'Missing required client capabilities: sampling'); + } + }); + const transport = await connectedTransport(server); + const response = await transport.handleMessage(toolsCall()); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + const frames = (await response.text()).split('\n\n').filter(frame => frame.includes('data: ')); + const terminal = frames.at(-1)!; + expect(JSON.parse(terminal.split('data: ')[1]!)).toMatchObject({ id: 1, error: { code: -32_021 } }); + }); + it('keeps handler-produced invalid-params errors in-band on HTTP 200 (never status-mapped)', async () => { const { server } = modernServer({ toolsCallHandler: async () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 496cfefe2b..eb4442a9f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1855,8 +1855,8 @@ importers: specifier: workspace:^ version: link:../../packages/client '@modelcontextprotocol/conformance': - specifier: 0.2.0-alpha.7 - version: 0.2.0-alpha.7(@cfworker/json-schema@4.1.1) + specifier: 0.2.0-alpha.9 + version: 0.2.0-alpha.9(@cfworker/json-schema@4.1.1) '@modelcontextprotocol/core-internal': specifier: workspace:^ version: link:../../packages/core-internal @@ -3140,8 +3140,8 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@modelcontextprotocol/conformance@0.2.0-alpha.7': - resolution: {integrity: sha512-S3usVyTWdEqvJpyGnXAz6Uj4yboBwT44lrmMqaQtxKcw4PK8H5XfdH5NQqNmDl9/zQbk4oDxtXqzUyGqTbEDzg==} + '@modelcontextprotocol/conformance@0.2.0-alpha.9': + resolution: {integrity: sha512-Bi5P5TQlOQGPJxCT7UAHbpG7wsR7sNZskHGtCoZBo6vDu416D2FXPgM4wKbg91teIgj4HjGkhnzlvP7U2dszfQ==} hasBin: true '@modelcontextprotocol/sdk@1.29.0': @@ -7763,7 +7763,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@modelcontextprotocol/conformance@0.2.0-alpha.7(@cfworker/json-schema@4.1.1)': + '@modelcontextprotocol/conformance@0.2.0-alpha.9(@cfworker/json-schema@4.1.1)': dependencies: '@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6) '@octokit/rest': 22.0.1 diff --git a/test/conformance/expected-failures.2026-07-28.yaml b/test/conformance/expected-failures.2026-07-28.yaml index eebd8e1721..bcc454700b 100644 --- a/test/conformance/expected-failures.2026-07-28.yaml +++ b/test/conformance/expected-failures.2026-07-28.yaml @@ -30,5 +30,8 @@ client: [] server: [] # --- Carried-forward scenarios (also run by the 2025 legs) --- - # (empty: json-schema-2020-12 burned by SEP-2106 fixture; sep-2164-resource-not-found - # burned by the spec#2907 error-code renumber + alpha.5 referee.) + # (empty: json-schema-2020-12 burned by the SEP-2106 fixture; + # sep-2164-resource-not-found burned by the spec#2907 error-code renumber + + # alpha.5 referee; server-stateless burned by conformance#376 at alpha.9 — + # the referee's `requiredCapabilities` assertion now matches the schema's + # `ClientCapabilities` object, which is what this SDK emits.) diff --git a/test/conformance/expected-failures.yaml b/test/conformance/expected-failures.yaml index fe1e950a5d..79e5facbd2 100644 --- a/test/conformance/expected-failures.yaml +++ b/test/conformance/expected-failures.yaml @@ -2,7 +2,7 @@ # CI exits 0 if only these fail, exits 1 on unexpected failures or stale entries. # # Baseline established against the published @modelcontextprotocol/conformance -# release pinned in package.json (0.2.0-alpha.7). Newer conformance releases +# release pinned in package.json (0.2.0-alpha.9). Newer conformance releases # are adopted by deliberately bumping the package.json pin and reconciling # this file in the same change. # @@ -24,7 +24,7 @@ client: [] server: # --- SEP-2663 (io.modelcontextprotocol/tasks) — server SDK does not implement the tasks extension --- - # Extension-tagged scenarios; selected only by `--suite all` (the alpha.7 referee + # Extension-tagged scenarios; selected only by `--suite all` (the alpha.9 referee # has no server-side `--suite extensions`). The active/draft/2026 legs never select # them, so they cannot flag these entries as stale. `tasks-status-notifications` is # intentionally absent: the referee SKIPs it unconditionally (harness rewrite pending diff --git a/test/conformance/package.json b/test/conformance/package.json index f3e8fcd3e4..cf120715df 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -38,7 +38,7 @@ "test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all" }, "devDependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.7", + "@modelcontextprotocol/conformance": "0.2.0-alpha.9", "@modelcontextprotocol/client": "workspace:^", "@modelcontextprotocol/server": "workspace:^", "@modelcontextprotocol/core-internal": "workspace:^", diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index cc75173e10..425b4d6647 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -186,6 +186,63 @@ function createMcpServer() { }) ); + // SEP-2575 `server-stateless` diagnostic fixtures: the scenario hardcodes + // these three tool names, and at conformance alpha.8 (conformance#372) the + // checks behind them fail as untestable when the names are missing. + + // Requires the `sampling` client capability via an MRTR createMessage + // input request; the scenario calls it with empty clientCapabilities and + // expects -32021 over HTTP 400. + mcpServer.registerTool( + 'test_missing_capability', + { + description: 'SEP-2575: requires the `sampling` client capability (drives the -32021 undeclared-capability rejection)', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + if (ctx.mcpReq.inputResponses?.['llm_answer'] === undefined) { + return inputRequired({ + inputRequests: { + llm_answer: inputRequired.createMessage({ + messages: [{ role: 'user', content: { type: 'text', text: 'Reply with the single word: pong' } }], + maxTokens: 16 + }) + } + }); + } + return { content: [{ type: 'text', text: 'sampling round-trip complete' }] }; + } + ); + + // A plain successful call: the check only asserts that the response stream + // carries no independent top-level JSON-RPC request. It must not elicit + // (the scenario declares no `elicitation` capability); the referee's own + // reference server does not elicit here either. + mcpServer.registerTool( + 'test_streaming_elicitation', + { + description: 'SEP-2575: yields a response stream carrying no independent top-level JSON-RPC requests', + inputSchema: z.object({}) + }, + async (): Promise => ({ + content: [{ type: 'text', text: 'stream observed: result frames only, no top-level requests' }] + }) + ); + + // `ctx.mcpReq.log` is gated on the request's `_meta.logLevel`; the scenario + // omits it and asserts no notifications/message frame appears. + mcpServer.registerTool( + 'test_logging_tool', + { + description: 'SEP-2575: logs via ctx.mcpReq.log so the no-log-without-logLevel rule is exercised', + inputSchema: z.object({}) + }, + async (_args, ctx): Promise => { + await ctx.mcpReq.log('info', 'test_logging_tool ran (delivered only when the request set _meta.logLevel)'); + return { content: [{ type: 'text', text: 'logged through the request-scoped, logLevel-gated channel' }] }; + } + ); + // Simple text tool mcpServer.registerTool( 'test_simple_text', From 35f6856a17a04da8d4e763856352ba99c1d151eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:26:04 +0100 Subject: [PATCH 6/8] Version Packages (beta) (#2416) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/pre.json | 5 ++++- packages/client/CHANGELOG.md | 11 +++++++++++ packages/client/package.json | 2 +- packages/codemod/CHANGELOG.md | 13 +++++++++++++ packages/codemod/package.json | 2 +- packages/core-internal/CHANGELOG.md | 6 ++++++ packages/core-internal/package.json | 2 +- packages/core/CHANGELOG.md | 11 +++++++++++ packages/core/package.json | 2 +- packages/middleware/express/CHANGELOG.md | 13 +++++++++++++ packages/middleware/express/package.json | 2 +- packages/middleware/fastify/CHANGELOG.md | 13 +++++++++++++ packages/middleware/fastify/package.json | 2 +- packages/middleware/hono/CHANGELOG.md | 13 +++++++++++++ packages/middleware/hono/package.json | 2 +- packages/middleware/node/CHANGELOG.md | 13 +++++++++++++ packages/middleware/node/package.json | 2 +- packages/server-legacy/CHANGELOG.md | 11 +++++++++++ packages/server-legacy/package.json | 2 +- packages/server/CHANGELOG.md | 13 +++++++++++++ packages/server/package.json | 2 +- 21 files changed, 131 insertions(+), 11 deletions(-) diff --git a/.changeset/pre.json b/.changeset/pre.json index 50c743f316..559973ea8a 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -25,6 +25,9 @@ "@modelcontextprotocol/test-integration": "2.0.0-alpha.1" }, "changesets": [ - "beta-release" + "beta-release", + "cjs-support-v2-packages", + "codemod-iterations-5", + "post-dispatch-32021-http-400" ] } diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index b347fa782f..98ca06c430 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,16 @@ # @modelcontextprotocol/client +## 2.0.0-beta.2 + +### Patch Changes + +- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts` + and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map + adds a `require` condition so `require('@modelcontextprotocol/…')` works from + CommonJS consumers. Output extensions are normalized across all packages + (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the + public import paths are unchanged. + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/client/package.json b/packages/client/package.json index bade926d9d..f96b681bc7 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/client", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Model Context Protocol implementation for TypeScript - Client package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/codemod/CHANGELOG.md b/packages/codemod/CHANGELOG.md index 2268799fd8..88458c85a4 100644 --- a/packages/codemod/CHANGELOG.md +++ b/packages/codemod/CHANGELOG.md @@ -1,5 +1,18 @@ # @modelcontextprotocol/codemod +## 2.0.0-beta.2 + +### Patch Changes + +- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts` + and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map + adds a `require` condition so `require('@modelcontextprotocol/…')` works from + CommonJS consumers. Output extensions are normalized across all packages + (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the + public import paths are unchanged. + +- [#2412](https://github.com/modelcontextprotocol/typescript-sdk/pull/2412) [`ef120b2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ef120b2be0c3c3d80468c3d4a9f79be30bb0c0a3) Thanks [@felixweinberger](https://github.com/felixweinberger)! - v1-to-v2 migration fixes from continued real-world migrations (codemod iterations 5). + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 0552fff7b3..803053fb2c 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/codemod", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core-internal/CHANGELOG.md b/packages/core-internal/CHANGELOG.md index d052594512..cce2b6f284 100644 --- a/packages/core-internal/CHANGELOG.md +++ b/packages/core-internal/CHANGELOG.md @@ -1,5 +1,11 @@ # @modelcontextprotocol/core-internal +## 2.0.0-beta.1 + +### Patch Changes + +- [#2399](https://github.com/modelcontextprotocol/typescript-sdk/pull/2399) [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Return HTTP 400 for a `MissingRequiredClientCapabilityError` (`-32021`) produced after dispatch. The spec mandates `400 Bad Request` for this error with no condition on where it arose, but only the pre-dispatch capability gate honored that; the post-handler emission — the `input_required` gate rejecting an embedded request whose required capability the caller did not declare — surfaced in-band on HTTP 200. The JSON-RPC error body is unchanged, every other error code (including a handler relaying a downstream peer's `-32020`/`-32022`) keeps the origin-keyed in-band behavior, and the mapping only applies while the response is uncommitted: an exchange that already streamed — or one hosted with `responseMode: 'sse'`, which opens its stream at dispatch end — keeps its committed 200 and carries the error in-stream. + ## 2.0.0-alpha.3 ### Major Changes diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 0239af7cb2..0514d36112 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/core-internal", "private": true, - "version": "2.0.0-beta.0", + "version": "2.0.0-beta.1", "description": "Model Context Protocol implementation for TypeScript - Core package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 2345712ed4..5d8653d168 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,16 @@ # @modelcontextprotocol/core +## 2.0.0-beta.2 + +### Patch Changes + +- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts` + and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map + adds a `require` condition so `require('@modelcontextprotocol/…')` works from + CommonJS consumers. Output extensions are normalized across all packages + (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the + public import paths are unchanged. + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 7a6f115e48..66e2802e2d 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/core", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Model Context Protocol for TypeScript — public Zod schemas (spec + OAuth/OpenID)", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/express/CHANGELOG.md b/packages/middleware/express/CHANGELOG.md index 2cf3441283..dd7b7f653a 100644 --- a/packages/middleware/express/CHANGELOG.md +++ b/packages/middleware/express/CHANGELOG.md @@ -1,5 +1,18 @@ # @modelcontextprotocol/express +## 2.0.0-beta.2 + +### Patch Changes + +- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts` + and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map + adds a `require` condition so `require('@modelcontextprotocol/…')` works from + CommonJS consumers. Output extensions are normalized across all packages + (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the + public import paths are unchanged. +- Updated dependencies [[`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff)]: + - @modelcontextprotocol/server@2.0.0-beta.2 + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index 52c99d40a2..f1c736a2b0 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/express", "private": false, - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/fastify/CHANGELOG.md b/packages/middleware/fastify/CHANGELOG.md index da75b01a67..71f5747525 100644 --- a/packages/middleware/fastify/CHANGELOG.md +++ b/packages/middleware/fastify/CHANGELOG.md @@ -1,5 +1,18 @@ # @modelcontextprotocol/fastify +## 2.0.0-beta.2 + +### Patch Changes + +- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts` + and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map + adds a `require` condition so `require('@modelcontextprotocol/…')` works from + CommonJS consumers. Output extensions are normalized across all packages + (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the + public import paths are unchanged. +- Updated dependencies [[`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff)]: + - @modelcontextprotocol/server@2.0.0-beta.2 + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json index 84b1c91ec4..7f430b518c 100644 --- a/packages/middleware/fastify/package.json +++ b/packages/middleware/fastify/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/fastify", "private": false, - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/hono/CHANGELOG.md b/packages/middleware/hono/CHANGELOG.md index 185c5a01bc..d7e9f48280 100644 --- a/packages/middleware/hono/CHANGELOG.md +++ b/packages/middleware/hono/CHANGELOG.md @@ -1,5 +1,18 @@ # @modelcontextprotocol/hono +## 2.0.0-beta.2 + +### Patch Changes + +- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts` + and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map + adds a `require` condition so `require('@modelcontextprotocol/…')` works from + CommonJS consumers. Output extensions are normalized across all packages + (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the + public import paths are unchanged. +- Updated dependencies [[`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff)]: + - @modelcontextprotocol/server@2.0.0-beta.2 + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json index e53942f33b..3c280c0ecb 100644 --- a/packages/middleware/hono/package.json +++ b/packages/middleware/hono/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/hono", "private": false, - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/node/CHANGELOG.md b/packages/middleware/node/CHANGELOG.md index 1ef2b9aaeb..ff35d59b7e 100644 --- a/packages/middleware/node/CHANGELOG.md +++ b/packages/middleware/node/CHANGELOG.md @@ -1,5 +1,18 @@ # @modelcontextprotocol/node +## 2.0.0-beta.2 + +### Patch Changes + +- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts` + and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map + adds a `require` condition so `require('@modelcontextprotocol/…')` works from + CommonJS consumers. Output extensions are normalized across all packages + (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the + public import paths are unchanged. +- Updated dependencies [[`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff)]: + - @modelcontextprotocol/server@2.0.0-beta.2 + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index e8b22a4472..aad6227dda 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/node", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/server-legacy/CHANGELOG.md b/packages/server-legacy/CHANGELOG.md index e466d9d1f4..72c273c85d 100644 --- a/packages/server-legacy/CHANGELOG.md +++ b/packages/server-legacy/CHANGELOG.md @@ -1,5 +1,16 @@ # @modelcontextprotocol/server-legacy +## 2.0.0-beta.2 + +### Patch Changes + +- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts` + and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map + adds a `require` condition so `require('@modelcontextprotocol/…')` works from + CommonJS consumers. Output extensions are normalized across all packages + (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the + public import paths are unchanged. + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index ef2e8cf608..f571a3af95 100644 --- a/packages/server-legacy/package.json +++ b/packages/server-legacy/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/server-legacy", "private": false, - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Frozen v1 SSE transport and OAuth Authorization Server helpers for the Model Context Protocol TypeScript SDK. Deprecated; use StreamableHTTP and a dedicated OAuth server in production.", "deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.", "license": "MIT", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 9d4f37b992..9467763c06 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,18 @@ # @modelcontextprotocol/server +## 2.0.0-beta.2 + +### Patch Changes + +- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts` + and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map + adds a `require` condition so `require('@modelcontextprotocol/…')` works from + CommonJS consumers. Output extensions are normalized across all packages + (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the + public import paths are unchanged. + +- [#2399](https://github.com/modelcontextprotocol/typescript-sdk/pull/2399) [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Return HTTP 400 for a `MissingRequiredClientCapabilityError` (`-32021`) produced after dispatch. The spec mandates `400 Bad Request` for this error with no condition on where it arose, but only the pre-dispatch capability gate honored that; the post-handler emission — the `input_required` gate rejecting an embedded request whose required capability the caller did not declare — surfaced in-band on HTTP 200. The JSON-RPC error body is unchanged, every other error code (including a handler relaying a downstream peer's `-32020`/`-32022`) keeps the origin-keyed in-band behavior, and the mapping only applies while the response is uncommitted: an exchange that already streamed — or one hosted with `responseMode: 'sse'`, which opens its stream at dispatch end — keeps its committed 200 and carries the error in-stream. + ## 2.0.0-beta.1 ### Patch Changes diff --git a/packages/server/package.json b/packages/server/package.json index 9a0750ca45..d77e533db8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "description": "Model Context Protocol implementation for TypeScript - Server package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From ddf6cc1d73575271e45120b9f3e5c9ea2139729d Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:18:25 +0100 Subject: [PATCH 7/8] docs(servers): document resources/subscribe serving; exercise subscriptions in the resources example (#2413) --- docs/servers/notifications.md | 2 +- docs/servers/resources.md | 46 +++++++++++++ examples/README.md | 2 +- examples/guides/servers/resources.examples.ts | 59 +++++++++++++++++ examples/resources/README.md | 2 +- examples/resources/client.ts | 57 ++++++++++++++++- examples/resources/server.ts | 64 +++++++++++++++++-- 7 files changed, 221 insertions(+), 11 deletions(-) diff --git a/docs/servers/notifications.md b/docs/servers/notifications.md index 42bc96415c..03bada8ef5 100644 --- a/docs/servers/notifications.md +++ b/docs/servers/notifications.md @@ -87,7 +87,7 @@ On stdio, [`serveStdio`](../serving/stdio.md) routes the instance's own `send*Li ::: ::: info Coming from 2025-era subscriptions -A 2025-era connection delivers per-resource updates without a listen stream — [Subscriptions](../clients/subscriptions.md#fall-back-to-legacy-per-resource-subscribe) covers that path, and [Protocol versions](../protocol-versions.md) the era split. +A 2025-era connection delivers per-resource updates without a listen stream — [Resources](./resources.md#serve-per-resource-subscriptions) covers the server's subscription bookkeeping, [Subscriptions](../clients/subscriptions.md#fall-back-to-legacy-per-resource-subscribe) the client call, and [Protocol versions](../protocol-versions.md) the era split. ::: ## Pick an event bus for multi-process deployments diff --git a/docs/servers/resources.md b/docs/servers/resources.md index 7082723d22..bb6cacd141 100644 --- a/docs/servers/resources.md +++ b/docs/servers/resources.md @@ -212,6 +212,51 @@ server.sendResourceListChanged(); The notification tells connected clients to call `resources/list` again. A change to one resource's content is a different signal, `notifications/resources/updated` — [Notifications](./notifications.md) covers both. +## Serve per-resource subscriptions + +A 2025-era client opts into `notifications/resources/updated` for one URI with `resources/subscribe`. The SDK routes the verb; the bookkeeping is yours: advertise the capability, track the URIs per connection, and send the notification to subscribers only. + +```ts source="../../examples/guides/servers/resources.examples.ts#sendResourceUpdated_subscribers" +let deployStatus = 'idle'; + +const deploys = new McpServer({ name: 'deploys', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); + +deploys.registerResource( + 'deploy-status', + 'deploys://status', + { description: 'The current deploy state', mimeType: 'text/plain' }, + async uri => ({ contents: [{ uri: uri.href, text: deployStatus }] }) +); + +// The SDK routes the two verbs; which URIs this connection watches is yours to track. +const subscribedUris = new Set(); +deploys.server.setRequestHandler('resources/subscribe', request => { + subscribedUris.add(request.params.uri); + return {}; +}); +deploys.server.setRequestHandler('resources/unsubscribe', request => { + subscribedUris.delete(request.params.uri); + return {}; +}); + +async function setDeployStatus(status: string): Promise { + deployStatus = status; + if (subscribedUris.has('deploys://status')) { + await deploys.server.sendResourceUpdated({ uri: 'deploys://status' }); + } +} +``` + +The `Set` belongs to one server instance, and each connection gets its own instance from your factory — a subscription never leaks across connections. Send `resources/updated` only to connections that subscribed; unsolicited per-resource updates are wrong on 2025-era connections. + +The pattern needs a connection that outlives the subscribe call: over stdio (and any sessionful wiring) the instance and its `Set` live as long as the connection. Behind `createMcpHandler`'s stateless legacy fallback each POST gets a fresh instance, so `resources/subscribe` succeeds and the `Set` is discarded with it — no update can ever be delivered on that posture. [Support legacy clients](../serving/legacy-clients.md) covers the serving postures. + +::: info +On [2026-07-28](../protocol-versions.md) connections the verb does not exist: clients name resource URIs in their `subscriptions/listen` filter, and the entry filters delivery itself — `serveStdio` routes the instance's own `sendResourceUpdated` call onto matching streams, and `createMcpHandler` delivers what you publish on its notifier ([Notifications](./notifications.md#publish-a-resource-update-through-the-handler)). +::: + +A dual-era server therefore still calls `sendResourceUpdated` on 2026-07-28 connections, where the subscribe set is always empty — gate on the connection's era as well as the set. The [`resources` example](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/resources) guards with `reqCtx.era === 'modern' || subscribedUris.has(uri)` in its factory and runs as a self-verifying pair: delivery is asserted over stdio on both eras and over HTTP on the 2026-07-28 listen path; the stateless legacy HTTP leg asserts only that the subscribe calls succeed. + ## Recap - `registerResource(name, uri, config, readCallback)` registers a resource at a fixed URI. @@ -220,3 +265,4 @@ The notification tells connected clients to call `resources/list` again. A chang - A template's `list` callback is what makes its instances appear in `resources/list`. - Resolve file-backed paths to their real location and reject anything outside the root before reading. - Registration changes emit `notifications/resources/list_changed` automatically. +- `resources/subscribe` bookkeeping is the server's: advertise `resources: { subscribe: true }`, track URIs per connection, send `resources/updated` to subscribers only. diff --git a/examples/README.md b/examples/README.md index ea6f518018..db8812871a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +24,7 @@ The one exception to the generic commands is the reference pair: [`cli-client/`] | ------------------------------------- | ------------------------------------------------------------------------ | | [`tools/`](./tools/README.md) | Register tools, infer input/output schemas, call them, structured output | | [`prompts/`](./prompts/README.md) | Prompts + argument completion | -| [`resources/`](./resources/README.md) | Static + templated resources, list/read | +| [`resources/`](./resources/README.md) | Static + templated resources, list/read, era-split subscriptions | | [`dual-era/`](./dual-era/README.md) | One factory, both protocol eras, both transports | ## Feature stories diff --git a/examples/guides/servers/resources.examples.ts b/examples/guides/servers/resources.examples.ts index 57d5cbe3b7..ec74419055 100644 --- a/examples/guides/servers/resources.examples.ts +++ b/examples/guides/servers/resources.examples.ts @@ -117,6 +117,41 @@ server.registerResource( // Imported dynamically so the page's lead region stays self-contained. // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// "Serve per-resource subscriptions" +// --------------------------------------------------------------------------- + +//#region sendResourceUpdated_subscribers +let deployStatus = 'idle'; + +const deploys = new McpServer({ name: 'deploys', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); + +deploys.registerResource( + 'deploy-status', + 'deploys://status', + { description: 'The current deploy state', mimeType: 'text/plain' }, + async uri => ({ contents: [{ uri: uri.href, text: deployStatus }] }) +); + +// The SDK routes the two verbs; which URIs this connection watches is yours to track. +const subscribedUris = new Set(); +deploys.server.setRequestHandler('resources/subscribe', request => { + subscribedUris.add(request.params.uri); + return {}; +}); +deploys.server.setRequestHandler('resources/unsubscribe', request => { + subscribedUris.delete(request.params.uri); + return {}; +}); + +async function setDeployStatus(status: string): Promise { + deployStatus = status; + if (subscribedUris.has('deploys://status')) { + await deploys.server.sendResourceUpdated({ uri: 'deploys://status' }); + } +} +//#endregion sendResourceUpdated_subscribers + const { Client, InMemoryTransport } = await import('@modelcontextprotocol/client'); const client = new Client({ name: 'resources-docs-harness', version: '1.0.0' }); @@ -153,5 +188,29 @@ if (uris.some(uri => uri.startsWith('users://')) || !uris.includes('teams://core server.sendResourceListChanged(); //#endregion sendResourceListChanged +// "Serve per-resource subscriptions" — a 2025-era client subscribes, the status +// changes, exactly one notifications/resources/updated arrives, and none after +// unsubscribe. +const watcher = new Client({ name: 'resources-docs-watcher', version: '1.0.0' }, { versionNegotiation: { mode: 'legacy' } }); +const [watcherTransport, deploysTransport] = InMemoryTransport.createLinkedPair(); +const updates: string[] = []; +watcher.setNotificationHandler('notifications/resources/updated', notification => { + updates.push(notification.params.uri); +}); +await deploys.connect(deploysTransport); +await watcher.connect(watcherTransport); + +await watcher.subscribeResource({ uri: 'deploys://status' }); +await setDeployStatus('deploying'); +await watcher.unsubscribeResource({ uri: 'deploys://status' }); +await setDeployStatus('done'); +await new Promise(resolve => setTimeout(resolve, 50)); +console.log('updates:', updates); +if (updates.length !== 1 || updates[0] !== 'deploys://status') { + throw new Error(`resources.md subscription claim failed: ${JSON.stringify(updates)}`); +} +await watcher.close(); +await deploys.close(); + await client.close(); await server.close(); diff --git a/examples/resources/README.md b/examples/resources/README.md index 409df6833c..35a18a8fbd 100644 --- a/examples/resources/README.md +++ b/examples/resources/README.md @@ -1,6 +1,6 @@ # resources -Direct resources (a fixed URI string) and templated resources (`ResourceTemplate('greeting://{name}')`). The client lists both, reads the direct config, and reads a templated greeting. +Direct resources (a fixed URI string), templated resources (`ResourceTemplate('greeting://{name}')`), and per-resource subscriptions. The client lists both kinds, reads the direct config and a templated greeting, then subscribes to `counter://value` — `subscriptions/listen` on 2026-07-28, `resources/subscribe` on 2025 — calls the `increment` tool, and asserts the `notifications/resources/updated` it produces. Per-request legacy HTTP has no delivery channel, so that leg skips the delivery assertion. ```bash pnpm tsx examples/resources/client.ts diff --git a/examples/resources/client.ts b/examples/resources/client.ts index c81fdf4bce..ee1325d42d 100644 --- a/examples/resources/client.ts +++ b/examples/resources/client.ts @@ -1,5 +1,11 @@ /** - * Drives the resources example: list, list templates, read direct + templated. + * Drives the resources example: list, list templates, read direct + templated, + * then subscribe to `counter://value` and assert the update notification. The + * subscription sender is era-split — `subscriptions/listen` on 2026-07-28, + * `resources/subscribe` on 2025 — while the notification handler is one + * registration either way. Per-request legacy HTTP has no channel to deliver + * notifications, so that leg asserts the calls succeed and skips the delivery + * assertion. */ import { check, parseExampleArgs, siblingPath } from '@mcp-examples/shared'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; @@ -18,6 +24,7 @@ await (transport === 'stdio' const list = await client.listResources(); check.ok(list.resources.some(r => r.uri === 'config://app')); +check.ok(list.resources.some(r => r.uri === 'counter://value')); const templates = await client.listResourceTemplates(); check.ok(templates.resourceTemplates.some(t => t.uriTemplate === 'greeting://{name}')); @@ -30,4 +37,52 @@ const hello = await client.readResource({ uri: 'greeting://world' }); const helloContent = hello.contents[0]; check.equal(helloContent && 'text' in helloContent ? helloContent.text : '', 'Hello, world!'); +// --- Subscriptions --------------------------------------------------------- + +// One handler serves both delivery paths: the 2026-07-28 listen stream and a +// 2025-era connection's unsolicited notification dispatch the same way. +let resolveUpdated: ((uri: string) => void) | undefined; +client.setNotificationHandler('notifications/resources/updated', notification => { + resolveUpdated?.(notification.params.uri); +}); + +// Per-request legacy HTTP answers each POST in isolation: subscribing succeeds, +// but there is no stream to deliver the notification on. +const deliverable = !(era === 'legacy' && transport === 'http'); + +const updated = new Promise(resolve => { + resolveUpdated = resolve; +}); + +const subscription = era === 'modern' ? await client.listen({ resourceSubscriptions: ['counter://value'] }) : undefined; +if (era === 'legacy') { + await client.subscribeResource({ uri: 'counter://value' }); +} + +const bumped = await client.callTool({ name: 'increment', arguments: {} }); +check.ok(!bumped.isError); +const bumpedContent = bumped.content[0]; +const bumpedValue = bumpedContent && bumpedContent.type === 'text' ? bumpedContent.text : ''; + +if (deliverable) { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('no resources/updated within 8s')), 8000); + }); + const updatedUri = await Promise.race([updated, timeout]).finally(() => clearTimeout(timer)); + check.equal(updatedUri, 'counter://value'); + + // The resource and the tool observe the same state: the re-read matches the + // value increment returned (not a literal, so a long-lived server re-runs). + const counter = await client.readResource({ uri: 'counter://value' }); + const counterContent = counter.contents[0]; + check.equal(counterContent && 'text' in counterContent ? counterContent.text : '', bumpedValue); +} + +if (subscription) { + await subscription.close(); +} else if (era === 'legacy') { + await client.unsubscribeResource({ uri: 'counter://value' }); +} + await client.close(); diff --git a/examples/resources/server.ts b/examples/resources/server.ts index 069be5769a..2ac10338d6 100644 --- a/examples/resources/server.ts +++ b/examples/resources/server.ts @@ -1,19 +1,33 @@ /** - * Resources primitive — direct + templated. + * Resources primitive — direct, templated, and subscribable. * * `McpServer.registerResource` accepts either a fixed URI string (direct - * resource) or a `ResourceTemplate` (URI template with substitution). One - * binary, either transport — selected from argv below. + * resource) or a `ResourceTemplate` (URI template with substitution). The + * `counter://value` resource is mutable: the `increment` tool bumps it and + * announces the change — in-band to this connection (2025-era subscribers + * tracked per connection, 2026-07-28 listen streams routed by the entry), and + * on the handler's notifier for clients on other requests. One binary, either + * transport — selected from argv below. */ import { createServer } from 'node:http'; import { parseExampleArgs } from '@mcp-examples/shared'; import { toNodeHandler } from '@modelcontextprotocol/node'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; -function buildServer(): McpServer { - const server = new McpServer({ name: 'resources-example', version: '1.0.0' }); +const COUNTER_URI = 'counter://value'; + +// The counter is application state, shared by every connection; which URIs a +// connection subscribed to is connection state, tracked inside buildServer. +let counter = 0; + +function buildServer(reqCtx: McpRequestContext, publishUpdated?: (uri: string) => void): McpServer { + const server = new McpServer( + { name: 'resources-example', version: '1.0.0' }, + { capabilities: { resources: { subscribe: true, listChanged: true } } } + ); // A direct resource at a fixed URI. server.registerResource( @@ -31,16 +45,52 @@ function buildServer(): McpServer { async (uri, vars) => ({ contents: [{ uri: uri.href, text: `Hello, ${vars.name}!` }] }) ); + // A mutable resource — the subscription story's subject. + server.registerResource( + 'counter', + COUNTER_URI, + { mimeType: 'text/plain', description: 'A number the increment tool bumps' }, + async uri => ({ contents: [{ uri: uri.href, mimeType: 'text/plain', text: String(counter) }] }) + ); + + // resources/subscribe bookkeeping is the application's: the SDK routes the + // two verbs, and which URIs THIS connection watches lives here. + const subscribedUris = new Set(); + server.server.setRequestHandler('resources/subscribe', request => { + subscribedUris.add(request.params.uri); + return {}; + }); + server.server.setRequestHandler('resources/unsubscribe', request => { + subscribedUris.delete(request.params.uri); + return {}; + }); + + server.registerTool('increment', { description: `Bump ${COUNTER_URI} by one` }, async () => { + counter += 1; + if (publishUpdated) { + // Per-request serving: this instance answers one request and is gone, + // so the change is published on the entry's notifier for the listen + // streams other requests hold open. + publishUpdated(COUNTER_URI); + } else if (reqCtx.era === 'modern' || subscribedUris.has(COUNTER_URI)) { + // Connection serving (stdio): announce in-band. The entry routes it + // onto 2026-07-28 listen streams; on a 2025-era connection it goes + // only to subscribers — unsolicited per-resource updates are wrong. + await server.server.sendResourceUpdated({ uri: COUNTER_URI }).catch(() => {}); + } + return { content: [{ type: 'text', text: String(counter) }] }; + }); + return server; } const { transport, port } = parseExampleArgs(); if (transport === 'stdio') { - void serveStdio(buildServer); + void serveStdio(reqCtx => buildServer(reqCtx)); console.error('[server] serving over stdio'); } else { - const handler = createMcpHandler(buildServer); + const handler = createMcpHandler(reqCtx => buildServer(reqCtx, uri => handler.notify.resourceUpdated(uri))); createServer(toNodeHandler(handler)).listen(port, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); }); From 79dc162efcb4e1f7b820bfb6068906483cf71ec7 Mon Sep 17 00:00:00 2001 From: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Date: Thu, 2 Jul 2026 16:01:32 +0100 Subject: [PATCH 8/8] Read codemod target versions from workspace manifests at build time (#2419) --- .changeset/codemod-versions-from-manifests.md | 5 +++ packages/codemod/package.json | 2 -- packages/codemod/scripts/generateVersions.ts | 36 ------------------- packages/codemod/src/bin/batchTest.ts | 2 +- packages/codemod/src/generated/versions.ts | 9 ----- .../codemod/src/utils/packageJsonUpdater.ts | 2 +- packages/codemod/src/versions.ts | 20 +++++++++++ 7 files changed, 27 insertions(+), 49 deletions(-) create mode 100644 .changeset/codemod-versions-from-manifests.md delete mode 100644 packages/codemod/scripts/generateVersions.ts delete mode 100644 packages/codemod/src/generated/versions.ts create mode 100644 packages/codemod/src/versions.ts diff --git a/.changeset/codemod-versions-from-manifests.md b/.changeset/codemod-versions-from-manifests.md new file mode 100644 index 0000000000..b0383703ad --- /dev/null +++ b/.changeset/codemod-versions-from-manifests.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +Read the v2 package versions the codemod writes into migrated `package.json` files directly from the workspace manifests at build time, replacing the committed generated `versions.ts` (which went stale after every release and made source builds write outdated versions). diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 803053fb2c..f3c19675a6 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -41,8 +41,6 @@ ], "scripts": { "typecheck": "tsgo -p tsconfig.json --noEmit", - "generate:versions": "tsx scripts/generateVersions.ts", - "prebuild": "pnpm run generate:versions", "build": "tsdown", "build:watch": "tsdown --watch", "prepack": "pnpm run build", diff --git a/packages/codemod/scripts/generateVersions.ts b/packages/codemod/scripts/generateVersions.ts deleted file mode 100644 index f4d827d76d..0000000000 --- a/packages/codemod/scripts/generateVersions.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const packagesDir = path.resolve(__dirname, '../..'); - -const PACKAGE_DIRS: Record = { - '@modelcontextprotocol/client': 'client', - '@modelcontextprotocol/server': 'server', - '@modelcontextprotocol/node': 'middleware/node', - '@modelcontextprotocol/express': 'middleware/express', - '@modelcontextprotocol/server-legacy': 'server-legacy', - '@modelcontextprotocol/core': 'core' -}; - -const versions: Record = {}; - -for (const [pkg, dir] of Object.entries(PACKAGE_DIRS)) { - const pkgJsonPath = path.join(packagesDir, dir, 'package.json'); - const pkgJson = JSON.parse(readFileSync(pkgJsonPath, 'utf8')); - versions[pkg] = `^${pkgJson.version}`; -} - -const entries = Object.entries(versions); -const lines = entries.map(([pkg, ver], i) => ` '${pkg}': '${ver}'${i < entries.length - 1 ? ',' : ''}`).join('\n'); - -const output = `// AUTO-GENERATED — do not edit. Run \`pnpm run generate:versions\` to regenerate. -export const V2_PACKAGE_VERSIONS: Record = { -${lines} -}; -`; - -const outPath = path.resolve(__dirname, '../src/generated/versions.ts'); -writeFileSync(outPath, output); -console.log(`Wrote ${outPath}`); diff --git a/packages/codemod/src/bin/batchTest.ts b/packages/codemod/src/bin/batchTest.ts index 1a6802bc86..3d95fd23c5 100644 --- a/packages/codemod/src/bin/batchTest.ts +++ b/packages/codemod/src/bin/batchTest.ts @@ -6,10 +6,10 @@ import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { V2_PACKAGE_VERSIONS } from '../generated/versions'; import { getMigration } from '../migrations/index'; import { run } from '../runner'; import type { Diagnostic, Migration } from '../types'; +import { V2_PACKAGE_VERSIONS } from '../versions'; // --------------------------------------------------------------------------- // Types diff --git a/packages/codemod/src/generated/versions.ts b/packages/codemod/src/generated/versions.ts deleted file mode 100644 index a82d986db8..0000000000 --- a/packages/codemod/src/generated/versions.ts +++ /dev/null @@ -1,9 +0,0 @@ -// AUTO-GENERATED — do not edit. Run `pnpm run generate:versions` to regenerate. -export const V2_PACKAGE_VERSIONS: Record = { - '@modelcontextprotocol/client': '^2.0.0-beta.1', - '@modelcontextprotocol/server': '^2.0.0-beta.1', - '@modelcontextprotocol/node': '^2.0.0-beta.1', - '@modelcontextprotocol/express': '^2.0.0-beta.1', - '@modelcontextprotocol/server-legacy': '^2.0.0-beta.1', - '@modelcontextprotocol/core': '^2.0.0-beta.1' -}; diff --git a/packages/codemod/src/utils/packageJsonUpdater.ts b/packages/codemod/src/utils/packageJsonUpdater.ts index 0a03fc1e52..c543db452d 100644 --- a/packages/codemod/src/utils/packageJsonUpdater.ts +++ b/packages/codemod/src/utils/packageJsonUpdater.ts @@ -3,8 +3,8 @@ import path from 'node:path'; import fg from 'fast-glob'; -import { V2_PACKAGE_VERSIONS } from '../generated/versions'; import type { PackageJsonChange } from '../types'; +import { V2_PACKAGE_VERSIONS } from '../versions'; import { findPackageJson } from './projectAnalyzer'; const V1_PACKAGE = '@modelcontextprotocol/sdk'; diff --git a/packages/codemod/src/versions.ts b/packages/codemod/src/versions.ts new file mode 100644 index 0000000000..64fae3a213 --- /dev/null +++ b/packages/codemod/src/versions.ts @@ -0,0 +1,20 @@ +import clientPkg from '../../client/package.json'; +import corePkg from '../../core/package.json'; +import expressPkg from '../../middleware/express/package.json'; +import nodePkg from '../../middleware/node/package.json'; +import serverPkg from '../../server/package.json'; +import serverLegacyPkg from '../../server-legacy/package.json'; + +/** + * Caret ranges for the v2 packages the codemod writes into migrated package.json + * files. Versions come straight from the workspace manifests — the bundler inlines + * them at build time — so a release bump can never leave this map stale. + */ +export const V2_PACKAGE_VERSIONS: Record = { + '@modelcontextprotocol/client': `^${clientPkg.version}`, + '@modelcontextprotocol/server': `^${serverPkg.version}`, + '@modelcontextprotocol/node': `^${nodePkg.version}`, + '@modelcontextprotocol/express': `^${expressPkg.version}`, + '@modelcontextprotocol/server-legacy': `^${serverLegacyPkg.version}`, + '@modelcontextprotocol/core': `^${corePkg.version}` +};