From 01e8b2c1ca40d9a6f077868783614c90e82a3ad6 Mon Sep 17 00:00:00 2001 From: squarepots <46488165+squarepots@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:23:31 +0800 Subject: [PATCH 01/44] chore(spec): align 2025 anchor provenance with release pin --- packages/core-internal/src/types/spec.types.2025-11-25.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-internal/src/types/spec.types.2025-11-25.ts b/packages/core-internal/src/types/spec.types.2025-11-25.ts index 225a53c2d7..3cbbc8f6c3 100644 --- a/packages/core-internal/src/types/spec.types.2025-11-25.ts +++ b/packages/core-internal/src/types/spec.types.2025-11-25.ts @@ -3,7 +3,7 @@ * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/2025-11-25/schema.ts - * Last updated from commit: 357adac47ab2654b64799f994e6db8d3df4ee19d + * Last updated from commit: 0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65 * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: pnpm run fetch:spec-types 2025-11-25 From cfa09db614308eaf4fd575c89833da8878d4acb7 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:43:33 +0100 Subject: [PATCH 02/44] fix(client): stop listen() rejections escaping as unhandledRejection during an in-flight send (#2642) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../listen-opening-unhandled-rejection.md | 5 + packages/client/src/client/client.ts | 33 ++++-- packages/client/test/client/listen.test.ts | 109 ++++++++++++++++++ 3 files changed, 137 insertions(+), 10 deletions(-) create mode 100644 .changeset/listen-opening-unhandled-rejection.md diff --git a/.changeset/listen-opening-unhandled-rejection.md b/.changeset/listen-opening-unhandled-rejection.md new file mode 100644 index 0000000000..a951b4d51a --- /dev/null +++ b/.changeset/listen-opening-unhandled-rejection.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix `Client.listen()` rejections escaping as process-level unhandled rejections. The internal `opening` promise could reject (ack timeout, transport close, server cancel, caller abort) while `listen()` was still serially awaiting `transport.send(...)`, so no rejection handler was attached yet — the rejection surfaced as an `unhandledRejection` that caller-side handling cannot prevent, and a send that never settles (e.g. a stdio write parked on `'drain'`) left `listen()` suspended forever even though the ack timer had already fired. `listen()` now suspends on the `opening` state machine directly and routes send failures into it, so every termination path rejects the returned promise and nothing escapes. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 0b386a63e8..a6665e4e15 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -2106,18 +2106,31 @@ export class Client extends Protocol { method: 'subscriptions/listen', params: { _meta: { ...this._outboundMetaEnvelope() }, notifications: filter } }; + // The send is NOT serially awaited: `opening` must be the promise + // listen() suspends on so that a settle() firing while the send is + // still in flight (ack timeout, transport close, server cancel, a + // stdio send parked on 'drain') rejects a promise whose handler is + // already attached — otherwise the rejection escapes as a + // process-level unhandledRejection the caller cannot prevent, and a + // send that never settles leaves listen() suspended forever even + // though the ack timer already fired. + const routeSendFailure = (error: unknown): void => { + // Send failure (including a per-request abort fired before + // response headers — `streamableHttp._send` rethrows with onerror + // suppressed). `settle()` is idempotent so a locally-aborted send + // hitting this path after `close()` is a no-op. + settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); + }; try { - await this.transport.send(jsonrpcRequest, { - requestSignal: requestAbort.signal, - onRequestStreamEnd: () => settle({ cause: 'remote', error: new Error('subscriptions/listen: stream ended') }) - }); + this.transport + .send(jsonrpcRequest, { + requestSignal: requestAbort.signal, + onRequestStreamEnd: () => settle({ cause: 'remote', error: new Error('subscriptions/listen: stream ended') }) + }) + .catch(routeSendFailure); } catch (error) { - // Synchronous OR awaited send failure (including a per-request - // abort fired before response headers — `streamableHttp._send` - // rethrows with onerror suppressed). `settle()` is idempotent so - // a locally-aborted send hitting this path after `close()` is a - // no-op. - settle({ cause: 'remote', error: error instanceof Error ? error : new Error(String(error)) }); + // A synchronous throw from send() (before it returns a promise). + routeSendFailure(error); } const honored = await opening; diff --git a/packages/client/test/client/listen.test.ts b/packages/client/test/client/listen.test.ts index 60f96be47a..acc8e33d72 100644 --- a/packages/client/test/client/listen.test.ts +++ b/packages/client/test/client/listen.test.ts @@ -953,6 +953,115 @@ describe('_resetConnectionState() clears connection-scoped debounce timers (fake }); }); +describe('Client.listen() — settle while the send is still in flight (unhandled-rejection regression)', () => { + const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); + + /** + * Capture process-level unhandledRejection events for the duration of a + * test body. Every settle() path that rejects the `opening` promise while + * `transport.send()` is still pending (a stdio send parked on 'drain', a + * slow HTTP send) used to escape here — caller-side handling cannot + * prevent it because the rejection fires before `await opening` attaches. + */ + async function withUnhandledCapture(run: () => Promise): Promise { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on('unhandledRejection', onUnhandled); + try { + await run(); + // Let any escaped rejection surface before uninstalling. + await sleep(30); + } finally { + process.off('unhandledRejection', onUnhandled); + } + return unhandled; + } + + /** A scripted modern connection whose `subscriptions/listen` send never settles. */ + async function parkedSendClient() { + const { clientTx, serverTx } = await scriptedModernNoAck(); + const realSend = clientTx.send.bind(clientTx); + clientTx.send = (m, opts) => { + if ((m as { method?: string }).method === 'subscriptions/listen') { + // Models a stdio send parked forever on 'drain' (stdio ignores + // TransportSendOptions.requestSignal) or any send that outlives + // the subscription. + return new Promise(() => {}); + } + return realSend(m, opts); + }; + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + return { client, clientTx, serverTx }; + } + + it('ack timeout during a parked send: listen() rejects with RequestTimeout instead of hanging; nothing escapes', async () => { + let outcome: unknown; + const unhandled = await withUnhandledCapture(async () => { + const { client } = await parkedSendClient(); + const settled = client.listen({ toolsListChanged: true }, { timeout: 50 }).then( + () => 'resolved', + e => e + ); + outcome = await Promise.race([settled, sleep(1000).then(() => 'listen() hung')]); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + }); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.RequestTimeout); + expect(unhandled).toEqual([]); + }); + + it('transport close during a parked send: listen() rejects instead of hanging; nothing escapes', async () => { + let outcome: unknown; + const unhandled = await withUnhandledCapture(async () => { + const { client, serverTx } = await parkedSendClient(); + const settled = client.listen({ toolsListChanged: true }, { timeout: 60_000 }).then( + () => 'resolved', + e => e + ); + await flush(); + await serverTx.close(); + outcome = await Promise.race([settled, sleep(1000).then(() => 'listen() hung')]); + }); + expect(outcome).toBeInstanceOf(Error); + expect(outcome).not.toBe('listen() hung'); + expect(unhandled).toEqual([]); + }); + + it('caller-signal abort during a parked send: listen() rejects with the signal reason; nothing escapes', async () => { + let outcome: unknown; + const unhandled = await withUnhandledCapture(async () => { + const { client } = await parkedSendClient(); + const ac = new AbortController(); + const settled = client.listen({ toolsListChanged: true }, { timeout: 60_000, signal: ac.signal }).then( + () => 'resolved', + e => e + ); + await flush(); + ac.abort(new Error('caller gave up')); + outcome = await Promise.race([settled, sleep(1000).then(() => 'listen() hung')]); + }); + expect(outcome).toBeInstanceOf(Error); + expect((outcome as Error).message).toContain('caller gave up'); + expect(unhandled).toEqual([]); + }); + + it('an asynchronously-rejected send still rejects listen() with the send failure', async () => { + const unhandled = await withUnhandledCapture(async () => { + const { clientTx } = await scriptedModernNoAck(); + const client = new Client({ name: 'c', version: '1' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(clientTx); + clientTx.send = () => Promise.reject(new Error('wire dropped')); + const error = await client.listen({ toolsListChanged: true }).catch(e => e as Error); + expect((error as Error).message).toContain('wire dropped'); + expect((client as unknown as { _listenState: Map })._listenState.size).toBe(0); + }); + expect(unhandled).toEqual([]); + }); +}); + describe('Client.listen() — ack timeout (fake timers)', () => { beforeEach(() => vi.useFakeTimers()); afterEach(() => vi.useRealTimers()); From f091897f4ba6c0519584382e31b68a6ca935f35b Mon Sep 17 00:00:00 2001 From: Axit Date: Mon, 28 Sep 2026 19:38:27 +0530 Subject: [PATCH 03/44] fix(codemod): keep a file's leading comment block above rewritten imports (#2582) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/codemod-preserve-file-header.md | 5 + .../v1-to-v2/transforms/importPaths.ts | 35 +++- packages/codemod/src/utils/importUtils.ts | 15 +- .../codemod/test/commentInsertion.test.ts | 30 ++++ .../v1-to-v2/transforms/importPaths.test.ts | 152 ++++++++++++++++++ 5 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 .changeset/codemod-preserve-file-header.md diff --git a/.changeset/codemod-preserve-file-header.md b/.changeset/codemod-preserve-file-header.md new file mode 100644 index 0000000000..b686d36080 --- /dev/null +++ b/.changeset/codemod-preserve-file-header.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/codemod': patch +--- + +The `v1-to-v2` codemod now writes rewritten imports where the first v1 import stood, not at the top of the file, so a license header, `// @ts-nocheck`, `/// ` or a `'use client'` / `'use server'` / `'use strict'` directive above it stays in place. Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is `ErrorCode` or `StreamableHTTPError`), the new import can still land above or inside the header, and a `/** */` header can be removed. Files already migrated with codemod 2.1.0 or earlier are not repaired; check the top of those files. diff --git a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts index 1d05a307f1..f4d338e4b7 100644 --- a/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts +++ b/packages/codemod/src/migrations/v1-to-v2/transforms/importPaths.ts @@ -52,7 +52,11 @@ export const importPathsTransform: Transform = { return spec.includes('/server/'); }); - const insertIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); + const importIndex = sourceFile.getImportDeclarations().indexOf(sdkImports[0]!); + // ts-morph inserts by position among all top-level children (own-line comments and statements count), not among imports. + const childIndex = sdkImports[0]!.getChildIndex(); + const previousEnd = childIndex > 0 ? sourceFile.getStatementsWithComments()[childIndex - 1]!.getEnd() : 0; + const blankLineAbove = childIndex > 0 && /^[ \t]*\r?\n[ \t]*\r?\n/.test(sourceFile.getFullText().slice(previousEnd)); // A leading file-header / JSDoc comment attaches to the first SDK import as leading trivia. When // that import is removed and re-emitted (the per-symbol split/merge path calls imp.remove()), @@ -62,9 +66,12 @@ export const importPathsTransform: Transform = { // (a blank line, or CRLF in CRLF files), so the later survival check would never match a header // that actually survived (in-place setModuleSpecifier rewrite) and would re-insert it, duplicating // it. The slice reproduces the block verbatim, so the includes() guard below is byte-exact. - const leadingRanges = sdkImports[0]!.getLeadingCommentRanges(); + // Own-line comments above the import are siblings and survive its removal; only comments attached to it can be dropped. + const leadingRanges = sdkImports[0]!.getLeadingCommentRanges().filter(range => range.getPos() >= previousEnd); const leadingCommentText = leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges[0]!.getPos(), leadingRanges.at(-1)!.getEnd()) : ''; + const leadingCommentGap = + leadingRanges.length > 0 ? sourceFile.getFullText().slice(leadingRanges.at(-1)!.getEnd(), sdkImports[0]!.getStart()) : ''; interface PendingImport { specs: NamedImportSpec[]; @@ -334,6 +341,10 @@ export const importPathsTransform: Transform = { } } + // New imports go where the first SDK import stood, or right below it when it was rewritten in place and still stands there. + const firstRemoved = sdkImports[0]!.wasForgotten(); + const insertIndex = firstRemoved ? childIndex : childIndex + 1; + const specLocal = (spec: NamedImportSpec): string => (typeof spec === 'string' ? spec : (spec.alias ?? spec.name)); for (const [target, groups] of pendingImports) { // Dedupe by local binding name (alias when present), keeping the spec so aliases survive. @@ -345,12 +356,22 @@ export const importPathsTransform: Transform = { } } + let valueInserted = false; if (valueSpecs.size > 0) { - addOrMergeImport(sourceFile, target, [...valueSpecs.values()], false, insertIndex); + valueInserted = addOrMergeImport( + sourceFile, + target, + [...valueSpecs.values()], + false, + insertIndex, + firstRemoved && blankLineAbove + ); } if (typeOnlySpecs.size > 0) { - const typeInsertIndex = valueSpecs.size > 0 ? insertIndex + 1 : insertIndex; - addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex); + // The type import goes one lower only when the value import was inserted, not when it merged into an existing import. + const typeInsertIndex = valueInserted ? insertIndex + 1 : insertIndex; + const blankLine = firstRemoved && blankLineAbove && !valueInserted; + addOrMergeImport(sourceFile, target, [...typeOnlySpecs.values()], true, typeInsertIndex, blankLine); } } @@ -358,8 +379,8 @@ export const importPathsTransform: Transform = { // the first import was rewritten in place and kept its comment). if (leadingCommentText && !sourceFile.getFullText().includes(leadingCommentText)) { const imports = sourceFile.getImportDeclarations(); - const anchor = imports[Math.min(insertIndex, imports.length - 1)]; - sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}\n`); + const anchor = imports[Math.min(importIndex, imports.length - 1)]; + sourceFile.insertText(anchor ? anchor.getStart() : 0, `${leadingCommentText}${leadingCommentGap}`); } return { changesCount, diagnostics, usedPackages }; diff --git a/packages/codemod/src/utils/importUtils.ts b/packages/codemod/src/utils/importUtils.ts index e2e6c70a49..24eaa0cf5f 100644 --- a/packages/codemod/src/utils/importUtils.ts +++ b/packages/codemod/src/utils/importUtils.ts @@ -64,14 +64,16 @@ function specLocalName(s: { name: string; alias?: string }): string { return s.alias ?? s.name; } +/** Adds the names to an existing import of the module, or inserts a new import; returns true when it inserted one. */ export function addOrMergeImport( sourceFile: SourceFile, moduleSpecifier: string, namedImports: NamedImportSpec[], isTypeOnly: boolean, - insertIndex: number -): void { - if (namedImports.length === 0) return; + insertIndex: number, + blankLineAbove = false +): boolean { + if (namedImports.length === 0) return false; const specs = namedImports.map(n => toSpec(n)); @@ -86,6 +88,7 @@ export function addOrMergeImport( if (newSpecs.length > 0) { existing.addNamedImports(newSpecs.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name }))); } + return false; } else { const seen = new Set(); const deduped = specs.filter(s => { @@ -94,12 +97,14 @@ export function addOrMergeImport( seen.add(local); return true; }); - const clampedIndex = Math.min(insertIndex, sourceFile.getImportDeclarations().length); + const clampedIndex = Math.min(insertIndex, sourceFile.getStatementsWithComments().length); sourceFile.insertImportDeclaration(clampedIndex, { moduleSpecifier, namedImports: deduped.map(s => (s.alias ? { name: s.name, alias: s.alias } : { name: s.name })), - isTypeOnly + isTypeOnly, + leadingTrivia: blankLineAbove ? writer => writer.blankLineIfLastNot() : undefined }); + return true; } } diff --git a/packages/codemod/test/commentInsertion.test.ts b/packages/codemod/test/commentInsertion.test.ts index 255cb9e581..689c949363 100644 --- a/packages/codemod/test/commentInsertion.test.ts +++ b/packages/codemod/test/commentInsertion.test.ts @@ -330,6 +330,36 @@ describe('comment insertion', () => { }); describe('markers whose import-declaration anchor is removed by the same pass', () => { + it('keeps a license header first and still writes the marker below it (#2575)', () => { + const dir = createTempDir(); + writeFileSync( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'app', dependencies: { '@modelcontextprotocol/sdk': '^1.29.0' } }) + ); + const file = path.join(dir, 'auth.ts'); + writeFileSync( + file, + [ + `// Copyright (c) 2026 Example Corp.`, + `// SPDX-License-Identifier: Apache-2.0`, + ``, + `import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';`, + ``, + `export const guard = requireBearerAuth({ verifier });`, + '' + ].join('\n') + ); + + const result = run(migration, { targetDir: dir, dryRun: false }); + + const lines = readFileSync(file, 'utf8').split('\n'); + expect(lines.slice(0, 3)).toEqual(['// Copyright (c) 2026 Example Corp.', '// SPDX-License-Identifier: Apache-2.0', '']); + expect(lines[3]).toBe(`import { requireBearerAuth } from "@modelcontextprotocol/server-legacy/auth";`); + const markerIndex = lines.findIndex(line => line.includes(CODEMOD_ERROR_PREFIX)); + expect(lines[markerIndex + 1]).toContain('requireBearerAuth({ verifier })'); + expect(result.diagnostics.find(d => d.insertComment)?.line).toBe(markerIndex + 1); + }); + it('inserts the resource-server auth helper marker at the usage site', () => { const dir = createTempDir(); writeFileSync( diff --git a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts index 933cf02e29..34fdaf4e4b 100644 --- a/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts +++ b/packages/codemod/test/v1-to-v2/transforms/importPaths.test.ts @@ -204,6 +204,135 @@ describe('import-paths transform', () => { expect(result).toContain('@modelcontextprotocol/server'); }); + it('keeps a license header above the rewritten import, with its blank line intact', () => { + // #2575: the header survived in the text but the re-emitted import was inserted above it, so + // the header stopped being the first thing in the file (breaking eslint-plugin-header / + // SPDX scanners) and the blank line separating it from the code was consumed, turning the + // header into a doc comment for the next declaration. Content-only assertions miss both. + const input = [ + `// Copyright (c) 2026 Example Corp.`, + `// SPDX-License-Identifier: Apache-2.0`, + ``, + `import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`, + ``, + `export const ok = (): CallToolResult => ({ content: [] });`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + const lines = result.split('\n'); + + expect(lines[0]).toBe('// Copyright (c) 2026 Example Corp.'); + expect(lines[1]).toBe('// SPDX-License-Identifier: Apache-2.0'); + // the blank line between header and code must survive, or the header reads as a doc comment + expect(lines[2]).toBe(''); + expect(lines.findIndex(l => l.startsWith('import'))).toBeGreaterThan(1); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toBe(input.replace(`'@modelcontextprotocol/sdk/types.js'`, `"@modelcontextprotocol/server"`)); + }); + + it('does not split a multi-line header run when the SDK import is not the first import', () => { + // #2575, second shape: the rewritten import was inserted *inside* the leading `//` run, + // stranding line 1 above an unrelated import. + const input = [ + `// page_to_markdown tool: fetches a URL and returns clean Markdown.`, + `// Uses @page2ai/core under the hood - inherits SSRF protection and a 10MB cap.`, + `// Static tab discovery emits per-tab sections for docs sites.`, + ``, + `import { fetchAndConvert } from '@page2ai/core';`, + `import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`, + ``, + `export const x = (r: CallToolResult) => fetchAndConvert(r);`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + const lines = result.split('\n'); + + // the three header lines stay contiguous at the top of the file + expect(lines[0]).toContain('page_to_markdown tool'); + expect(lines[1]).toContain('@page2ai/core under the hood'); + expect(lines[2]).toContain('Static tab discovery'); + expect(lines.findIndex(l => l.startsWith('import'))).toBeGreaterThan(2); + expect(result).toContain('@modelcontextprotocol/server'); + expect(result).toBe(input.replace(`'@modelcontextprotocol/sdk/types.js'`, `"@modelcontextprotocol/server"`)); + }); + + it('leaves a comment above a mid-file import attached to that import', () => { + // A comment above a later import documents that import: it stays directly above it. + const input = [ + `import { fetchAndConvert } from '@page2ai/core';`, + ``, + `// Result type returned to the caller.`, + `import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';`, + ``, + `export const x = (r: CallToolResult) => fetchAndConvert(r);`, + '' + ].join('\n'); + const result = applyTransform(input, { projectType: 'server' }); + + expect(result.split('// Result type returned to the caller.').length - 1).toBe(1); + expect(result.indexOf('// Result type returned to the caller.')).toBeGreaterThan(result.indexOf('@page2ai/core')); + expect(result).toBe(input.replace(`'@modelcontextprotocol/sdk/types.js'`, `"@modelcontextprotocol/server"`)); + }); + + it(`keeps a 'use client' directive above the rewritten import`, () => { + const input = [ + `'use client';`, + ``, + `import { Client } from '@modelcontextprotocol/sdk/client/index.js';`, + `import { useState } from 'react';`, + '' + ].join('\n'); + const expected = input.replace(`'@modelcontextprotocol/sdk/client/index.js'`, `"@modelcontextprotocol/client"`); + expect(applyTransform(input)).toBe(expected); + }); + + it('keeps the blank line below a JSDoc-style license header', () => { + const input = [ + `/**`, + ` * @license Apache-2.0`, + ` */`, + ``, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + ``, + `export const server = new McpServer({ name: 'x', version: '1.0.0' });`, + '' + ].join('\n'); + const expected = input.replace(`'@modelcontextprotocol/sdk/server/mcp.js'`, `"@modelcontextprotocol/server"`); + expect(applyTransform(input)).toBe(expected); + }); + + it('does not duplicate a license header that is followed by a JSDoc block', () => { + const input = [ + `// Copyright (c) 2026 Example Corp.`, + ``, + `/**`, + ` * What this file does.`, + ` */`, + ``, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + ``, + `export const server = new McpServer({ name: 'x', version: '1.0.0' });`, + '' + ].join('\n'); + const expected = input.replace(`'@modelcontextprotocol/sdk/server/mcp.js'`, `"@modelcontextprotocol/server"`); + expect(applyTransform(input)).toBe(expected); + }); + + it('puts new imports below a namespace import that is rewritten in place, not above the header', () => { + const input = [ + `/** @license Apache-2.0 */`, + ``, + `import * as types from '@modelcontextprotocol/sdk/types.js';`, + `import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';`, + '' + ].join('\n'); + const lines = applyTransform(input, { projectType: 'server' }).split('\n'); + expect(lines[0]).toBe(`/** @license Apache-2.0 */`); + expect(lines[1]).toBe(''); + expect(lines[2]).toContain('* as types'); + expect(lines[3]).toBe(`import { McpServer } from "@modelcontextprotocol/server";`); + }); + it('routes OAuth *Schema from sdk/shared/auth.js to core; the TYPE resolves by context', () => { // OAuthTokensSchema is a Zod schema re-exported by core (AUTH_SCHEMA_NAMES), so route it // there — `OAuthTokensSchema.parse(...)` keeps working. OAuthTokens (the type) has no schema-name @@ -766,6 +895,29 @@ describe('import-paths transform', () => { const output = sourceFile.getFullText(); expect(output).toContain('@modelcontextprotocol/client'); expect(output).not.toContain('@modelcontextprotocol/sdk'); + const lines = output.split('\n'); + expect(lines[0]).toBe(`import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';`); + expect(lines[1]).toBe(`import type { Tool } from "@modelcontextprotocol/client";`); + expect(lines.findLastIndex(line => line.startsWith('import '))).toBeLessThan(lines.findIndex(line => line.startsWith('const c'))); + }); + + it('keeps the type import with the imports when the value import merges into an existing v2 import', () => { + const input = [ + `// Copyright (c) 2026 Example Corp.`, + `// SPDX-License-Identifier: Apache-2.0`, + ``, + `import { Client } from '@modelcontextprotocol/client';`, + `import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';`, + `import type { Tool } from '@modelcontextprotocol/sdk/types.js';`, + ``, + `const c = new Client({});`, + '' + ].join('\n'); + const lines = applyTransform(input, { projectType: 'client' }).split('\n'); + expect(lines.slice(0, 3)).toEqual(['// Copyright (c) 2026 Example Corp.', '// SPDX-License-Identifier: Apache-2.0', '']); + expect(lines[3]).toBe(`import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';`); + expect(lines[4]).toBe(`import type { Tool } from "@modelcontextprotocol/client";`); + expect(lines.findLastIndex(line => line.startsWith('import '))).toBeLessThan(lines.findIndex(line => line.startsWith('const c'))); }); it('applies SIMPLE_RENAMES to re-export specifiers', () => { From e3fb9edcdec70ed7c8463548ea9b90bbcb74c4ab Mon Sep 17 00:00:00 2001 From: Vijay Misal Date: Mon, 28 Sep 2026 19:51:38 +0530 Subject: [PATCH 04/44] fix(server): restore onclose after modern exchanges (#2778) Signed-off-by: vjymisal0 Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/curvy-rivers-restore.md | 5 ++ .../server/src/server/createMcpHandler.ts | 7 ++- .../test/server/createMcpHandler.test.ts | 47 +++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 .changeset/curvy-rivers-restore.md diff --git a/.changeset/curvy-rivers-restore.md b/.changeset/curvy-rivers-restore.md new file mode 100644 index 0000000000..1c15f14631 --- /dev/null +++ b/.changeset/curvy-rivers-restore.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Fix a stack overflow in `createMcpHandler` when the factory returns the same server instance for more than one request. Returning a fresh instance per request is still required. diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 82cfa061bc..52ac41fd20 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -867,10 +867,15 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa // Track the instance until its exchange tears down so close() can abort it. const previousOnClose = server.onclose; inflight.add(server); - server.onclose = () => { + const onExchangeClose = () => { inflight.delete(server); + // Restore by identity so a handler installed during the exchange is kept. + if (server.onclose === onExchangeClose) { + server.onclose = previousOnClose; + } previousOnClose?.(); }; + server.onclose = onExchangeClose; try { const response = await invoke(product, route.message, { diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 4dae5c0ad7..8de6672f49 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -274,6 +274,53 @@ describe('createMcpHandler — modern path', () => { expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: 'factory exploded' })); }); + it('restores a reused server onclose handler after each modern exchange', async () => { + const reused = new McpServer({ name: 'entry-test-server', version: '1.0.0' }); + reused.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + + const originalOnClose = vi.fn(); + reused.server.onclose = originalOnClose; + + const handler = createMcpHandler(() => reused); + + for (let i = 0; i < 3; i++) { + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: `hello-${i}` }))); + expect(response.status).toBe(200); + await response.text(); + expect(reused.server.onclose).toBe(originalOnClose); + } + + expect(originalOnClose).toHaveBeenCalledTimes(3); + }); + + it('keeps an onclose handler that was installed during the exchange', async () => { + const reused = new McpServer({ name: 'entry-test-server', version: '1.0.0' }); + const installedDuringExchange = vi.fn(); + let chained: (() => void) | undefined; + reused.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => { + if (chained === undefined) { + const previous = reused.server.onclose; + chained = () => { + installedDuringExchange(); + previous?.(); + }; + reused.server.onclose = chained; + } + return { content: [{ type: 'text', text }] }; + }); + + const handler = createMcpHandler(() => reused); + + const response = await handler.fetch(postRequest(modernToolsCall('echo', { text: 'hello' }))); + expect(response.status).toBe(200); + await response.text(); + + expect(reused.server.onclose).toBe(chained); + expect(installedDuringExchange).toHaveBeenCalledTimes(1); + }); + it('closes and releases the per-request instance when a modern exchange fails internally', async () => { const { factory, state } = testFactory(); const onerror = vi.fn(); From c0f7aec2925a46b32dc2f2aad5bf636a36f02c78 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:27:11 +0100 Subject: [PATCH 05/44] fix(client): inline jose types in the CommonJS declaration file (#2883) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/client-cjs-types-inline-jose.md | 5 +++ packages/client/tsdown.config.ts | 2 +- scripts/smoke-dist-types.mjs | 44 +++++++++++++++++++++- 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 .changeset/client-cjs-types-inline-jose.md diff --git a/.changeset/client-cjs-types-inline-jose.md b/.changeset/client-cjs-types-inline-jose.md new file mode 100644 index 0000000000..5c2113c5ca --- /dev/null +++ b/.changeset/client-cjs-types-inline-jose.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Fix a type-check failure for CommonJS TypeScript projects introduced in 2.1.0: `dist/index.d.cts` imported types from `jose`, which is ESM-only, so `tsc` with `module: node16`/`node18` and `skipLibCheck: false` failed with TS1479. The two `jose` types used by the DPoP API (`CryptoKey`, `JWK`) are now inlined into the declaration files. No runtime change. diff --git a/packages/client/tsdown.config.ts b/packages/client/tsdown.config.ts index 45e0d7a28e..1a1229fedf 100644 --- a/packages/client/tsdown.config.ts +++ b/packages/client/tsdown.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ shims: true, dts: { resolver: 'tsc', - resolve: ['ajv', 'ajv-formats', 'json-schema-typed'], + resolve: ['ajv', 'ajv-formats', 'json-schema-typed', 'jose'], compilerOptions: { baseUrl: '.', paths: { diff --git a/scripts/smoke-dist-types.mjs b/scripts/smoke-dist-types.mjs index 7effbb92cd..71584b92cc 100644 --- a/scripts/smoke-dist-types.mjs +++ b/scripts/smoke-dist-types.mjs @@ -3,7 +3,7 @@ // bundler emits only as non-fatal warnings (its failOnWarn does not fail on // MISSING_EXPORT). Run after `pnpm build:all`. import { execFileSync } from 'node:child_process'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -65,6 +65,48 @@ try { ); execFileSync('pnpm', ['exec', 'tsc', '-p', dir], { cwd: repo, stdio: 'inherit' }); console.log('dist-types smoke: clean (skipLibCheck: false)'); + + // CommonJS consumer: resolves through the package `exports` map to the .d.cts files, which must not import ESM-only packages. + const cjs = path.join(dir, 'cjs'); + mkdirSync(path.join(cjs, 'node_modules', '@modelcontextprotocol'), { recursive: true }); + for (const pkg of ['client', 'server']) { + symlinkSync(path.join(repo, 'packages', pkg), path.join(cjs, 'node_modules', '@modelcontextprotocol', pkg), 'dir'); + } + writeFileSync( + path.join(cjs, 'consumer.cts'), + [ + "import { Client } from '@modelcontextprotocol/client';", + "import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';", + "import { McpServer } from '@modelcontextprotocol/server';", + "import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';", + "export const c = new Client({ name: 'smoke', version: '1.0.0' });", + "export const s = new McpServer({ name: 'smoke', version: '1.0.0' });", + 'export { StdioClientTransport, StdioServerTransport };', + '' + ].join('\n') + ); + writeFileSync( + path.join(cjs, 'tsconfig.json'), + JSON.stringify( + { + compilerOptions: { + strict: true, + noEmit: true, + skipLibCheck: false, + module: 'node16', + moduleResolution: 'node16', + target: 'es2022', + types: ['node'], + typeRoots: [path.join(repo, 'node_modules', '@types')] + }, + include: ['consumer.cts'] + }, + null, + 2 + ) + ); + execFileSync('pnpm', ['exec', 'tsc', '-p', cjs], { cwd: repo, stdio: 'inherit' }); + console.log('dist-types smoke (CommonJS consumer): clean (skipLibCheck: false)'); } finally { rmSync(dir, { recursive: true, force: true }); } From d992df724723c30305a573a5087c2743d23acd9f Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:32:00 +0100 Subject: [PATCH 06/44] chore: remove CODEOWNERS patterns that match nothing on main (#2884) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .github/CODEOWNERS | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 596e6991d4..fe03e038d9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -2,10 +2,3 @@ # Default owners for everything in the repo * @modelcontextprotocol/typescript-sdk - -# Auth team owns all auth-related code -/src/server/auth/ @modelcontextprotocol/typescript-sdk-auth -/src/client/auth* @modelcontextprotocol/typescript-sdk-auth -/src/shared/auth* @modelcontextprotocol/typescript-sdk-auth -/src/examples/client/simpleOAuthClient.ts @modelcontextprotocol/typescript-sdk-auth -/src/examples/server/demoInMemoryOAuthProvider.ts @modelcontextprotocol/typescript-sdk-auth \ No newline at end of file From efebf5b2ba2f07c6fa27ada824a0999b801fec0a Mon Sep 17 00:00:00 2001 From: WellAbin <62972474+web-abin@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:07:26 +0800 Subject: [PATCH 07/44] docs(client): correct token endpoint TLS citation (#2768) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/correct-token-endpoint-tls-citation.md | 5 +++++ packages/client/src/client/auth.ts | 7 +++++-- packages/client/src/client/authErrors.ts | 10 ++++++---- 3 files changed, 16 insertions(+), 6 deletions(-) create mode 100644 .changeset/correct-token-endpoint-tls-citation.md diff --git a/.changeset/correct-token-endpoint-tls-citation.md b/.changeset/correct-token-endpoint-tls-citation.md new file mode 100644 index 0000000000..50dd1d3220 --- /dev/null +++ b/.changeset/correct-token-endpoint-tls-citation.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Correct the JSDoc for insecure OAuth token endpoints. The TLS requirement comes from the MCP authorization specification's OAuth 2.1 communication-security rules, not SEP-2207, which covers OIDC-flavored refresh-token guidance. Documentation only; no runtime behavior change. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 6426d8dcb2..ce6b5f2040 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -875,8 +875,11 @@ function isLoopbackHost(hostname: string): boolean { } /** - * SEP-2207: refuse to send credentials to a non-TLS, non-loopback token endpoint. - * Throws {@linkcode InsecureTokenEndpointError}. Loopback hosts are exempt. + * Refuse to send credentials to a non-TLS token endpoint. The MCP authorization + * specification requires authorization server endpoints to use HTTPS. Loopback + * hosts are exempt here for local development. + * + * @throws {@linkcode InsecureTokenEndpointError} if the endpoint is insecure. */ export function assertSecureTokenEndpoint(tokenEndpoint: string | URL): URL { const url = new URL(String(tokenEndpoint)); diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index 5afa88413d..98b2c57c32 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -132,10 +132,12 @@ export class RegistrationRejectedError extends OAuthClientFlowError { /** * Thrown by the token-exchange and refresh paths when the resolved token - * endpoint is not `https:` and is not a loopback host (SEP-2207). This is a - * configuration error — re-authorizing cannot fix it — so it intentionally does - * **not** extend `OAuthError` and `auth()`'s refresh branch rethrows it instead - * of falling through to a fresh `/authorize` redirect. + * endpoint is not `https:` and is not a loopback host. The MCP authorization + * specification requires authorization server endpoints to use HTTPS; loopback + * hosts are exempt here for local development. This is a configuration error — + * re-authorizing cannot fix it — so it intentionally does **not** extend + * `OAuthError` and `auth()`'s refresh branch rethrows it instead of falling + * through to a fresh `/authorize` redirect. */ export class InsecureTokenEndpointError extends OAuthClientFlowError { static { From a4ae2f98da3814a9290f2791cfcca8148dcec978 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:12:05 +0100 Subject: [PATCH 08/44] docs(client): correct the registerClient deprecation citation to spec PR #2858 (#2729) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/dcr-deprecation-citation.md | 5 +++++ docs/migration/upgrade-to-v2.md | 6 ++++-- packages/client/src/client/auth.ts | 15 +++++++++++---- 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 .changeset/dcr-deprecation-citation.md diff --git a/.changeset/dcr-deprecation-citation.md b/.changeset/dcr-deprecation-citation.md new file mode 100644 index 0000000000..14cb84d7e4 --- /dev/null +++ b/.changeset/dcr-deprecation-citation.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Correct the `registerClient` `@deprecated` notice: Dynamic Client Registration was deprecated by spec PR modelcontextprotocol#2858 (Client ID Metadata Documents), not SEP-2577 (which deprecates roots, sampling, and logging). The notice now also names the earliest possible removal date under the feature lifecycle policy (2027-07-28) and clarifies that the `client_id_metadata_document_supported` gating lives in the built-in `auth()` flow — `registerClient` called directly always sends the registration request. Documentation only; no runtime behavior change. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index fa8f2f2175..5b0310b762 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -484,8 +484,10 @@ instead. `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, `RootsListChangedNotification`). -- **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata - Documents per SEP-991. +- **`registerClient`** (Dynamic Client Registration) — deprecated via spec PR + [modelcontextprotocol#2858](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2858) + rather than SEP-2577 (listed here because the `@deprecated` annotations landed in + the same sweep); prefer Client ID Metadata Documents per SEP-991. The deprecation is annotation-only — JSDoc `@deprecated` markers were added, nothing else: every deprecated runtime API keeps its v1 call signature (e.g. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index ce6b5f2040..a3fa18f054 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -2484,10 +2484,17 @@ export async function fetchToken( * consistently across both DCR and the subsequent authorization request. * * @deprecated Dynamic Client Registration is deprecated as of protocol version - * 2026-07-28 (SEP-2577) in favor of Client ID Metadata Documents (SEP-991). - * Remains functional during the deprecation window (at least twelve months). - * Prefer a CIMD URL `client_id` when the authorization server advertises - * `client_id_metadata_document_supported`; the SDK already gates on this for you. + * 2026-07-28 in favor of Client ID Metadata Documents (SEP-991); the deprecation + * landed via spec PR + * {@link https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2858 | modelcontextprotocol#2858} + * (SEP-2577 is the separate roots/sampling/logging deprecation). Remains + * functional during the deprecation window — at least twelve months under the + * feature lifecycle policy (SEP-2596), so 2027-07-28 is the earliest possible + * removal date. Prefer a CIMD URL `client_id` when the authorization server + * advertises `client_id_metadata_document_supported`: the built-in `auth()` flow + * skips registration for you when that capability is advertised AND your + * provider supplies `clientMetadataUrl`, but `registerClient` itself does not + * gate — calling it directly always sends the registration request. */ export async function registerClient( authorizationServerUrl: string | URL, From e780e13869ad419a864b669bd5fa9702cfb36b14 Mon Sep 17 00:00:00 2001 From: Syed Tashfin Date: Mon, 28 Sep 2026 17:29:50 +0200 Subject: [PATCH 09/44] fix(client): preserve _meta on input_required results (#2862) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/input-required-meta-passthrough.md | 5 ++++ .../test/client/inputRequiredEngine.test.ts | 27 +++++++++++++++++++ .../src/shared/inputRequiredEngine.ts | 11 +++++--- packages/core-internal/src/wire/codec.ts | 3 +++ .../src/wire/rev2026-07-28/codec.ts | 8 ++++-- .../test/shared/inputRequiredFunnel.test.ts | 23 ++++++++++++++++ 6 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 .changeset/input-required-meta-passthrough.md diff --git a/.changeset/input-required-meta-passthrough.md b/.changeset/input-required-meta-passthrough.md new file mode 100644 index 0000000000..6f689097e7 --- /dev/null +++ b/.changeset/input-required-meta-passthrough.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Preserve `_meta` on `input_required` results. The 2026-07-28 decode seam rebuilt the payload from `inputRequests` and `requestState` only, so result-level metadata a server sent on an `input_required` result (including `io.modelcontextprotocol/serverInfo`) was dropped before an `allowInputRequired: true` caller could see it. `Result._meta` is a result-level field, so `input_required` carries it exactly like any other result. diff --git a/packages/client/test/client/inputRequiredEngine.test.ts b/packages/client/test/client/inputRequiredEngine.test.ts index e2d8c16dde..e048ea6bc2 100644 --- a/packages/client/test/client/inputRequiredEngine.test.ts +++ b/packages/client/test/client/inputRequiredEngine.test.ts @@ -371,4 +371,31 @@ describe('manual mode', () => { await client.close(); }); + + it('carries the result-level _meta through to the manual caller', async () => { + const { clientTx } = await scriptedModernServer(() => ({ + resultType: 'input_required', + inputRequests: { github_login: ELICIT_ENTRY }, + requestState: 'manual-state', + _meta: { + 'io.modelcontextprotocol/serverInfo': { name: 'scripted-mrtr-server', version: '1.0.0' }, + 'example.com/displayHint': 'inline' + } + })); + + const client = makeClient({ inputRequired: { autoFulfill: false } }); + await client.connect(clientTx); + + const first = (await client.callTool({ name: 'deploy', arguments: {} }, { allowInputRequired: true })) as unknown as Record< + string, + unknown + >; + expect(first.resultType).toBe('input_required'); + expect(first._meta).toEqual({ + 'io.modelcontextprotocol/serverInfo': { name: 'scripted-mrtr-server', version: '1.0.0' }, + 'example.com/displayHint': 'inline' + }); + + await client.close(); + }); }); diff --git a/packages/core-internal/src/shared/inputRequiredEngine.ts b/packages/core-internal/src/shared/inputRequiredEngine.ts index cc8fec2c4b..03d6319d98 100644 --- a/packages/core-internal/src/shared/inputRequiredEngine.ts +++ b/packages/core-internal/src/shared/inputRequiredEngine.ts @@ -13,7 +13,7 @@ * `droppedInputResponseKeys` context fields), and the named extension point. */ import { SdkError, SdkErrorCode } from '../errors/sdkErrors'; -import type { InputRequiredResult, JSONRPCRequest, RequestMeta, Result } from '../types/types'; +import type { InputRequiredResult, JSONRPCRequest, RequestMeta, Result, ResultMetaObject } from '../types/types'; import type { StandardSchemaV1 } from '../util/standardSchema'; import type { WireCodec } from '../wire/codec'; import type { @@ -237,10 +237,15 @@ export function runInputRequiredFlow( * codec's decoded payload — what an `allowInputRequired: true` caller * receives instead of the auto-fulfilled complete result. */ -export function manualInputRequiredValue(decoded: { inputRequests: Record; requestState?: string }): InputRequiredResult { +export function manualInputRequiredValue(decoded: { + inputRequests: Record; + requestState?: string; + _meta?: ResultMetaObject; +}): InputRequiredResult { return { resultType: 'input_required', inputRequests: decoded.inputRequests as InputRequiredResult['inputRequests'], - ...(decoded.requestState !== undefined && { requestState: decoded.requestState }) + ...(decoded.requestState !== undefined && { requestState: decoded.requestState }), + ...(decoded._meta !== undefined && { _meta: decoded._meta }) }; } diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index 7672a8d64a..cf545c2ebc 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -57,6 +57,7 @@ import type { RequestMethod, RequestTypeMap, Result, + ResultMetaObject, ResultTypeMap } from '../types/types'; import { rev2025Codec } from './rev2025-11-25/codec'; @@ -147,6 +148,8 @@ export type DecodedResult = */ inputRequests: Record; requestState?: string; + /** Result-level `_meta`, carried through so a manual caller sees it. */ + _meta?: ResultMetaObject; } | { kind: 'invalid'; error: SdkError }; diff --git a/packages/core-internal/src/wire/rev2026-07-28/codec.ts b/packages/core-internal/src/wire/rev2026-07-28/codec.ts index 9bc6c4d737..c26d5ce467 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/codec.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/codec.ts @@ -29,7 +29,7 @@ import type * as z from 'zod/v4'; import { SdkError, SdkErrorCode } from '../../errors/sdkErrors'; import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, LOG_LEVEL_META_KEY, PROTOCOL_VERSION_META_KEY } from '../../types/constants'; -import type { CallToolResult, Implementation, Result } from '../../types/types'; +import type { CallToolResult, Implementation, Result, ResultMetaObject } from '../../types/types'; import type { DecodedResult, EnvelopeIssue, LiftedWireMaterial, OutboundEnvelopeMaterial, ValidateOutcome, WireCodec } from '../codec'; import { appendTextFallbackForNonObject } from '../textFallback'; import { buildSchemas2026 } from './buildSchemas'; @@ -202,6 +202,9 @@ export const rev2026Codec: WireCodec & { const rawInputRequests = raw['inputRequests']; const inputRequests = isPlainObject(rawInputRequests) ? rawInputRequests : {}; const requestState = raw['requestState']; + // Carry result-level `_meta` through, parsed as on complete results (malformed drops to absent). + const metaParse = raw['_meta'] === undefined ? undefined : buildSchemas2026().ResultMetaSchema.safeParse(raw['_meta']); + const meta = metaParse?.success ? (metaParse.data as ResultMetaObject) : undefined; if (Object.keys(inputRequests).length === 0 && typeof requestState !== 'string') { // At-least-one rule, client side: with neither inputRequests // nor requestState there is nothing to fulfil and nothing to @@ -220,7 +223,8 @@ export const rev2026Codec: WireCodec & { return { kind: 'input_required', inputRequests, - ...(typeof requestState === 'string' && { requestState }) + ...(typeof requestState === 'string' && { requestState }), + ...(meta !== undefined && { _meta: meta }) }; } if (rawResultType !== 'complete') { diff --git a/packages/core-internal/test/shared/inputRequiredFunnel.test.ts b/packages/core-internal/test/shared/inputRequiredFunnel.test.ts index 85642f8f43..84ccdea58f 100644 --- a/packages/core-internal/test/shared/inputRequiredFunnel.test.ts +++ b/packages/core-internal/test/shared/inputRequiredFunnel.test.ts @@ -67,6 +67,29 @@ describe('manual mode (allowInputRequired)', () => { await protocol.close(); }); + test('preserves the result-level _meta on the handed-back value', async () => { + const protocol = await wireWithRawResult({ + ...INPUT_REQUIRED_BODY, + _meta: { 'io.modelcontextprotocol/serverInfo': { name: 'peer', version: '1.0.0' }, 'example.com/displayHint': 'inline' } + }); + + const result = await protocol.request( + { method: 'tools/call', params: { name: 'echo', arguments: {} } }, + { + allowInputRequired: true + } + ); + + // `Result._meta` is a result-level field, so an `input_required` result + // carries it like any other complete result would. + expect((result as Record)['_meta']).toEqual({ + 'io.modelcontextprotocol/serverInfo': { name: 'peer', version: '1.0.0' }, + 'example.com/displayHint': 'inline' + }); + + await protocol.close(); + }); + test('discrimination happens on the raw body, before the consumer-provided result schema runs', async () => { const protocol = await wireWithRawResult(INPUT_REQUIRED_BODY); From 9dd722fd0b533b3cb44cd2834409f7ea4dd32b00 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:53:16 +0100 Subject: [PATCH 10/44] fix(core): await the notification send so a failed send is never briefly unhandled (#2885) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/await-notification-send.md | 6 ++ ...egacyHandshakeCloseAfterInitialize.test.ts | 82 +++++++++++++++++++ packages/core-internal/src/shared/protocol.ts | 2 +- .../shared/notificationSendRejection.test.ts | 82 +++++++++++++++++++ .../server/notificationSendRejection.test.ts | 52 ++++++++++++ 5 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 .changeset/await-notification-send.md create mode 100644 packages/client/test/client/legacyHandshakeCloseAfterInitialize.test.ts create mode 100644 packages/core-internal/test/shared/notificationSendRejection.test.ts create mode 100644 packages/server/test/server/notificationSendRejection.test.ts diff --git a/.changeset/await-notification-send.md b/.changeset/await-notification-send.md new file mode 100644 index 0000000000..2df131c33c --- /dev/null +++ b/.changeset/await-notification-send.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +Sending a notification on a closed connection no longer produces a briefly unhandled promise rejection (seen as `unhandledrejection` on Cloudflare Workers) in addition to the returned rejection. diff --git a/packages/client/test/client/legacyHandshakeCloseAfterInitialize.test.ts b/packages/client/test/client/legacyHandshakeCloseAfterInitialize.test.ts new file mode 100644 index 0000000000..5288d0b8d9 --- /dev/null +++ b/packages/client/test/client/legacyHandshakeCloseAfterInitialize.test.ts @@ -0,0 +1,82 @@ +/** + * The trigger reported in #2864: the transport closes during the legacy + * `initialize` handshake, right after the server's result is delivered, so the + * client's `notifications/initialized` is sent on a connection that is already + * gone. `connect()` must reject through its returned promise with the + * not-connected send failure, and nothing else may escape. + * + * Node's rejection tracker does not report the one-microtask handler gap that + * workerd does (see core-internal's notificationSendRejection test for the + * timing observation itself); this end-to-end test pins the trigger path and + * the error `connect()` rejects with. + */ +import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal'; +import { isJSONRPCRequest, SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client'; + +/** Answers `initialize`, then closes in the same tick — before the client can send `notifications/initialized`. */ +class ReplyThenCloseTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + + sent: JSONRPCMessage[] = []; + closeCalls = 0; + + async start(): Promise {} + + async send(message: JSONRPCMessage): Promise { + this.sent.push(message); + if (!isJSONRPCRequest(message) || message.method !== 'initialize') return; + queueMicrotask(() => { + this.onmessage?.({ + jsonrpc: '2.0', + id: message.id, + result: { protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'flaky', version: '0' } } + }); + this.onclose?.(); + }); + } + + async close(): Promise { + this.closeCalls++; + } +} + +describe('legacy handshake: transport closes between the initialize result and notifications/initialized', () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + + beforeEach(() => { + unhandled.length = 0; + process.on('unhandledRejection', onUnhandled); + }); + + afterEach(() => { + process.off('unhandledRejection', onUnhandled); + }); + + test('connect() rejects with SdkError NotConnected from the initialized-notification send and no rejection escapes', async () => { + const transport = new ReplyThenCloseTransport(); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'legacy' } }); + + const rejection = await client.connect(transport).then( + () => undefined, + (error: unknown) => error + ); + + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.NotConnected); + // The handshake got as far as the send that failed: initialize went out, + // the initialized notification never did. + expect(transport.sent.map(m => ('method' in m ? m.method : 'response'))).toEqual(['initialize']); + + // Let any stray rejection surface before asserting none did. + await new Promise(resolve => setTimeout(resolve, 0)); + expect(unhandled).toEqual([]); + }); +}); diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 637be389aa..8e0bc04034 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -1593,7 +1593,7 @@ export abstract class Protocol { * Emits a notification, which is a one-way message that does not expect a response. */ async notification(notification: Notification, options?: NotificationOptions): Promise { - return this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options); + return await this._notificationViaCodec(this._resolveOutboundCodec(notification.method), notification, options); } /** diff --git a/packages/core-internal/test/shared/notificationSendRejection.test.ts b/packages/core-internal/test/shared/notificationSendRejection.test.ts new file mode 100644 index 0000000000..88129278bb --- /dev/null +++ b/packages/core-internal/test/shared/notificationSendRejection.test.ts @@ -0,0 +1,82 @@ +/** + * `Protocol.notification()` must hand its caller the ONLY rejection a failed + * send produces. The send funnel (`_notificationViaCodec`) is an async method + * that throws synchronously when there is no transport, so the promise it + * returns is already rejected. If `notification()` returns that promise + * instead of awaiting it, the async function resolves with a thenable: its + * `then` is read synchronously at return, but the call is deferred to the + * thenable job, so the inner rejection sits with no handler for one + * microtask. Node's tracker forgives that; + * workerd (Cloudflare Workers) reports it as `unhandledrejection` followed by + * `rejectionhandled`, which surfaces as noise in Vitest runs on that platform + * (#2864). + * + * The observation below is the handler-attachment timing itself: with + * `return await`, `await` attaches its reaction synchronously through the + * internal promise path and the inner promise's own `then` property is never + * read; with a bare `return`, `then` is read at return and called one + * microtask later. + */ +import { describe, expect, test } from 'vitest'; + +import { SdkError, SdkErrorCode } from '../../src/errors/sdkErrors'; +import type { BaseContext } from '../../src/shared/protocol'; +import { Protocol } from '../../src/shared/protocol'; + +class TestProtocolImpl extends Protocol { + protected assertCapabilityForMethod(): void {} + protected assertNotificationCapability(): void {} + protected assertRequestHandlerCapability(): void {} + protected buildContext(ctx: BaseContext): BaseContext { + return ctx; + } +} + +/** + * A natively rejected promise whose `then` property records every read. + * Resolving an async function with a thenable reaches `then` via property + * lookup; `await` on a native promise does not. + */ +function instrumentedRejection(error: Error): { promise: Promise; thenReads: () => number } { + const promise = Promise.reject(error); + const originalThen = promise.then.bind(promise); + let reads = 0; + Object.defineProperty(promise, 'then', { + get() { + reads++; + return originalThen; + } + }); + return { promise, thenReads: () => reads }; +} + +describe('Protocol.notification(): a failed send rejects only through the returned promise', () => { + test('when not connected, the inner send rejection is handled in the same microtask (no thenable-job hop)', async () => { + const protocol = new TestProtocolImpl(); + const notConnected = new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + const inner = instrumentedRejection(notConnected); + + // Stand in for the real funnel with a rejection we can observe. The real + // one throws synchronously on `!this._transport`, i.e. it also returns an + // already-rejected promise — the shape that matters here. + // Plain instance override rather than `vi.spyOn`: the spy wrapper itself + // reads `then` on any promise a spied call returns, which would mask the + // observation below. + (protocol as unknown as { _notificationViaCodec: () => Promise })._notificationViaCodec = () => inner.promise; + + await expect(protocol.notification({ method: 'notifications/initialized' })).rejects.toBe(notConnected); + + // A bare `return innerPromise` from the async method reads `then` + // synchronously at return and calls it one microtask later — the window + // in which workerd reports the inner rejection unhandled. + expect(inner.thenReads()).toBe(0); + }); + + test('when not connected, the returned promise still rejects with SdkError NotConnected (unstubbed path)', async () => { + const protocol = new TestProtocolImpl(); + + await expect(protocol.notification({ method: 'notifications/initialized' })).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.NotConnected + ); + }); +}); diff --git a/packages/server/test/server/notificationSendRejection.test.ts b/packages/server/test/server/notificationSendRejection.test.ts new file mode 100644 index 0000000000..3ff02f0909 --- /dev/null +++ b/packages/server/test/server/notificationSendRejection.test.ts @@ -0,0 +1,52 @@ +/** + * Server-side twin of core-internal's notificationSendRejection test: a + * `sendLoggingMessage()` on a server with no transport must reject ONLY + * through the returned promise. The send funnel returns an already-rejected + * promise; if `Protocol.notification()` returned it instead of awaiting it, + * the inner rejection would sit unhandled for one microtask (reported by + * workerd as `unhandledrejection` + `rejectionhandled`, #2864). + */ +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { describe, expect, test } from 'vitest'; + +import { Server } from '../../src/server/server'; + +function instrumentedRejection(error: Error): { promise: Promise; thenReads: () => number } { + const promise = Promise.reject(error); + const originalThen = promise.then.bind(promise); + let reads = 0; + Object.defineProperty(promise, 'then', { + get() { + reads++; + return originalThen; + } + }); + return { promise, thenReads: () => reads }; +} + +describe('Server notification sends on a closed connection', () => { + test('sendLoggingMessage() when not connected: the inner send rejection is handled in the same microtask', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { logging: {} } }); + const notConnected = new SdkError(SdkErrorCode.NotConnected, 'Not connected'); + const inner = instrumentedRejection(notConnected); + + // Plain instance override rather than `vi.spyOn`: the spy wrapper itself + // reads `then` on any promise a spied call returns, which would mask the + // observation below. + (server as unknown as { _notificationViaCodec: () => Promise })._notificationViaCodec = () => inner.promise; + + await expect(server.sendLoggingMessage({ level: 'info', data: 'hello' })).rejects.toBe(notConnected); + + // `then` is read (synchronously at return, called one microtask later) + // only when `notification()` returns the inner promise without awaiting it. + expect(inner.thenReads()).toBe(0); + }); + + test('sendLoggingMessage() when not connected still rejects with SdkError NotConnected (unstubbed path)', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: { logging: {} } }); + + await expect(server.sendLoggingMessage({ level: 'info', data: 'hello' })).rejects.toSatisfy( + (error: unknown) => error instanceof SdkError && error.code === SdkErrorCode.NotConnected + ); + }); +}); From ef39308e963496b75e50553f6265d99cacb4fbb9 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:44:22 +0100 Subject: [PATCH 11/44] fix(client): follow nextCursor until it is absent; stop on a repeated page (#2886) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/keep-next-cursor-on-early-stop.md | 5 ++ packages/client/src/client/client.ts | 27 ++++---- .../client/test/client/responseCache.test.ts | 68 ++++++++++++++++++- 3 files changed, 83 insertions(+), 17 deletions(-) create mode 100644 .changeset/keep-next-cursor-on-early-stop.md diff --git a/.changeset/keep-next-cursor-on-early-stop.md b/.changeset/keep-next-cursor-on-early-stop.md new file mode 100644 index 0000000000..a56a05d899 --- /dev/null +++ b/.changeset/keep-next-cursor-on-early-stop.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +`listTools()`, `listPrompts()`, `listResources()` and `listResourceTemplates()` called without a cursor now follow `nextCursor` until the server stops sending one, instead of stopping silently with a short list when a cursor repeats; a page that has the same items and the same `nextCursor` as the page before it ends the walk and is not added twice, and `listMaxPages` still caps the walk. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index a6665e4e15..8118b60cea 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -1614,7 +1614,7 @@ export class Client extends Protocol { } const hit = await this._serveFromCache('prompts/list', undefined, options); if (hit !== undefined) return hit; - return this._listAllPages('prompts/list', params, options, (acc, page) => acc.prompts.push(...page.prompts)); + return this._listAllPages('prompts/list', params, options, r => r.prompts); } /** @@ -1654,9 +1654,7 @@ export class Client extends Protocol { } const hit = await this._serveFromCache('resources/list', undefined, options); if (hit !== undefined) return hit; - return this._listAllPages('resources/list', params, options, (acc, page) => - acc.resources.push(...page.resources) - ); + return this._listAllPages('resources/list', params, options, r => r.resources); } /** @@ -1687,9 +1685,7 @@ export class Client extends Protocol { } const hit = await this._serveFromCache('resources/templates/list', undefined, options); if (hit !== undefined) return hit; - return this._listAllPages('resources/templates/list', params, options, (acc, page) => - acc.resourceTemplates.push(...page.resourceTemplates) - ); + return this._listAllPages('resources/templates/list', params, options, r => r.resourceTemplates); } /** @@ -1698,8 +1694,7 @@ export class Client extends Protocol { * methods' no-`cursor` auto-aggregate path. Page 1's result object is * mutated in place (its items array is extended; `nextCursor` is * cleared); page-1 metadata (`ttlMs`, `cacheScope`, `_meta`) is preserved. - * A `nextCursor` that repeats stops the walk (defence against a - * non-converging server, mcp.d's `drainList` guard); + * A page with the same items and `nextCursor` as the previous page ends the walk; * {@linkcode ClientOptions.listMaxPages} is a hard cap — hitting it * throws, so a partial aggregate is never cached. The * captured-generation guard skips the write when a `list_changed` landed @@ -1718,7 +1713,7 @@ export class Client extends Protocol { method: RequestMethod, baseParams: { readonly [key: string]: unknown } | undefined, options: CacheableRequestOptions | undefined, - append: (acc: R, page: R) => void, + items: (result: R) => unknown[], finalize?: (acc: R) => void ): Promise { // `'bypass'` is the no-touch path: the cache is neither read nor @@ -1732,9 +1727,9 @@ export class Client extends Protocol { const generation = this._cache.captureGeneration(method); const acc = (await this.request({ method, ...(baseParams && { params: { ...baseParams } }) }, options)) as R; let cursor = acc.nextCursor; - const seen = new Set(); + let previous = acc; let pages = 1; - while (cursor !== undefined && !seen.has(cursor)) { + while (cursor !== undefined) { if (this._listMaxPages !== 0 && pages >= this._listMaxPages) { throw new SdkError( SdkErrorCode.ListPaginationExceeded, @@ -1742,9 +1737,11 @@ export class Client extends Protocol { { method, listMaxPages: this._listMaxPages } ); } - seen.add(cursor); const page = (await this.request({ method, params: { ...baseParams, cursor } }, options)) as R; - append(acc, page); + // Same items and same cursor as the page before: the server made no progress. + if (page.nextCursor === cursor && JSON.stringify(items(page)) === JSON.stringify(items(previous))) break; + items(acc).push(...items(page)); + previous = page; cursor = page.nextCursor; pages++; } @@ -2536,7 +2533,7 @@ export class Client extends Protocol { 'tools/list', params, options, - (acc, page) => acc.tools.push(...page.tools), + r => r.tools, acc => this._excludeInvalidXMcpHeaderTools(acc) ); } diff --git a/packages/client/test/client/responseCache.test.ts b/packages/client/test/client/responseCache.test.ts index cec28ac6d8..e2e2326705 100644 --- a/packages/client/test/client/responseCache.test.ts +++ b/packages/client/test/client/responseCache.test.ts @@ -326,6 +326,13 @@ interface ScriptOptions { listHint?: { ttlMs?: number; cacheScope?: 'public' | 'private' }; readHint?: { ttlMs?: number; cacheScope?: 'public' | 'private' }; serverInfo?: { name: string; version: string }; + /** + * When set, every `tools/list` page but the last carries this one `nextCursor` + * (a server that repeats a cursor). The server keeps its own position and + * serves the scripted pages in order; each response carries a `_meta` value + * that differs per response. + */ + repeatCursor?: string; } async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): Promise { @@ -333,6 +340,7 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): let lists = 0; const wireCounts = new Map(); const params: ({ cursor?: string; _meta?: unknown } | undefined)[] = []; + let served = 0; serverTx.onmessage = m => { const r = m as JSONRPCRequest; if (r.id === undefined) return; @@ -352,8 +360,8 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): lists++; params.push(r.params as { cursor?: string; _meta?: unknown } | undefined); const cursor = (r.params as { cursor?: string } | undefined)?.cursor; - const idx = cursor === undefined ? 0 : Number(cursor); - const next = idx + 1 < pages.length ? String(idx + 1) : undefined; + const idx = opts.repeatCursor !== undefined ? served++ : cursor === undefined ? 0 : Number(cursor); + const next = idx + 1 < pages.length ? (opts.repeatCursor ?? String(idx + 1)) : undefined; void serverTx.send({ jsonrpc: '2.0', id: r.id, @@ -362,6 +370,7 @@ async function scriptedModernServer(pages: Tool[][], opts: ScriptOptions = {}): ttlMs: opts.listHint?.ttlMs ?? 0, cacheScope: opts.listHint?.cacheScope ?? 'private', tools: pages[idx] ?? [], + ...(opts.repeatCursor !== undefined && { _meta: { 'scripted/page': idx } }), ...(next !== undefined && { nextCursor: next }) } }); @@ -493,6 +502,61 @@ describe('Client response-cache substrate', () => { expect(page.tools.map(t => t.name)).toEqual(['a']); }); + it('follows a cursor that repeats until nextCursor is absent', async () => { + // The server hands out the same cursor (`''`) on every page and tracks + // its own position; the walk ends when `nextCursor` is absent (#2735). + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A, TOOL_B]], { repeatCursor: '' }); + const client = modernClient(); + await client.connect(clientTx); + + const result = await client.listTools(); + expect(result.tools.map(t => t.name)).toEqual(['a', 'b', 'a', 'b']); + expect(listCount()).toBe(3); + expect(result).not.toHaveProperty('nextCursor'); + }); + + it('a page with the same items and cursor as the one before ends the walk and is not appended', async () => { + const store = new InMemoryResponseCacheStore(); + // Every response is `[a, b]` with the same `nextCursor` (`''`); only + // `_meta` differs. Page 2 makes no progress, so it ends the walk. + const { clientTx, listCount } = await scriptedModernServer( + [ + [TOOL_A, TOOL_B], + [TOOL_A, TOOL_B], + [TOOL_A, TOOL_B] + ], + { + repeatCursor: '' + } + ); + const client = modernClient(store); + await client.connect(clientTx); + + const result = await client.listTools(); + expect(result.tools.map(t => t.name)).toEqual(['a', 'b']); + expect(listCount()).toBe(2); + expect(result).not.toHaveProperty('nextCursor'); + + const entry = store.get({ method: 'tools/list', partition: part() }); + expect((JSON.parse(entry!.value) as { tools: Tool[] }).tools.map(t => t.name)).toEqual(['a', 'b']); + }); + + it('listMaxPages still ends a walk whose cursor repeats while the pages keep changing', async () => { + const { clientTx, listCount } = await scriptedModernServer([[TOOL_A], [TOOL_B], [TOOL_A], [TOOL_B], [TOOL_A]], { + repeatCursor: '' + }); + const client = new Client( + { name: 'cache-client', version: '1.0.0' }, + { versionNegotiation: { mode: { pin: MODERN } }, listMaxPages: 3 } + ); + await client.connect(clientTx); + + const error = await client.listTools().catch(e => e as SdkError); + expect(error).toBeInstanceOf(SdkError); + expect((error as SdkError).code).toBe(SdkErrorCode.ListPaginationExceeded); + expect(listCount()).toBe(3); + }); + it('listPrompts/listResources/listResourceTemplates auto-aggregate and write the response cache', async () => { const store = new InMemoryResponseCacheStore(); const { clientTx } = await scriptedModernServer([[TOOL_A]]); From edd12e282620ebf770d67316f19cf91d4112a1bd Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:27:47 +0100 Subject: [PATCH 12/44] fix(client): deprecate omitting expectedIssuer and apply the SEP-2352 issuer check consistently (#2887) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../expected-issuer-deprecate-omission.md | 10 + docs/clients/machine-auth.md | 13 +- docs/migration/upgrade-to-v2.md | 13 +- .../guides/clients/machine-auth.examples.ts | 9 +- examples/oauth-client-credentials/README.md | 3 +- examples/oauth-client-credentials/client.ts | 7 +- examples/oauth/dualModeAuth.ts | 16 +- packages/client/src/client/auth.ts | 37 +++- packages/client/src/client/authErrors.ts | 25 ++- .../src/client/authExtensions.examples.ts | 6 +- packages/client/src/client/authExtensions.ts | 53 ++++- packages/client/src/client/crossAppAccess.ts | 9 +- packages/client/test/client/auth.test.ts | 189 +++++++++++++++++- .../client/test/client/authExtensions.test.ts | 53 +++++ .../client/test/client/crossAppAccess.test.ts | 18 ++ .../core-internal/src/shared/authUtils.ts | 8 + .../test/shared/authUtils.test.ts | 23 ++- packages/core/src/auth.ts | 28 ++- .../src/auth/providers/proxyProvider.ts | 8 +- .../test/auth/providers/proxyProvider.test.ts | 36 ++++ test/e2e/scenarios/client-auth.test.ts | 12 +- 21 files changed, 499 insertions(+), 77 deletions(-) create mode 100644 .changeset/expected-issuer-deprecate-omission.md diff --git a/.changeset/expected-issuer-deprecate-omission.md b/.changeset/expected-issuer-deprecate-omission.md new file mode 100644 index 0000000000..8fa6d07a42 --- /dev/null +++ b/.changeset/expected-issuer-deprecate-omission.md @@ -0,0 +1,10 @@ +--- +'@modelcontextprotocol/client': minor +'@modelcontextprotocol/core': minor +--- + +Constructing `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider` or `CrossAppAccessProvider` without `expectedIssuer` is deprecated: the constructor logs one `console.warn` and that call signature is marked `@deprecated`. Behaviour is otherwise unchanged. Pass the `issuer` of the authorization server the credentials were registered with. + +`fetchToken()` throws `AuthorizationServerMismatchError`, before sending anything, when the provider's client information is bound to a different authorization server than the one it is called with. The `AuthorizationServerMismatchError` message no longer assumes the authorization-code callback; its fields are unchanged. + +`OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer` stamp, so a provider that reads storage back through them keeps it. `auth()` overwrites it on every save. diff --git a/docs/clients/machine-auth.md b/docs/clients/machine-auth.md index 1b3c6614bb..d3c7d2caa5 100644 --- a/docs/clients/machine-auth.md +++ b/docs/clients/machine-auth.md @@ -15,7 +15,8 @@ import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from const authProvider = new ClientCredentialsProvider({ clientId: 'reporting-job', - clientSecret: 'reporting-job-secret' + clientSecret: 'reporting-job-secret', + expectedIssuer: 'https://auth.example.com' }); const client = new Client({ name: 'reporting-job', version: '1.0.0' }); @@ -26,9 +27,7 @@ await client.connect(transport); `connect` discovers the server's authorization server, posts the grant to its token endpoint, and attaches the access token to every request. On a 401 the provider refreshes the token and the transport retries once. No browser, no end user. -::: tip -Pass `expectedIssuer` to pin the credential to the authorization server it was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret. -::: +`expectedIssuer` is the `issuer` of the authorization server the credential was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret. `PrivateKeyJwtProvider` and `CrossAppAccessProvider` below take it too; omitting it is deprecated, and the credential then goes to whichever authorization server the MCP server advertises. ## Bring your own bearer token @@ -50,7 +49,8 @@ The transport calls `token()` before every request and sets the `Authorization` const authProvider = new PrivateKeyJwtProvider({ clientId: 'reporting-job', privateKey: pemEncodedKey, - algorithm: 'RS256' + algorithm: 'RS256', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); @@ -80,7 +80,8 @@ const authProvider = new CrossAppAccessProvider({ return grant.jwtAuthGrant; }, clientId: 'reporting-job', - clientSecret: 'reporting-job-secret' + clientSecret: 'reporting-job-secret', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 5b0310b762..b7bacd6adf 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1155,6 +1155,7 @@ path will not catch them): | RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | | Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) | | `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | +| `auth()` on a provider that cannot re-register, or `fetchToken()`: client information stamped for a different AS | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | #### Connect-time OAuth retry (`UnauthorizedError`) @@ -1268,7 +1269,8 @@ same handling as the POST send path. `auth()` stamps an `issuer` field onto every value it passes to `saveTokens()` / `saveClientInformation()` and threads `{ issuer }` as the `ctx` argument to those methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer` -names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. +names a different AS is treated as `undefined` and the flow re-registers / re-authorizes +(or throws `AuthorizationServerMismatchError` when the provider has no `saveClientInformation()`). **Round-trip the stored object verbatim and you're protected** — single-slot storage works. Dropping the stamp is easy to miss: a `saveTokens()` implementation that rebuilds the object field-by-field and drops `issuer` leaves the value unstamped — @@ -1278,14 +1280,15 @@ re-stamps on first use where the provider can persist it). If you see that warni repeating after upgrading, check this first. To hold credentials for several authorization servers at once, key your storage on `ctx.issuer` (treat **`ctx === undefined` as "return the most-recently-saved token set"** — the transport's per-request `Authorization: Bearer` read calls `tokens()` with -no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation` -add an optional `issuer?: string` field on top of the wire types. +no `ctx`). `OAuthTokensSchema` / `OAuthClientInformationSchema` keep the optional `issuer`, so +reading storage back through them is fine; the `StoredOAuthTokens` / `StoredOAuthClientInformation` +aliases name the stored shape. `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are `@deprecated` (still written for back-compat, never read by the SDK). The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and -`CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define -`saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the +`CrossAppAccessProvider` gain `expectedIssuer?: string` (omitting it is deprecated) and no +longer define `saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the callback leg can verify it is exchanging the code at the same AS the redirect targeted; without it the SDK `console.warn`s once per callback (`discoveryState` must persist with the same durability as `codeVerifier`). Both methods are optional on diff --git a/examples/guides/clients/machine-auth.examples.ts b/examples/guides/clients/machine-auth.examples.ts index 373c5bdc04..4eff63198c 100644 --- a/examples/guides/clients/machine-auth.examples.ts +++ b/examples/guides/clients/machine-auth.examples.ts @@ -23,7 +23,8 @@ import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from const authProvider = new ClientCredentialsProvider({ clientId: 'reporting-job', - clientSecret: 'reporting-job-secret' + clientSecret: 'reporting-job-secret', + expectedIssuer: 'https://auth.example.com' }); const client = new Client({ name: 'reporting-job', version: '1.0.0' }); @@ -48,7 +49,8 @@ function privateKeyJwt_provider(pemEncodedKey: string) { const authProvider = new PrivateKeyJwtProvider({ clientId: 'reporting-job', privateKey: pemEncodedKey, - algorithm: 'RS256' + algorithm: 'RS256', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); @@ -76,7 +78,8 @@ function crossAppAccess_provider(getIdToken: () => Promise) { return grant.jwtAuthGrant; }, clientId: 'reporting-job', - clientSecret: 'reporting-job-secret' + clientSecret: 'reporting-job-secret', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); diff --git a/examples/oauth-client-credentials/README.md b/examples/oauth-client-credentials/README.md index 37bd1525a9..d8fdf23ab2 100644 --- a/examples/oauth-client-credentials/README.md +++ b/examples/oauth-client-credentials/README.md @@ -34,7 +34,8 @@ import { PrivateKeyJwtProvider } from '@modelcontextprotocol/client'; const authProvider = new PrivateKeyJwtProvider({ clientId: 'my-service', privateKey: pemEncodedKey, - algorithm: 'RS256' + algorithm: 'RS256', + expectedIssuer: 'https://auth.example.com' }); ``` diff --git a/examples/oauth-client-credentials/client.ts b/examples/oauth-client-credentials/client.ts index 3d40654fac..fa14f6df28 100644 --- a/examples/oauth-client-credentials/client.ts +++ b/examples/oauth-client-credentials/client.ts @@ -10,7 +10,8 @@ * granted scopes. * * No browser, no readline. The SDK's auth driver does the discovery; the only - * thing the caller supplies is the pre-registered client's id+secret. + * thing the caller supplies is the pre-registered client's id+secret and the + * issuer of the AS it is registered with. */ import { check, parseExampleArgs } from '@mcp-examples/shared'; import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; @@ -28,9 +29,13 @@ check.match(unauth.headers.get('www-authenticate') ?? '', /Bearer/); check.match(unauth.headers.get('www-authenticate') ?? '', /oauth-protected-resource/); // Authenticated via client_credentials → 200, ctx.authInfo carries the granted scopes. +// `expectedIssuer` names the AS the client is registered with (the story's AS listens on PORT+1). +const authServerUrl = new URL('/', url); +authServerUrl.port = String(Number(authServerUrl.port) + 1); const provider = new ClientCredentialsProvider({ clientId: 'demo-m2m-client', clientSecret: 'demo-m2m-secret', + expectedIssuer: authServerUrl.href, scope: 'mcp:tools mcp:read' }); const client = new Client( diff --git a/examples/oauth/dualModeAuth.ts b/examples/oauth/dualModeAuth.ts index ac4dcc1e82..28aff03b50 100644 --- a/examples/oauth/dualModeAuth.ts +++ b/examples/oauth/dualModeAuth.ts @@ -55,11 +55,16 @@ function createHostManagedTransport(serverUrl: URL, session: HostSessionStore, u // --- MODE B: User-configured OAuth ----------------------------------------- -function createUserConfiguredTransport(serverUrl: URL, clientId: string, clientSecret: string): StreamableHTTPClientTransport { +function createUserConfiguredTransport( + serverUrl: URL, + clientId: string, + clientSecret: string, + expectedIssuer: string +): StreamableHTTPClientTransport { // Built-in OAuth provider — the transport adapts it to AuthProvider internally. // On 401, adaptOAuthProvider synthesizes onUnauthorized → handleOAuthUnauthorized, // which runs token refresh (or redirect for interactive flows). - const authProvider = new ClientCredentialsProvider({ clientId, clientSecret }); + const authProvider = new ClientCredentialsProvider({ clientId, clientSecret, expectedIssuer }); return new StreamableHTTPClientTransport(serverUrl, { authProvider }); } @@ -92,11 +97,12 @@ async function main() { } else if (mode === 'oauth') { const clientId = process.env.OAUTH_CLIENT_ID; const clientSecret = process.env.OAUTH_CLIENT_SECRET; - if (!clientId || !clientSecret) { - console.error('OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET required for oauth mode'); + const expectedIssuer = process.env.OAUTH_EXPECTED_ISSUER; + if (!clientId || !clientSecret || !expectedIssuer) { + console.error('OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET and OAUTH_EXPECTED_ISSUER required for oauth mode'); process.exit(1); } - transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret); + transport = createUserConfiguredTransport(serverUrl, clientId, clientSecret, expectedIssuer); } else { console.error(`Unknown mode: ${mode}. Use 'host' or 'oauth'.`); process.exit(1); diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index a3fa18f054..7bd8930a49 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -25,7 +25,8 @@ import { OAuthTokensSchema, OpenIdProviderDiscoveryMetadataSchema, resourceUrlFromServerUrl, - stampErrorBrands + stampErrorBrands, + withoutIssuer } from '@modelcontextprotocol/core-internal'; import pkceChallenge from 'pkce-challenge'; @@ -129,12 +130,14 @@ export interface OAuthClientInformationContext { * "binding on first use" claim would be false and would fire on every call. */ export function discardIfIssuerMismatch( - stored: T | undefined, + stored: T | null | undefined, issuer: string, opts?: { canPersistStamp?: boolean } ): T | undefined { - if (stored === undefined) return undefined; - if (stored.issuer === undefined) { + // Nothing stored (`null` from a `JSON.parse(storage.getItem(...))`-style getter included). + if (!stored) return undefined; + // A stamp that is not a string (raw storage) counts as no stamp. + if (typeof stored.issuer !== 'string') { if (opts?.canPersistStamp !== false) { console.warn( `[mcp-sdk] SEP-2352: stored OAuth credential has no 'issuer' stamp (pre-upgrade storage or ` + @@ -142,7 +145,7 @@ export function discardIfIssuerMismatch( `ensure your provider round-trips the issuer field.` ); } - return stored; + return stored.issuer === undefined ? stored : { ...stored, issuer: undefined }; } return issuersMatch(stored.issuer, issuer) ? stored : undefined; } @@ -1001,7 +1004,8 @@ export interface AuthOptions { /** * Opt-out for the RFC 8414 §3.3 issuer-echo check during authorization * server discovery. Disabling it is **security-weakening** and intended only - * for authorization servers known to publish a mismatched `issuer`. + * for authorization servers known to publish a mismatched `issuer`. The unchecked + * `issuer` is also what `expectedIssuer` and stored `issuer` stamps are compared with. * * @default false */ @@ -2254,7 +2258,7 @@ export async function executeTokenRequest( const json: unknown = await response.json(); try { - return OAuthTokensSchema.parse(json); + return OAuthTokensSchema.parse(withoutIssuer(json)); } catch (parseError) { // Some OAuth servers (e.g., GitHub) return error responses with HTTP 200 status. // Check for error field only if token parsing failed. @@ -2441,6 +2445,18 @@ export async function fetchToken( }); } + // SEP-2352: nothing is sent to an authorization server other than the one the client information is stamped for. + const issuer = metadata?.issuer ?? String(authorizationServerUrl); + const readClientInformation = async () => { + const rawClientInfo = await provider.clientInformation({ issuer }); + const checked = discardIfIssuerMismatch(rawClientInfo, issuer, { canPersistStamp: false }); + if (rawClientInfo && checked === undefined) { + throw new AuthorizationServerMismatchError(String(rawClientInfo.issuer), issuer); + } + return checked; + }; + let clientInformation = await readClientInformation(); + // Prefer scope from options, fallback to provider.clientMetadata.scope const effectiveScope = scope ?? provider.clientMetadata.scope; @@ -2462,12 +2478,13 @@ export async function fetchToken( tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl); } - const clientInformation = await provider.clientInformation({ issuer: metadata?.issuer ?? String(authorizationServerUrl) }); + // A provider may fill in its client information while the request is prepared. + clientInformation ??= await readClientInformation(); return executeTokenRequest(authorizationServerUrl, { metadata, tokenRequestParams, - clientInformation: clientInformation ?? undefined, + clientInformation, addClientAuthentication: provider.addClientAuthentication, resource, dpop: await provider.dpop?.(), @@ -2542,5 +2559,5 @@ export async function registerClient( throw new RegistrationRejectedError({ status: response.status, body: await response.text(), submittedMetadata }); } - return OAuthClientInformationFullSchema.parse(await response.json()); + return OAuthClientInformationFullSchema.parse(withoutIssuer(await response.json())); } diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index 98b2c57c32..df61820c5f 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -177,14 +177,18 @@ export class InsecureTokenEndpointError extends OAuthClientFlowError { * `requiredScope`, which appears in the error message). */ /** - * Thrown by `auth()` on the authorization-code callback leg when the - * authorization server resolved by discovery differs from the one recorded in - * `discoveryState()` at redirect time. The `authorization_code` and PKCE - * `code_verifier` are bound to the AS that minted the code (RFC 7636); sending - * them to a different AS's token endpoint is a credential-exfiltration vector. + * Thrown by `auth()` and `fetchToken()` when the authorization server in use differs + * from the one a credential is bound to and the flow cannot simply start over: * - * This is the only runtime check left in the SEP-2352 model — stored tokens and - * client credentials are protected structurally by the `issuer` stamp instead. + * - on the authorization-code callback leg, when it differs from the one recorded + * in `discoveryState()` at redirect time — the `authorization_code` and PKCE + * `code_verifier` are bound to the AS that minted the code (RFC 7636); + * - for client information whose `issuer` stamp names a different authorization + * server on a provider that cannot re-register (no `saveClientInformation()`, + * e.g. the bundled static-credential providers); `fetchToken()` throws for any provider. + * + * Stored tokens and re-registrable client information are protected structurally + * by the `issuer` stamp instead (they read back as absent). */ export class AuthorizationServerMismatchError extends OAuthClientFlowError { static { @@ -192,15 +196,14 @@ export class AuthorizationServerMismatchError extends OAuthClientFlowError { } constructor( - /** The issuer recorded in `discoveryState()` when the authorization redirect was issued. */ + /** The issuer the credential is bound to (recorded in `discoveryState()` at redirect time, or stamped on the client information). */ public readonly recordedIssuer: string, /** The issuer resolved by discovery on this call. */ public readonly currentIssuer: string ) { super( - `Authorization server changed between redirect and callback ` + - `(redirected to ${JSON.stringify(recordedIssuer)}, callback resolved ${JSON.stringify(currentIssuer)}); ` + - `refusing to send authorization_code/code_verifier to a different token endpoint` + `Authorization server mismatch: credentials are bound to ${JSON.stringify(recordedIssuer)} ` + + `but this call resolved ${JSON.stringify(currentIssuer)}; refusing to present them to a different authorization server` ); } } diff --git a/packages/client/src/client/authExtensions.examples.ts b/packages/client/src/client/authExtensions.examples.ts index 668cdd3504..35bee44d5e 100644 --- a/packages/client/src/client/authExtensions.examples.ts +++ b/packages/client/src/client/authExtensions.examples.ts @@ -33,7 +33,8 @@ function ClientCredentialsProvider_basicUsage(serverUrl: URL) { //#region ClientCredentialsProvider_basicUsage const provider = new ClientCredentialsProvider({ clientId: 'my-client', - clientSecret: 'my-secret' + clientSecret: 'my-secret', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(serverUrl, { @@ -51,7 +52,8 @@ function PrivateKeyJwtProvider_basicUsage(pemEncodedPrivateKey: string, serverUr const provider = new PrivateKeyJwtProvider({ clientId: 'my-client', privateKey: pemEncodedPrivateKey, - algorithm: 'RS256' + algorithm: 'RS256', + expectedIssuer: 'https://auth.example.com' }); const transport = new StreamableHTTPClientTransport(serverUrl, { diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index 073b42ee0c..e210786b7e 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -94,6 +94,23 @@ export function createPrivateKeyJwtAuth(options: { }; } +/** + * The `issuer` stamp for constructor-supplied client information. Omitting `expectedIssuer` is + * deprecated: nothing else says which authorization server such credentials belong to. + */ +function checkedExpectedIssuer(expectedIssuer: string | undefined): string | undefined { + if (expectedIssuer === undefined) { + console.warn( + '[mcp-sdk] Omitting `expectedIssuer` is deprecated. Without it, the MCP server decides which authorization ' + + "server receives this client's credentials; pass your authorization server's issuer URL as " + + '`expectedIssuer` so they are only sent there.' + ); + } else if (typeof expectedIssuer !== 'string' || !expectedIssuer) { + throw new Error("expectedIssuer must be the authorization server's issuer URL"); + } + return expectedIssuer; +} + /** * Options for creating a {@linkcode ClientCredentialsProvider}. */ @@ -121,9 +138,10 @@ export interface ClientCredentialsProviderOptions { /** * The authorization server's `issuer` identifier these credentials were registered with. * Stamped onto the stored client information so `auth()`'s SEP-2352 issuer check - * refuses to send the credential to any other authorization server. Hosts supplying - * static client credentials SHOULD set this; omitting it preserves the legacy - * (no-binding) behaviour for back-compat. + * refuses to send the credential to any other authorization server. + * + * Omitting it is deprecated: the credential then goes to whichever authorization server + * the MCP server advertises. */ expectedIssuer?: string; } @@ -138,7 +156,8 @@ export interface ClientCredentialsProviderOptions { * ```ts source="./authExtensions.examples.ts#ClientCredentialsProvider_basicUsage" * const provider = new ClientCredentialsProvider({ * clientId: 'my-client', - * clientSecret: 'my-secret' + * clientSecret: 'my-secret', + * expectedIssuer: 'https://auth.example.com' * }); * * const transport = new StreamableHTTPClientTransport(serverUrl, { @@ -151,11 +170,14 @@ export class ClientCredentialsProvider implements OAuthClientProvider { private _clientInfo: StoredOAuthClientInformation; private _clientMetadata: OAuthClientMetadata; + constructor(options: ClientCredentialsProviderOptions & { expectedIssuer: string }); + /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ + constructor(options: ClientCredentialsProviderOptions); constructor(options: ClientCredentialsProviderOptions) { this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: options.expectedIssuer + issuer: checkedExpectedIssuer(options.expectedIssuer) }; this._clientMetadata = { client_name: options.clientName ?? 'client-credentials-client', @@ -274,7 +296,8 @@ export interface PrivateKeyJwtProviderOptions { * const provider = new PrivateKeyJwtProvider({ * clientId: 'my-client', * privateKey: pemEncodedPrivateKey, - * algorithm: 'RS256' + * algorithm: 'RS256', + * expectedIssuer: 'https://auth.example.com' * }); * * const transport = new StreamableHTTPClientTransport(serverUrl, { @@ -288,10 +311,13 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { private _clientMetadata: OAuthClientMetadata; addClientAuthentication: AddClientAuthentication; + constructor(options: PrivateKeyJwtProviderOptions & { expectedIssuer: string }); + /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ + constructor(options: PrivateKeyJwtProviderOptions); constructor(options: PrivateKeyJwtProviderOptions) { this._clientInfo = { client_id: options.clientId, - issuer: options.expectedIssuer + issuer: checkedExpectedIssuer(options.expectedIssuer) }; this._clientMetadata = { client_name: options.clientName ?? 'private-key-jwt-client', @@ -400,10 +426,13 @@ export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { private _clientMetadata: OAuthClientMetadata; addClientAuthentication: AddClientAuthentication; + constructor(options: StaticPrivateKeyJwtProviderOptions & { expectedIssuer: string }); + /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ + constructor(options: StaticPrivateKeyJwtProviderOptions); constructor(options: StaticPrivateKeyJwtProviderOptions) { this._clientInfo = { client_id: options.clientId, - issuer: options.expectedIssuer + issuer: checkedExpectedIssuer(options.expectedIssuer) }; this._clientMetadata = { client_name: options.clientName ?? 'static-private-key-jwt-client', @@ -591,7 +620,8 @@ export interface CrossAppAccessProviderOptions { * return result.jwtAuthGrant; * }, * clientId: 'my-mcp-client', - * clientSecret: 'my-mcp-secret' + * clientSecret: 'my-mcp-secret', + * expectedIssuer: 'https://auth.example.com' * }); * * const transport = new StreamableHTTPClientTransport(serverUrl, { @@ -609,11 +639,14 @@ export class CrossAppAccessProvider implements OAuthClientProvider { private _resourceUrl?: string; private _scope?: string; + constructor(options: CrossAppAccessProviderOptions & { expectedIssuer: string }); + /** @deprecated Pass `expectedIssuer` so the credentials are only sent to that authorization server. */ + constructor(options: CrossAppAccessProviderOptions); constructor(options: CrossAppAccessProviderOptions) { this._clientInfo = { client_id: options.clientId, client_secret: options.clientSecret, - issuer: options.expectedIssuer + issuer: checkedExpectedIssuer(options.expectedIssuer) }; this._clientMetadata = { client_name: options.clientName ?? 'cross-app-access-client', diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index 49f010d626..a5491258ec 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -9,7 +9,12 @@ */ import type { FetchLike } from '@modelcontextprotocol/core-internal'; -import { IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal'; +import { + IdJagTokenExchangeResponseSchema, + OAuthErrorResponseSchema, + OAuthTokensSchema, + withoutIssuer +} from '@modelcontextprotocol/core-internal'; import type { ClientAuthMethod } from './auth'; import { applyClientAuthentication, assertSecureTokenEndpoint, discoverAuthorizationServerMetadata } from './auth'; @@ -298,7 +303,7 @@ export async function exchangeJwtAuthGrant(options: { const responseBody = await response.json(); // Validate response using core schema - const parseResult = OAuthTokensSchema.safeParse(responseBody); + const parseResult = OAuthTokensSchema.safeParse(withoutIssuer(responseBody)); if (!parseResult.success) { throw new Error(`Invalid token response: ${parseResult.error.message}`); } diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index e57ab4e91c..a87cbd7d92 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -6,7 +6,13 @@ import type { StoredOAuthClientInformation, StoredOAuthTokens } from '@modelcontextprotocol/core-internal'; -import { LATEST_PROTOCOL_VERSION, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/core-internal'; +import { + LATEST_PROTOCOL_VERSION, + OAuthClientInformationSchema, + OAuthError, + OAuthErrorCode, + OAuthTokensSchema +} from '@modelcontextprotocol/core-internal'; import type { Mock } from 'vitest'; import { expect, vi } from 'vitest'; @@ -25,6 +31,7 @@ import { discoverOAuthServerInfo, exchangeAuthorization, extractWWWAuthenticateParams, + fetchToken, InsecureTokenEndpointError, isHttpsUrl, isStrictScopeSuperset, @@ -5004,7 +5011,7 @@ describe('OAuth Authorization', () => { function createMigratingFetch() { let active = AS_ONE; const registerCalls: string[] = []; - const tokenCalls: Array<{ issuer: string; body: URLSearchParams }> = []; + const tokenCalls: Array<{ issuer: string; body: URLSearchParams; authorization: string | null }> = []; const fetchFn = async (url: string | URL, init?: RequestInit): Promise => { const u = new URL(String(url)); if (u.pathname.includes('/.well-known/oauth-protected-resource')) { @@ -5019,7 +5026,7 @@ describe('OAuth Authorization', () => { } if (u.pathname === '/token') { const body = new URLSearchParams(String(init?.body)); - tokenCalls.push({ issuer: u.origin, body }); + tokenCalls.push({ issuer: u.origin, body, authorization: new Headers(init?.headers).get('authorization') }); return Response.json({ access_token: 'at', token_type: 'Bearer' }); } return new Response(null, { status: 404 }); @@ -5310,8 +5317,8 @@ describe('OAuth Authorization', () => { it('ClientCredentialsProvider without expectedIssuer: no SEP-2352 warn on auth()', async () => { const srv = createMigratingFetch(); - const provider = new ClientCredentialsProvider({ clientId: 'static', clientSecret: 's' }); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const provider = new ClientCredentialsProvider({ clientId: 'static', clientSecret: 's' }); await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn }); @@ -5319,6 +5326,180 @@ describe('OAuth Authorization', () => { warn.mockRestore(); }); + it('fetchToken sends nothing to an authorization server other than the one the client information is bound to', async () => { + const srv = createMigratingFetch(); + const addClientAuthentication = vi.fn>(); + const prepareTokenRequest = vi.fn(() => new URLSearchParams({ grant_type: 'client_credentials' })); + const provider: OAuthClientProvider = { ...createBlobProvider(false), addClientAuthentication, prepareTokenRequest }; + provider.clientInformation = () => ({ client_id: 'cid', client_secret: 'bound-secret', issuer: AS_ONE }); + + const err = await fetchToken(provider, AS_TWO, { metadata: asMetadata(AS_TWO), fetchFn: srv.fetchFn }).then( + () => undefined, + e => e + ); + expect(err).toBeInstanceOf(AuthorizationServerMismatchError); + expect(err).toMatchObject({ recordedIssuer: AS_ONE, currentIssuer: AS_TWO }); + expect(srv.tokenCalls).toEqual([]); + expect(prepareTokenRequest).not.toHaveBeenCalled(); + expect(addClientAuthentication).not.toHaveBeenCalled(); + + // A matching stamp is used as before. + provider.addClientAuthentication = undefined; + await fetchToken(provider, AS_ONE, { metadata: asMetadata(AS_ONE), fetchFn: srv.fetchFn }); + expect(srv.tokenCalls.map(c => [c.issuer, c.authorization])).toEqual([[AS_ONE, `Basic ${btoa('cid:bound-secret')}`]]); + }); + + it('a provider that reads storage back through the SDK schemas keeps the binding', async () => { + const storage = new Map(); + const provider: OAuthClientProvider = { + ...createBlobProvider(false), + clientInformation: () => + storage.has('info') ? OAuthClientInformationSchema.parseAsync(JSON.parse(storage.get('info')!)) : undefined, + saveClientInformation: i => void storage.set('info', JSON.stringify(i)), + tokens: () => (storage.has('tokens') ? OAuthTokensSchema.parseAsync(JSON.parse(storage.get('tokens')!)) : undefined), + saveTokens: t => void storage.set('tokens', JSON.stringify(t)) + }; + storage.set('info', JSON.stringify({ client_id: 'cid', client_secret: 'secret-one', issuer: AS_ONE })); + storage.set('tokens', JSON.stringify({ access_token: 'at', token_type: 'Bearer', refresh_token: 'rt-one', issuer: AS_ONE })); + const srv = createMigratingFetch(); + srv.switchTo(AS_TWO); + + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).toBe('REDIRECT'); + expect(srv.tokenCalls).toEqual([]); + expect(OAuthTokensSchema.parse({ access_token: 'at', token_type: 'Bearer', issuer: null }).issuer).toBeUndefined(); + }); + + it('a null clientInformation() counts as no client information', async () => { + // The `JSON.parse(storage.getItem(key))` idiom yields `null`, not `undefined`, for an empty slot. + const storage = new Map(); + const srv = createMigratingFetch(); + const provider: OAuthClientProvider = { + get redirectUrl() { + return undefined; + }, + get clientMetadata() { + return { client_name: 't', redirect_uris: [] }; + }, + clientInformation: () => JSON.parse(storage.get('info') ?? 'null'), + tokens: () => undefined, + saveTokens: () => {}, + redirectToAuthorization: () => {}, + saveCodeVerifier: () => {}, + codeVerifier: () => 'v', + prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }) + }; + + await fetchToken(provider, AS_ONE, { metadata: asMetadata(AS_ONE), fetchFn: srv.fetchFn }); + expect(srv.tokenCalls).toHaveLength(1); + expect(srv.tokenCalls[0]!.authorization).toBeNull(); + expect(srv.tokenCalls[0]!.body.get('client_id')).toBeNull(); + + await expect(auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).rejects.toThrow( + 'OAuth client information must be saveable for dynamic registration' + ); + }); + + /** Adds an `issuer` to every token and registration response of `fetchFn`. */ + const withResponseIssuer = + (fetchFn: ReturnType['fetchFn']) => + async (url: string | URL, init?: RequestInit): Promise => { + const response = await fetchFn(url, init); + if (!['/token', '/register'].includes(new URL(String(url)).pathname)) return response; + const body = { ...((await response.json()) as object), issuer: 'https://other.example.com' }; + return Response.json(body, { status: response.status }); + }; + + it('an issuer in a token or registration response is not returned', async () => { + const fetchFn = withResponseIssuer(createMigratingFetch().fetchFn); + const metadata = asMetadata(AS_ONE); + const clientInformation = { client_id: 'cid' }; + const provider: OAuthClientProvider = { + ...createBlobProvider(false), + clientInformation: () => clientInformation, + prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }) + }; + + const results = [ + await registerClient(AS_ONE, { metadata, clientMetadata: { redirect_uris: [] }, fetchFn }), + await exchangeAuthorization(AS_ONE, { + metadata, + clientInformation, + authorizationCode: 'code', + codeVerifier: 'v', + redirectUri: 'http://localhost:3000/callback', + fetchFn + }), + await refreshAuthorization(AS_ONE, { metadata, clientInformation, refreshToken: 'rt', fetchFn }), + await fetchToken(provider, AS_ONE, { metadata, fetchFn }) + ]; + for (const result of results) { + expect(result).not.toHaveProperty('issuer'); + } + }); + + it('auth() saves the resolved issuer when the responses carry another one', async () => { + const srv = createMigratingFetch(); + const provider = { + ...createBlobProvider(false), + redirectUrl: undefined, + prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }) + }; + + const fetchFn = withResponseIssuer(srv.fetchFn); + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn })).toBe('AUTHORIZED'); + expect(srv.registerCalls).toEqual([AS_ONE]); + expect(srv.tokenCalls).toHaveLength(1); + expect(provider.stored.info?.issuer).toBe(AS_ONE); + expect(provider.stored.tokens?.issuer).toBe(AS_ONE); + }); + + it('fetchToken uses client information that the provider fills in while preparing the request', async () => { + const srv = createMigratingFetch(); + let filled: StoredOAuthClientInformation | undefined; + const provider: OAuthClientProvider = { + ...createBlobProvider(false), + clientInformation: () => filled, + prepareTokenRequest: () => { + filled = { client_id: 'cid', client_secret: 'lazy-secret', issuer: AS_ONE }; + return new URLSearchParams({ grant_type: 'client_credentials' }); + } + }; + + await fetchToken(provider, AS_ONE, { metadata: asMetadata(AS_ONE), fetchFn: srv.fetchFn }); + expect(srv.tokenCalls.map(c => [c.issuer, c.authorization])).toEqual([[AS_ONE, `Basic ${btoa('cid:lazy-secret')}`]]); + + // The value read after preparing goes through the same check. + filled = undefined; + const err = await fetchToken(provider, AS_TWO, { metadata: asMetadata(AS_TWO), fetchFn: srv.fetchFn }).then( + () => undefined, + e => e + ); + expect(err).toBeInstanceOf(AuthorizationServerMismatchError); + expect(err).toMatchObject({ recordedIssuer: AS_ONE, currentIssuer: AS_TWO }); + expect(srv.tokenCalls).toHaveLength(1); + }); + + it.each([null, 42, { href: AS_ONE }])('a stored issuer of %j counts as no stamp', async issuer => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const raw = { client_id: 'cid', client_secret: 's', issuer } as unknown as StoredOAuthClientInformation; + expect(discardIfIssuerMismatch(raw, AS_ONE)).toEqual({ client_id: 'cid', client_secret: 's' }); + expect(warn).toHaveBeenCalledTimes(1); + + const srv = createMigratingFetch(); + const provider = { ...createBlobProvider(false), prepareTokenRequest: () => new URLSearchParams({ grant_type: 'x' }) }; + provider.stored.info = raw; + provider.stored.tokens = { access_token: 'at', token_type: 'Bearer', issuer } as unknown as StoredOAuthTokens; + + await fetchToken(provider, AS_ONE, { metadata: asMetadata(AS_ONE), fetchFn: srv.fetchFn }); + expect(srv.tokenCalls.map(c => c.authorization)).toEqual([`Basic ${btoa('cid:s')}`]); + + // auth() binds it on first use, as it does for a value with no stamp. + expect(await auth(provider, { serverUrl: 'https://api.example.com/mcp', fetchFn: srv.fetchFn })).toBe('REDIRECT'); + expect(provider.stored.info?.issuer).toBe(AS_ONE); + expect(provider.stored.tokens?.issuer).toBe(AS_ONE); + warn.mockRestore(); + }); + it('m2m expectedIssuer: ClientCredentialsProvider refuses to send the credential to a different AS', async () => { const srv = createMigratingFetch(); srv.switchTo(AS_TWO); diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index 3f55593fc7..cb4f36a1e1 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -1,6 +1,7 @@ import { createMockOAuthFetch } from '@modelcontextprotocol/test-helpers'; import { describe, expect, it, vi } from 'vitest'; +import type { OAuthClientProvider } from '../../src/client/auth'; import { auth } from '../../src/client/auth'; import { ClientCredentialsProvider, @@ -16,6 +17,7 @@ const AUTH_SERVER_URL = 'https://auth.example.com'; describe('auth-extensions providers (end-to-end with auth())', () => { it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => { const provider = new ClientCredentialsProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'my-client', clientSecret: 'my-secret', clientName: 'test-client' @@ -53,6 +55,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { it('sends scope in token request when ClientCredentialsProvider is configured with scope', async () => { const provider = new ClientCredentialsProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'my-client', clientSecret: 'my-secret', clientName: 'test-client', @@ -82,6 +85,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => { const provider = new PrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'HS256', @@ -125,6 +129,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { it('sends scope in token request when PrivateKeyJwtProvider is configured with scope', async () => { const provider = new PrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'HS256', @@ -157,6 +162,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => { const provider = new PrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'none', @@ -180,6 +186,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { const staticAssertion = 'header.payload.signature'; const provider = new StaticPrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'static-client', jwtBearerAssertion: staticAssertion, clientName: 'static-private-key-jwt-client' @@ -217,6 +224,7 @@ describe('auth-extensions providers (end-to-end with auth())', () => { const staticAssertion = 'header.payload.signature'; const provider = new StaticPrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'static-client', jwtBearerAssertion: staticAssertion, clientName: 'static-private-key-jwt-client', @@ -247,6 +255,35 @@ describe('auth-extensions providers (end-to-end with auth())', () => { }); }); +describe('expectedIssuer', () => { + const providers: Array<[string, (options: { expectedIssuer?: string }) => OAuthClientProvider]> = [ + ['ClientCredentialsProvider', o => new ClientCredentialsProvider({ clientId: 'c', clientSecret: 's', ...o })], + ['PrivateKeyJwtProvider', o => new PrivateKeyJwtProvider({ clientId: 'c', privateKey: 'k'.repeat(64), algorithm: 'HS256', ...o })], + ['StaticPrivateKeyJwtProvider', o => new StaticPrivateKeyJwtProvider({ clientId: 'c', jwtBearerAssertion: 'a', ...o })], + ['CrossAppAccessProvider', o => new CrossAppAccessProvider({ assertion: async () => 'g', clientId: 'c', clientSecret: 's', ...o })] + ]; + + it.each(providers)('%s logs one deprecation message at construction when it is omitted', async (_name, create) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetchFn = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL }); + + create({ expectedIssuer: AUTH_SERVER_URL }); + expect(warn).not.toHaveBeenCalled(); + + const provider = create({}); + expect(await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn })).toBe('AUTHORIZED'); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Omitting `expectedIssuer` is deprecated')); + warn.mockRestore(); + }); + + it.each([null, '', 42, new URL(AUTH_SERVER_URL)])('rejects %j at construction', value => { + expect(() => new ClientCredentialsProvider({ clientId: 'c', clientSecret: 's', expectedIssuer: value as string })).toThrow( + 'expectedIssuer must be' + ); + }); +}); + describe('createPrivateKeyJwtAuth', () => { const baseOptions = { issuer: 'client-id', @@ -450,6 +487,7 @@ describe('createPrivateKeyJwtAuth', () => { it('passes custom claims through PrivateKeyJwtProvider', async () => { const provider = new PrivateKeyJwtProvider({ + expectedIssuer: AUTH_SERVER_URL, clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'HS256', @@ -487,6 +525,7 @@ describe('CrossAppAccessProvider', () => { expect(ctx.fetchFn).toBeDefined(); return 'jwt-authorization-grant-token'; }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'my-mcp-client', clientSecret: 'my-mcp-secret', clientName: 'xaa-test-client' @@ -535,6 +574,7 @@ describe('CrossAppAccessProvider', () => { capturedScope = ctx.scope; return 'jwt-grant'; }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -570,6 +610,7 @@ describe('CrossAppAccessProvider', () => { capturedFetchFn = ctx.fetchFn; return 'jwt-grant'; }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret', fetchFn: customFetch @@ -587,6 +628,7 @@ describe('CrossAppAccessProvider', () => { it('throws error when authorization server URL is not available', async () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -600,6 +642,7 @@ describe('CrossAppAccessProvider', () => { it('throws error when resource URL is not available', async () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -615,6 +658,7 @@ describe('CrossAppAccessProvider', () => { it('stores and retrieves authorization server URL', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -628,6 +672,7 @@ describe('CrossAppAccessProvider', () => { it('stores and retrieves resource URL', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -641,6 +686,7 @@ describe('CrossAppAccessProvider', () => { it('has correct client metadata', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret', clientName: 'custom-xaa-client' @@ -656,6 +702,7 @@ describe('CrossAppAccessProvider', () => { it('uses default client name when not provided', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -666,6 +713,7 @@ describe('CrossAppAccessProvider', () => { it('returns undefined for redirectUrl (non-interactive flow)', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -676,6 +724,7 @@ describe('CrossAppAccessProvider', () => { it('throws error for redirectToAuthorization (not used in jwt-bearer)', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -686,6 +735,7 @@ describe('CrossAppAccessProvider', () => { it('throws error for codeVerifier (not used in jwt-bearer)', () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -698,6 +748,7 @@ describe('CrossAppAccessProvider', () => { assertion: async () => { throw new Error('Failed to get ID token from IdP'); }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -722,6 +773,7 @@ describe('CrossAppAccessProvider', () => { setTimeout(() => resolve('async-jwt-grant'), 10); }); }, + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); @@ -746,6 +798,7 @@ describe('CrossAppAccessProvider', () => { it('includes scope in token request params when provided', async () => { const provider = new CrossAppAccessProvider({ assertion: async () => 'jwt-grant', + expectedIssuer: AUTH_SERVER_URL, clientId: 'client', clientSecret: 'secret' }); diff --git a/packages/client/test/client/crossAppAccess.test.ts b/packages/client/test/client/crossAppAccess.test.ts index b81d3527c8..fd4752a9c5 100644 --- a/packages/client/test/client/crossAppAccess.test.ts +++ b/packages/client/test/client/crossAppAccess.test.ts @@ -458,6 +458,24 @@ describe('crossAppAccess', () => { ).rejects.toThrow('JWT grant exchange failed: invalid_grant - JWT signature verification failed'); }); + it('does not return an issuer from the token response', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ access_token: 'at', token_type: 'Bearer', issuer: 'https://other.example.com' }) + } as Response); + + const tokens = await exchangeJwtAuthGrant({ + tokenEndpoint: 'https://auth.chat.example/token', + jwtAuthGrant: 'jwt', + clientId: 'client', + clientSecret: 'secret', + fetchFn: mockFetch + }); + + expect(tokens).toEqual({ access_token: 'at', token_type: 'Bearer' }); + expect(tokens).not.toHaveProperty('issuer'); + }); + it('validates token response with schema', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/packages/core-internal/src/shared/authUtils.ts b/packages/core-internal/src/shared/authUtils.ts index 3083e425b9..a73575eb73 100644 --- a/packages/core-internal/src/shared/authUtils.ts +++ b/packages/core-internal/src/shared/authUtils.ts @@ -14,6 +14,14 @@ export function resourceUrlFromServerUrl(url: URL | string): URL { return resourceURL; } +/** Returns an authorization-server response body without `issuer`, which only the client sets, when it stores a value (SEP-2352). */ +export function withoutIssuer(body: unknown): unknown { + if (typeof body !== 'object' || body === null || Array.isArray(body)) return body; + const copy: Record = { ...body }; + delete copy.issuer; + return copy; +} + /** * Checks if a requested resource URL matches a configured resource URL. * A requested resource matches if it has the same scheme, domain, port, diff --git a/packages/core-internal/test/shared/authUtils.test.ts b/packages/core-internal/test/shared/authUtils.test.ts index 312d1809ef..85a6d266b6 100644 --- a/packages/core-internal/test/shared/authUtils.test.ts +++ b/packages/core-internal/test/shared/authUtils.test.ts @@ -1,4 +1,4 @@ -import { checkResourceAllowed, resourceUrlFromServerUrl } from '../../src/shared/authUtils'; +import { checkResourceAllowed, resourceUrlFromServerUrl, withoutIssuer } from '../../src/shared/authUtils'; describe('auth-utils', () => { describe('resourceUrlFromServerUrl', () => { @@ -87,4 +87,25 @@ describe('auth-utils', () => { ).toBe(false); }); }); + + describe('withoutIssuer', () => { + it('should drop issuer and keep everything else', () => { + const body = { access_token: 'at', token_type: 'Bearer', issuer: 'https://as.example.com', extra: 1 }; + expect(withoutIssuer(body)).toEqual({ access_token: 'at', token_type: 'Bearer', extra: 1 }); + expect(withoutIssuer(body)).not.toHaveProperty('issuer'); + expect(body.issuer).toBe('https://as.example.com'); + }); + + it('should drop an issuer that is not an own property', () => { + expect((withoutIssuer(Object.create({ issuer: 'https://as.example.com' })) as { issuer?: unknown }).issuer).toBeUndefined(); + }); + + it('should return anything that is not a plain object unchanged', () => { + const list = [{ issuer: 'https://as.example.com' }]; + expect(withoutIssuer(list)).toBe(list); + expect(withoutIssuer('issuer')).toBe('issuer'); + expect(withoutIssuer(null)).toBeNull(); + expect(withoutIssuer(undefined)).toBeUndefined(); + }); + }); }); diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts index cf98edd716..d7f7f8858e 100644 --- a/packages/core/src/auth.ts +++ b/packages/core/src/auth.ts @@ -144,7 +144,13 @@ export const OAuthTokensSchema = z token_type: z.string(), expires_in: z.coerce.number().optional(), scope: z.string().optional(), - refresh_token: z.string().optional() + refresh_token: z.string().optional(), + /** + * Not part of the wire format: the authorization server this value was obtained from, added by + * the client's `auth()` before it is stored and compared when it is read back (SEP-2352). + */ + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod .catch(), not a Promise chain + issuer: z.string().optional().catch(undefined) }) .strip(); @@ -223,7 +229,13 @@ export const OAuthClientInformationSchema = z client_id: z.string(), client_secret: z.string().optional(), client_id_issued_at: z.number().optional(), - client_secret_expires_at: z.number().optional() + client_secret_expires_at: z.number().optional(), + /** + * Not part of the wire format: the authorization server this value was obtained from, added by + * the client's `auth()` before it is stored and compared when it is read back (SEP-2352). + */ + // eslint-disable-next-line unicorn/prefer-top-level-await -- Zod .catch(), not a Promise chain + issuer: z.string().optional().catch(undefined) }) .strip(); @@ -267,20 +279,18 @@ export type OAuthClientInformationMixed = OAuthClientInformation | OAuthClientIn * {@linkcode OAuthTokens} as persisted by an `OAuthClientProvider`. Adds an * SDK-stamped authorization-server `issuer` identifier so stored tokens are * bound to the AS that issued them. The `issuer` field is **not** part of the - * RFC 6749 wire response and is intentionally absent from the wire-response - * schema; the client SDK writes it before calling `saveTokens`. + * RFC 6749 wire response; the client SDK writes it before calling `saveTokens`. */ -export type StoredOAuthTokens = OAuthTokens & { issuer?: string }; +export type StoredOAuthTokens = OAuthTokens & { issuer?: string | undefined }; /** * {@linkcode OAuthClientInformationMixed} as persisted by an * `OAuthClientProvider`. Adds an SDK-stamped authorization-server `issuer` * identifier so stored client credentials are bound to the AS that issued them. - * The `issuer` field is **not** part of the RFC 7591 wire response and is - * intentionally absent from the wire-response schema; the client SDK writes it - * before calling `saveClientInformation`. + * The `issuer` field is **not** part of the RFC 7591 wire response; the client SDK + * writes it before calling `saveClientInformation`. */ -export type StoredOAuthClientInformation = OAuthClientInformationMixed & { issuer?: string }; +export type StoredOAuthClientInformation = OAuthClientInformationMixed & { issuer?: string | undefined }; export type OAuthClientRegistrationError = z.infer; export type OAuthTokenRevocationRequest = z.infer; diff --git a/packages/server-legacy/src/auth/providers/proxyProvider.ts b/packages/server-legacy/src/auth/providers/proxyProvider.ts index 347589198a..692dd29121 100644 --- a/packages/server-legacy/src/auth/providers/proxyProvider.ts +++ b/packages/server-legacy/src/auth/providers/proxyProvider.ts @@ -1,5 +1,5 @@ import type { FetchLike, OAuthClientInformationFull, OAuthTokenRevocationRequest, OAuthTokens } from '@modelcontextprotocol/core-internal'; -import { OAuthClientInformationFullSchema, OAuthTokensSchema } from '@modelcontextprotocol/core-internal'; +import { OAuthClientInformationFullSchema, OAuthTokensSchema, withoutIssuer } from '@modelcontextprotocol/core-internal'; import type { Response } from 'express'; import type { OAuthRegisteredClientsStore } from '../clients'; @@ -119,7 +119,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthClientInformationFullSchema.parse(data); + return OAuthClientInformationFullSchema.parse(withoutIssuer(data)); } }) }; @@ -194,7 +194,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthTokensSchema.parse(data); + return OAuthTokensSchema.parse(withoutIssuer(data)); } async exchangeRefreshToken( @@ -235,7 +235,7 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { } const data = await response.json(); - return OAuthTokensSchema.parse(data); + return OAuthTokensSchema.parse(withoutIssuer(data)); } async verifyAccessToken(token: string): Promise { diff --git a/packages/server-legacy/test/auth/providers/proxyProvider.test.ts b/packages/server-legacy/test/auth/providers/proxyProvider.test.ts index 7915435eac..014caf8aa3 100644 --- a/packages/server-legacy/test/auth/providers/proxyProvider.test.ts +++ b/packages/server-legacy/test/auth/providers/proxyProvider.test.ts @@ -233,6 +233,23 @@ describe('Proxy OAuth Server Provider', () => { ); expect(tokens).toEqual(mockTokenResponse); }); + + it('does not forward an issuer from the upstream token response', async () => { + (global.fetch as Mock).mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ...mockTokenResponse, issuer: 'https://upstream.example.com' }) + }) + ); + + const exchanged = await provider.exchangeAuthorizationCode(validClient, 'test-code', 'test-verifier'); + const refreshed = await provider.exchangeRefreshToken(validClient, 'test-refresh-token'); + + expect(exchanged).toEqual(mockTokenResponse); + expect(exchanged).not.toHaveProperty('issuer'); + expect(refreshed).toEqual(mockTokenResponse); + expect(refreshed).not.toHaveProperty('issuer'); + }); }); describe('client registration', () => { @@ -264,6 +281,25 @@ describe('Proxy OAuth Server Provider', () => { expect(result).toEqual(newClient); }); + it('does not forward an issuer from the upstream registration response', async () => { + const newClient: OAuthClientInformationFull = { + client_id: 'new-client', + redirect_uris: ['https://new-client.com/callback'] + }; + + (global.fetch as Mock).mockImplementation(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ ...newClient, issuer: 'https://upstream.example.com' }) + }) + ); + + const result = await provider.clientsStore.registerClient!(newClient); + + expect(result).toEqual(newClient); + expect(result).not.toHaveProperty('issuer'); + }); + it('handles registration failure', async () => { mockFailedResponse(); const newClient: OAuthClientInformationFull = { diff --git a/test/e2e/scenarios/client-auth.test.ts b/test/e2e/scenarios/client-auth.test.ts index 6441abd593..556cd29ef8 100644 --- a/test/e2e/scenarios/client-auth.test.ts +++ b/test/e2e/scenarios/client-auth.test.ts @@ -1038,7 +1038,7 @@ verifies('client-auth:client-credentials', async (_args: TestArgs) => { return baseFetch(url, init); }; - const provider = new ClientCredentialsProvider({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }); + const provider = new ClientCredentialsProvider({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, expectedIssuer: ISSUER }); const client = new Client({ name: 'c', version: '0' }); const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); @@ -1276,7 +1276,12 @@ verifies('client-auth:private-key-jwt', async (_args: TestArgs) => { const privateKeyPem = keyPair.privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); const publicKeyPem = keyPair.publicKey.export({ type: 'spki', format: 'pem' }).toString(); - const provider = new PrivateKeyJwtProvider({ clientId: CLIENT_ID, privateKey: privateKeyPem, algorithm: 'RS256' }); + const provider = new PrivateKeyJwtProvider({ + clientId: CLIENT_ID, + privateKey: privateKeyPem, + algorithm: 'RS256', + expectedIssuer: ISSUER + }); const client = new Client({ name: 'c', version: '0' }); const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch }); @@ -1722,7 +1727,8 @@ verifies('client-auth:private-key-jwt:static-assertion', async (_args: TestArgs) const provider = new StaticPrivateKeyJwtProvider({ clientId: CLIENT_ID, - jwtBearerAssertion: preBuiltJwt + jwtBearerAssertion: preBuiltJwt, + expectedIssuer: ISSUER }); const client = new Client({ name: 'c', version: '0' }); From c55efa62fc4218592418ffbd313d1a906286f1d3 Mon Sep 17 00:00:00 2001 From: Sushant Date: Tue, 29 Sep 2026 00:20:06 +0530 Subject: [PATCH 13/44] fix(server): close a listen stream that has honored nothing (#2651) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../listen-close-when-nothing-honored.md | 5 ++ packages/server/src/server/listenRouter.ts | 6 +++ .../server/createMcpHandlerListen.test.ts | 54 +++++++++++++++++++ test/e2e/scenarios/subscriptions.test.ts | 2 +- 4 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .changeset/listen-close-when-nothing-honored.md diff --git a/.changeset/listen-close-when-nothing-honored.md b/.changeset/listen-close-when-nothing-honored.md new file mode 100644 index 0000000000..74e61fdaae --- /dev/null +++ b/.changeset/listen-close-when-nothing-honored.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`createMcpHandler` now ends a `subscriptions/listen` stream right after the acknowledgement when it honored none of the requested notification types, instead of holding the stream open with nothing to deliver. The client receives the acknowledgement and then the `resultType: "complete"` result. Streams that honor at least one type are unchanged. diff --git a/packages/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts index 40c4a38cf2..32ae8f26df 100644 --- a/packages/server/src/server/listenRouter.ts +++ b/packages/server/src/server/listenRouter.ts @@ -213,6 +213,12 @@ export function createListenRouter(options: ListenRouterOptions): ListenRouter { ); writeNotification(ack.method, ack.params); + // Nothing honored means nothing can ever be delivered: complete the subscription instead of holding the stream. + if (Object.keys(honored).length === 0) { + teardown(true); + return; + } + // Only after the ack frame is enqueued does delivery activate. unsubscribe = bus.subscribe(event => { if (closed || !listenFilterAccepts(honored, event)) return; diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index fe17aa7536..392887f0b3 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -264,6 +264,60 @@ describe('createMcpHandler — subscriptions/listen', () => { }); }); + it('acks and closes when capabilities honor nothing, instead of holding the stream', async () => { + // A server declaring no listChanged and no resources.subscribe honors + // nothing, so the stream can never carry a notification: every + // listenFilterAccepts({}, event) is false. It should end, not idle. + const bareFactory = () => new McpServer({ name: 'listen-test-server', version: '1.0.0' }, { capabilities: {} }); + const handler = createMcpHandler(bareFactory, { keepAliveMs: 0 }); + const response = await handler.fetch( + listenRequest(7, { toolsListChanged: true, resourcesListChanged: true, resourceSubscriptions: ['file:///a'] }) + ); + + // Reading the body to its end only resolves when the server ends the stream itself. + const body = await response.text(); + const messages = body + .split('\n\n') + .filter(frame => frame.length > 0) + .map(frame => JSON.parse(frame.slice(frame.indexOf('data: ') + 'data: '.length)) as unknown); + + expect(messages).toEqual([ + { + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { notifications: {}, _meta: { [SUBSCRIPTION_ID_META_KEY]: 7 } } + }, + { + jsonrpc: '2.0', + id: 7, + result: { + resultType: 'complete', + _meta: { + [SUBSCRIPTION_ID_META_KEY]: 7, + 'io.modelcontextprotocol/serverInfo': { name: 'listen-test-server', version: '1.0.0' } + } + } + } + ]); + }); + + it('still holds the stream when at least one type is honored', async () => { + // The narrowing must not close a stream that can still deliver: tools + // is honored here, resources is not. + const partialFactory = () => + new McpServer({ name: 'listen-test-server', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); + const handler = createMcpHandler(partialFactory, { keepAliveMs: 0 }); + const response = await handler.fetch(listenRequest(8, { toolsListChanged: true, resourcesListChanged: true })); + + const [ack] = await readMessages(response, 1); + expect(ack).toEqual({ + jsonrpc: '2.0', + method: 'notifications/subscriptions/acknowledged', + params: { notifications: { toolsListChanged: true }, _meta: { [SUBSCRIPTION_ID_META_KEY]: 8 } } + }); + await handler.close(); + }); + it('legacy-classified listen never reaches the entry listen router (no ack delivered)', async () => { const handler = createMcpHandler(trivialFactory(), { keepAliveMs: 0 }); // No envelope claim → classified legacy → dispatched through the diff --git a/test/e2e/scenarios/subscriptions.test.ts b/test/e2e/scenarios/subscriptions.test.ts index ce644fa430..ef3a42e444 100644 --- a/test/e2e/scenarios/subscriptions.test.ts +++ b/test/e2e/scenarios/subscriptions.test.ts @@ -276,7 +276,7 @@ verifies('subscriptions:listen:capacity-guard', async () => { jsonrpc: '2.0', id, method: 'subscriptions/listen', - params: { _meta: modernEnvelopeMeta(), notifications: {} } + params: { _meta: modernEnvelopeMeta(), notifications: { toolsListChanged: true } } }) }) ); From dd22ba25ddbe7cd8822e1fe9c738a974996a28f6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:05:29 +0100 Subject: [PATCH 14/44] Version Packages (#2849) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/await-notification-send.md | 6 ---- .changeset/client-cjs-types-inline-jose.md | 5 --- .changeset/codemod-preserve-file-header.md | 5 --- .../correct-token-endpoint-tls-citation.md | 5 --- .changeset/curvy-rivers-restore.md | 5 --- .changeset/dcr-deprecation-citation.md | 5 --- .../expected-issuer-deprecate-omission.md | 10 ------ .changeset/input-required-meta-passthrough.md | 5 --- .changeset/keep-next-cursor-on-early-stop.md | 5 --- .../listen-close-when-nothing-honored.md | 5 --- .../listen-opening-unhandled-rejection.md | 5 --- .changeset/loopback-localhost-subdomains.md | 5 --- packages/client/CHANGELOG.md | 31 +++++++++++++++++++ packages/client/package.json | 2 +- packages/codemod/CHANGELOG.md | 6 ++++ packages/codemod/package.json | 2 +- packages/core-internal/CHANGELOG.md | 7 +++++ packages/core-internal/package.json | 2 +- packages/core/CHANGELOG.md | 10 ++++++ packages/core/package.json | 2 +- packages/server-legacy/CHANGELOG.md | 7 +++++ packages/server-legacy/package.json | 2 +- packages/server/CHANGELOG.md | 13 ++++++++ packages/server/package.json | 2 +- 24 files changed, 80 insertions(+), 72 deletions(-) delete mode 100644 .changeset/await-notification-send.md delete mode 100644 .changeset/client-cjs-types-inline-jose.md delete mode 100644 .changeset/codemod-preserve-file-header.md delete mode 100644 .changeset/correct-token-endpoint-tls-citation.md delete mode 100644 .changeset/curvy-rivers-restore.md delete mode 100644 .changeset/dcr-deprecation-citation.md delete mode 100644 .changeset/expected-issuer-deprecate-omission.md delete mode 100644 .changeset/input-required-meta-passthrough.md delete mode 100644 .changeset/keep-next-cursor-on-early-stop.md delete mode 100644 .changeset/listen-close-when-nothing-honored.md delete mode 100644 .changeset/listen-opening-unhandled-rejection.md delete mode 100644 .changeset/loopback-localhost-subdomains.md diff --git a/.changeset/await-notification-send.md b/.changeset/await-notification-send.md deleted file mode 100644 index 2df131c33c..0000000000 --- a/.changeset/await-notification-send.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@modelcontextprotocol/client': patch -'@modelcontextprotocol/server': patch ---- - -Sending a notification on a closed connection no longer produces a briefly unhandled promise rejection (seen as `unhandledrejection` on Cloudflare Workers) in addition to the returned rejection. diff --git a/.changeset/client-cjs-types-inline-jose.md b/.changeset/client-cjs-types-inline-jose.md deleted file mode 100644 index 5c2113c5ca..0000000000 --- a/.changeset/client-cjs-types-inline-jose.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Fix a type-check failure for CommonJS TypeScript projects introduced in 2.1.0: `dist/index.d.cts` imported types from `jose`, which is ESM-only, so `tsc` with `module: node16`/`node18` and `skipLibCheck: false` failed with TS1479. The two `jose` types used by the DPoP API (`CryptoKey`, `JWK`) are now inlined into the declaration files. No runtime change. diff --git a/.changeset/codemod-preserve-file-header.md b/.changeset/codemod-preserve-file-header.md deleted file mode 100644 index b686d36080..0000000000 --- a/.changeset/codemod-preserve-file-header.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/codemod': patch ---- - -The `v1-to-v2` codemod now writes rewritten imports where the first v1 import stood, not at the top of the file, so a license header, `// @ts-nocheck`, `/// ` or a `'use client'` / `'use server'` / `'use strict'` directive above it stays in place. Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is `ErrorCode` or `StreamableHTTPError`), the new import can still land above or inside the header, and a `/** */` header can be removed. Files already migrated with codemod 2.1.0 or earlier are not repaired; check the top of those files. diff --git a/.changeset/correct-token-endpoint-tls-citation.md b/.changeset/correct-token-endpoint-tls-citation.md deleted file mode 100644 index 50dd1d3220..0000000000 --- a/.changeset/correct-token-endpoint-tls-citation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Correct the JSDoc for insecure OAuth token endpoints. The TLS requirement comes from the MCP authorization specification's OAuth 2.1 communication-security rules, not SEP-2207, which covers OIDC-flavored refresh-token guidance. Documentation only; no runtime behavior change. diff --git a/.changeset/curvy-rivers-restore.md b/.changeset/curvy-rivers-restore.md deleted file mode 100644 index 1c15f14631..0000000000 --- a/.changeset/curvy-rivers-restore.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server': patch ---- - -Fix a stack overflow in `createMcpHandler` when the factory returns the same server instance for more than one request. Returning a fresh instance per request is still required. diff --git a/.changeset/dcr-deprecation-citation.md b/.changeset/dcr-deprecation-citation.md deleted file mode 100644 index 14cb84d7e4..0000000000 --- a/.changeset/dcr-deprecation-citation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Correct the `registerClient` `@deprecated` notice: Dynamic Client Registration was deprecated by spec PR modelcontextprotocol#2858 (Client ID Metadata Documents), not SEP-2577 (which deprecates roots, sampling, and logging). The notice now also names the earliest possible removal date under the feature lifecycle policy (2027-07-28) and clarifies that the `client_id_metadata_document_supported` gating lives in the built-in `auth()` flow — `registerClient` called directly always sends the registration request. Documentation only; no runtime behavior change. diff --git a/.changeset/expected-issuer-deprecate-omission.md b/.changeset/expected-issuer-deprecate-omission.md deleted file mode 100644 index 8fa6d07a42..0000000000 --- a/.changeset/expected-issuer-deprecate-omission.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@modelcontextprotocol/client': minor -'@modelcontextprotocol/core': minor ---- - -Constructing `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider` or `CrossAppAccessProvider` without `expectedIssuer` is deprecated: the constructor logs one `console.warn` and that call signature is marked `@deprecated`. Behaviour is otherwise unchanged. Pass the `issuer` of the authorization server the credentials were registered with. - -`fetchToken()` throws `AuthorizationServerMismatchError`, before sending anything, when the provider's client information is bound to a different authorization server than the one it is called with. The `AuthorizationServerMismatchError` message no longer assumes the authorization-code callback; its fields are unchanged. - -`OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer` stamp, so a provider that reads storage back through them keeps it. `auth()` overwrites it on every save. diff --git a/.changeset/input-required-meta-passthrough.md b/.changeset/input-required-meta-passthrough.md deleted file mode 100644 index 6f689097e7..0000000000 --- a/.changeset/input-required-meta-passthrough.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Preserve `_meta` on `input_required` results. The 2026-07-28 decode seam rebuilt the payload from `inputRequests` and `requestState` only, so result-level metadata a server sent on an `input_required` result (including `io.modelcontextprotocol/serverInfo`) was dropped before an `allowInputRequired: true` caller could see it. `Result._meta` is a result-level field, so `input_required` carries it exactly like any other result. diff --git a/.changeset/keep-next-cursor-on-early-stop.md b/.changeset/keep-next-cursor-on-early-stop.md deleted file mode 100644 index a56a05d899..0000000000 --- a/.changeset/keep-next-cursor-on-early-stop.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -`listTools()`, `listPrompts()`, `listResources()` and `listResourceTemplates()` called without a cursor now follow `nextCursor` until the server stops sending one, instead of stopping silently with a short list when a cursor repeats; a page that has the same items and the same `nextCursor` as the page before it ends the walk and is not added twice, and `listMaxPages` still caps the walk. diff --git a/.changeset/listen-close-when-nothing-honored.md b/.changeset/listen-close-when-nothing-honored.md deleted file mode 100644 index 74e61fdaae..0000000000 --- a/.changeset/listen-close-when-nothing-honored.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server': patch ---- - -`createMcpHandler` now ends a `subscriptions/listen` stream right after the acknowledgement when it honored none of the requested notification types, instead of holding the stream open with nothing to deliver. The client receives the acknowledgement and then the `resultType: "complete"` result. Streams that honor at least one type are unchanged. diff --git a/.changeset/listen-opening-unhandled-rejection.md b/.changeset/listen-opening-unhandled-rejection.md deleted file mode 100644 index a951b4d51a..0000000000 --- a/.changeset/listen-opening-unhandled-rejection.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Fix `Client.listen()` rejections escaping as process-level unhandled rejections. The internal `opening` promise could reject (ack timeout, transport close, server cancel, caller abort) while `listen()` was still serially awaiting `transport.send(...)`, so no rejection handler was attached yet — the rejection surfaced as an `unhandledRejection` that caller-side handling cannot prevent, and a send that never settles (e.g. a stdio write parked on `'drain'`) left `listen()` suspended forever even though the ack timer had already fired. `listen()` now suspends on the `opening` state machine directly and routes send failures into it, so every termination path rejects the returned promise and nothing escapes. diff --git a/.changeset/loopback-localhost-subdomains.md b/.changeset/loopback-localhost-subdomains.md deleted file mode 100644 index 938ec481fd..0000000000 --- a/.changeset/loopback-localhost-subdomains.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Treat hostnames ending in `.localhost` as loopback for the SEP-2207 token-endpoint https guard (RFC 6761 §6.3), so host-based multi-tenant local OAuth works. The SDK does not resolve the name itself: `*.localhost` reaches the local machine only if the system resolver follows RFC 6761. diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 6c39696494..a4c620cc35 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,36 @@ # @modelcontextprotocol/client +## 2.2.0 + +### Minor Changes + +- [#2887](https://github.com/modelcontextprotocol/typescript-sdk/pull/2887) [`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd) Thanks [@maxisbey](https://github.com/maxisbey)! - Constructing `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider` or `CrossAppAccessProvider` without `expectedIssuer` is deprecated: the constructor logs one `console.warn` and that call signature is marked `@deprecated`. Behaviour is otherwise unchanged. Pass the `issuer` of the authorization server the credentials were registered with. + + `fetchToken()` throws `AuthorizationServerMismatchError`, before sending anything, when the provider's client information is bound to a different authorization server than the one it is called with. The `AuthorizationServerMismatchError` message no longer assumes the authorization-code callback; its fields are unchanged. + + `OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer` stamp, so a provider that reads storage back through them keeps it. `auth()` overwrites it on every save. + +### Patch Changes + +- [#2885](https://github.com/modelcontextprotocol/typescript-sdk/pull/2885) [`9dd722f`](https://github.com/modelcontextprotocol/typescript-sdk/commit/9dd722fd0b533b3cb44cd2834409f7ea4dd32b00) Thanks [@claude](https://github.com/apps/claude)! - Sending a notification on a closed connection no longer produces a briefly unhandled promise rejection (seen as `unhandledrejection` on Cloudflare Workers) in addition to the returned rejection. + +- [#2883](https://github.com/modelcontextprotocol/typescript-sdk/pull/2883) [`c0f7aec`](https://github.com/modelcontextprotocol/typescript-sdk/commit/c0f7aec2925a46b32dc2f2aad5bf636a36f02c78) Thanks [@claude](https://github.com/apps/claude)! - Fix a type-check failure for CommonJS TypeScript projects introduced in 2.1.0: `dist/index.d.cts` imported types from `jose`, which is ESM-only, so `tsc` with `module: node16`/`node18` and `skipLibCheck: false` failed with TS1479. The two `jose` types used by the DPoP API (`CryptoKey`, `JWK`) are now inlined into the declaration files. No runtime change. + +- [#2768](https://github.com/modelcontextprotocol/typescript-sdk/pull/2768) [`efebf5b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/efebf5b2ba2f07c6fa27ada824a0999b801fec0a) Thanks [@web-abin](https://github.com/web-abin)! - Correct the JSDoc for insecure OAuth token endpoints. The TLS requirement comes from the MCP authorization specification's OAuth 2.1 communication-security rules, not SEP-2207, which covers OIDC-flavored refresh-token guidance. Documentation only; no runtime behavior change. + +- [#2729](https://github.com/modelcontextprotocol/typescript-sdk/pull/2729) [`a4ae2f9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4ae2f98da3814a9290f2791cfcca8148dcec978) Thanks [@claude](https://github.com/apps/claude)! - Correct the `registerClient` `@deprecated` notice: Dynamic Client Registration was deprecated by spec PR modelcontextprotocol#2858 (Client ID Metadata Documents), not SEP-2577 (which deprecates roots, sampling, and logging). The notice now also names the earliest possible removal date under the feature lifecycle policy (2027-07-28) and clarifies that the `client_id_metadata_document_supported` gating lives in the built-in `auth()` flow — `registerClient` called directly always sends the registration request. Documentation only; no runtime behavior change. + +- [#2862](https://github.com/modelcontextprotocol/typescript-sdk/pull/2862) [`e780e13`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e780e13869ad419a864b669bd5fa9702cfb36b14) Thanks [@SyedTashfin](https://github.com/SyedTashfin)! - Preserve `_meta` on `input_required` results. The 2026-07-28 decode seam rebuilt the payload from `inputRequests` and `requestState` only, so result-level metadata a server sent on an `input_required` result (including `io.modelcontextprotocol/serverInfo`) was dropped before an `allowInputRequired: true` caller could see it. `Result._meta` is a result-level field, so `input_required` carries it exactly like any other result. + +- [#2886](https://github.com/modelcontextprotocol/typescript-sdk/pull/2886) [`ef39308`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ef39308e963496b75e50553f6265d99cacb4fbb9) Thanks [@claude](https://github.com/apps/claude)! - `listTools()`, `listPrompts()`, `listResources()` and `listResourceTemplates()` called without a cursor now follow `nextCursor` until the server stops sending one, instead of stopping silently with a short list when a cursor repeats; a page that has the same items and the same `nextCursor` as the page before it ends the walk and is not added twice, and `listMaxPages` still caps the walk. + +- [#2642](https://github.com/modelcontextprotocol/typescript-sdk/pull/2642) [`cfa09db`](https://github.com/modelcontextprotocol/typescript-sdk/commit/cfa09db614308eaf4fd575c89833da8878d4acb7) Thanks [@claude](https://github.com/apps/claude)! - Fix `Client.listen()` rejections escaping as process-level unhandled rejections. The internal `opening` promise could reject (ack timeout, transport close, server cancel, caller abort) while `listen()` was still serially awaiting `transport.send(...)`, so no rejection handler was attached yet — the rejection surfaced as an `unhandledRejection` that caller-side handling cannot prevent, and a send that never settles (e.g. a stdio write parked on `'drain'`) left `listen()` suspended forever even though the ack timer had already fired. `listen()` now suspends on the `opening` state machine directly and routes send failures into it, so every termination path rejects the returned promise and nothing escapes. + +- [#2597](https://github.com/modelcontextprotocol/typescript-sdk/pull/2597) [`7f7a94c`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7f7a94c22017e121a960e071bb50ec75e34450bd) Thanks [@arimu1](https://github.com/arimu1)! - Treat hostnames ending in `.localhost` as loopback for the SEP-2207 token-endpoint https guard (RFC 6761 §6.3), so host-based multi-tenant local OAuth works. The SDK does not resolve the name itself: `*.localhost` reaches the local machine only if the system resolver follows RFC 6761. + +- Updated dependencies [[`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd)]: + - @modelcontextprotocol/core@2.2.0 + ## 2.1.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 997e9bcfff..23c9a6dd82 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/client", - "version": "2.1.0", + "version": "2.2.0", "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 2a3d2558a4..f40b02ca33 100644 --- a/packages/codemod/CHANGELOG.md +++ b/packages/codemod/CHANGELOG.md @@ -1,5 +1,11 @@ # @modelcontextprotocol/codemod +## 2.2.0 + +### Patch Changes + +- [#2582](https://github.com/modelcontextprotocol/typescript-sdk/pull/2582) [`f091897`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f091897f4ba6c0519584382e31b68a6ca935f35b) Thanks [@axits-lab](https://github.com/axits-lab)! - The `v1-to-v2` codemod now writes rewritten imports where the first v1 import stood, not at the top of the file, so a license header, `// @ts-nocheck`, `/// ` or a `'use client'` / `'use server'` / `'use strict'` directive above it stays in place. Known gap: when a later step of the codemod replaces or removes the import (for example a file whose only SDK import is `ErrorCode` or `StreamableHTTPError`), the new import can still land above or inside the header, and a `/** */` header can be removed. Files already migrated with codemod 2.1.0 or earlier are not repaired; check the top of those files. + ## 2.1.0 ### Patch Changes diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 976e923f8a..030315eaeb 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/codemod", - "version": "2.1.0", + "version": "2.2.0", "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 7c96d30e13..fcf3a8c856 100644 --- a/packages/core-internal/CHANGELOG.md +++ b/packages/core-internal/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/core-internal +## 2.0.2 + +### Patch Changes + +- Updated dependencies [[`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd)]: + - @modelcontextprotocol/core@2.2.0 + ## 2.0.1 ### Patch Changes diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 63719b3c2b..1febcabe68 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.1", + "version": "2.0.2", "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 2f1543900b..669b0d07dd 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,15 @@ # @modelcontextprotocol/core +## 2.2.0 + +### Minor Changes + +- [#2887](https://github.com/modelcontextprotocol/typescript-sdk/pull/2887) [`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd) Thanks [@maxisbey](https://github.com/maxisbey)! - Constructing `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider` or `CrossAppAccessProvider` without `expectedIssuer` is deprecated: the constructor logs one `console.warn` and that call signature is marked `@deprecated`. Behaviour is otherwise unchanged. Pass the `issuer` of the authorization server the credentials were registered with. + + `fetchToken()` throws `AuthorizationServerMismatchError`, before sending anything, when the provider's client information is bound to a different authorization server than the one it is called with. The `AuthorizationServerMismatchError` message no longer assumes the authorization-code callback; its fields are unchanged. + + `OAuthTokensSchema` and `OAuthClientInformationSchema` accept the optional `issuer` stamp, so a provider that reads storage back through them keeps it. `auth()` overwrites it on every save. + ## 2.1.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index d11125e961..a321becd5a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/core", - "version": "2.1.0", + "version": "2.2.0", "description": "Model Context Protocol for TypeScript — public Zod schemas (spec + OAuth/OpenID)", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/server-legacy/CHANGELOG.md b/packages/server-legacy/CHANGELOG.md index 553cba100f..84aeffc0b0 100644 --- a/packages/server-legacy/CHANGELOG.md +++ b/packages/server-legacy/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/server-legacy +## 2.2.0 + +### Patch Changes + +- Updated dependencies [[`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd)]: + - @modelcontextprotocol/core@2.2.0 + ## 2.1.0 ### Patch Changes diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index 45b9fe65e1..f89b1a6dea 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.1.0", + "version": "2.2.0", "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 e82a8dac9c..8ea1932a19 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,18 @@ # @modelcontextprotocol/server +## 2.2.0 + +### Patch Changes + +- [#2885](https://github.com/modelcontextprotocol/typescript-sdk/pull/2885) [`9dd722f`](https://github.com/modelcontextprotocol/typescript-sdk/commit/9dd722fd0b533b3cb44cd2834409f7ea4dd32b00) Thanks [@claude](https://github.com/apps/claude)! - Sending a notification on a closed connection no longer produces a briefly unhandled promise rejection (seen as `unhandledrejection` on Cloudflare Workers) in addition to the returned rejection. + +- [#2778](https://github.com/modelcontextprotocol/typescript-sdk/pull/2778) [`e3fb9ed`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e3fb9edcdec70ed7c8463548ea9b90bbcb74c4ab) Thanks [@vjymisal0](https://github.com/vjymisal0)! - Fix a stack overflow in `createMcpHandler` when the factory returns the same server instance for more than one request. Returning a fresh instance per request is still required. + +- [#2651](https://github.com/modelcontextprotocol/typescript-sdk/pull/2651) [`c55efa6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/c55efa62fc4218592418ffbd313d1a906286f1d3) Thanks [@sushantkumar23](https://github.com/sushantkumar23)! - `createMcpHandler` now ends a `subscriptions/listen` stream right after the acknowledgement when it honored none of the requested notification types, instead of holding the stream open with nothing to deliver. The client receives the acknowledgement and then the `resultType: "complete"` result. Streams that honor at least one type are unchanged. + +- Updated dependencies [[`edd12e2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/edd12e282620ebf770d67316f19cf91d4112a1bd)]: + - @modelcontextprotocol/core@2.2.0 + ## 2.1.0 ### Minor Changes diff --git a/packages/server/package.json b/packages/server/package.json index 9483242b91..a7f0d2d68f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server", - "version": "2.1.0", + "version": "2.2.0", "description": "Model Context Protocol implementation for TypeScript - Server package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", From 4d94e7b1ccf769d94a7bbba7789f1ee6c7dfdd8c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:20:44 +0100 Subject: [PATCH 15/44] fix(server): convert tool schemas on demand, not at registration (#2889) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/lazy-tool-schema-conversion.md | 5 + docs/migration/upgrade-to-v2.md | 2 +- packages/server/src/server/mcp.ts | 69 +++---- .../server/lazyToolSchemaConversion.test.ts | 172 ++++++++++++++++++ .../lazyToolSchemaConversionRequests.test.ts | 141 ++++++++++++++ .../test/server/mcpParamValidation.test.ts | 7 +- 6 files changed, 348 insertions(+), 48 deletions(-) create mode 100644 .changeset/lazy-tool-schema-conversion.md create mode 100644 packages/server/test/server/lazyToolSchemaConversion.test.ts create mode 100644 packages/server/test/server/lazyToolSchemaConversionRequests.test.ts diff --git a/.changeset/lazy-tool-schema-conversion.md b/.changeset/lazy-tool-schema-conversion.md new file mode 100644 index 0000000000..61d75a3f1c --- /dev/null +++ b/.changeset/lazy-tool-schema-conversion.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`registerTool` no longer converts tool schemas up front, so a server built per request stops converting every tool on every request. The warning about an invalid `x-mcp-header` declaration now appears each time tools are listed, not when the tool is registered. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index b7bacd6adf..843bbd7638 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -672,7 +672,7 @@ codemod inverts the common nesting automatically and flags shapes it cannot rewr **Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). Check the **declared range** in your `package.json`, not just the installed version: a zod-3 range that satisfied the v1 peer installs and typechecks cleanly under v2 and only fails at -runtime — and quietly: registration swallows the conversion failure, the server starts +runtime — and quietly: registration does not convert the schema, the server starts and connects normally, and the first `tools/list` (so `client.listTools()`) answers with an error pointing at `fromJsonSchema()` while the process keeps running. (Only the deprecated unwrapped raw-shape form with zod-3 field values throws at registration, diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 70a5539bfb..01c63705d4 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -77,12 +77,7 @@ export class McpServer { } = {}; private _registeredTools: { [name: string]: RegisteredTool } = {}; private _registeredPrompts: { [name: string]: RegisteredPrompt } = {}; - /** - * Per-tool JSON-converted `inputSchema`, memoized so the SEP-2243 - * registration-time scan and the pre-dispatch validation step share one - * conversion instead of paying it twice per request under the - * per-request-factory `createMcpHandler` model. - */ + /** Per-tool JSON-converted `inputSchema`, filled on first use by `toolInputSchemaJson()`. */ private _toolInputSchemaJson: { [name: string]: Record } = {}; /** @@ -98,15 +93,7 @@ export class McpServer { if (tool === undefined || !tool.enabled) return undefined; if (Object.hasOwn(this._toolInputSchemaJson, name)) return this._toolInputSchemaJson[name]; if (tool.inputSchema === undefined) return EMPTY_OBJECT_JSON_SCHEMA; - // Lazy path: the memo slot is unset because `registerTool`'s eager - // conversion threw (and was swallowed per its "warn, never throw" - // contract) or `update({paramsSchema})`/rename invalidated it. The - // pre-dispatch SEP-2243 caller must not turn that into a 500 for a - // `tools/call` whose body-authoritative dispatch would otherwise - // succeed — return `undefined` so validation is skipped and the - // conversion failure stays where it always surfaced (`tools/list`). - // A successful re-derive is memoized so the per-request-factory - // `createMcpHandler` model does not re-convert on every call. + // A conversion failure returns `undefined` so it surfaces where it always has (`tools/list`). try { const json = standardSchemaToJsonSchema(tool.inputSchema, 'input'); this._toolInputSchemaJson[name] = json; @@ -238,7 +225,7 @@ export class McpServer { title: tool.title, description: tool.description, inputSchema: tool.inputSchema - ? (standardSchemaToJsonSchema(tool.inputSchema, 'input') as Tool['inputSchema']) + ? (convertListedInputSchema(name, tool.inputSchema) as Tool['inputSchema']) : EMPTY_OBJECT_JSON_SCHEMA, annotations: tool.annotations, icons: tool.icons, @@ -889,42 +876,21 @@ export class McpServer { // Validate tool name according to SEP specification validateAndWarnToolName(name); - // SEP-2243 registration-time declaration-validity check (additive: warn, - // never throw — clients enforce by exclusion, servers by header - // validation; a malformed declaration here should not block local - // development against a stdio client that ignores it). The conversion - // is memoized so the pre-dispatch validation step in `createMcpHandler` - // (and `toolInputSchemaJson()`) does not repeat it for the same tool. - // `standardSchemaToJsonSchema` can throw for schemas it cannot convert - // (e.g. a vendor without `~standard.jsonSchema`); the try/catch keeps - // the "warn, never throw" contract. - if (inputSchema !== undefined) { - try { - const json = standardSchemaToJsonSchema(inputSchema, 'input'); - this._toolInputSchemaJson[name] = json; - const scan = scanXMcpHeaderDeclarations(json); - if (!scan.valid) { - console.warn( - `[mcp-sdk] tool '${name}' carries an invalid x-mcp-header declaration and will be excluded by ` + - `conforming Streamable HTTP clients: ${scan.reason}` - ); - } - } catch { - // Conversion failure: leave the cache slot unset so the lazy - // path in `toolInputSchemaJson()` (and `tools/list`) surfaces - // the failure where it always has. - } - } - // Track current handler for executor regeneration let currentHandler = handler; + let outputSchemaJson: Record | undefined; const registeredTool: RegisteredTool = { title, description, inputSchema, outputSchema, - outputSchemaJson: convertOutputSchemaJson(outputSchema), + get outputSchemaJson() { + return (outputSchemaJson ??= convertOutputSchemaJson(registeredTool.outputSchema)); + }, + set outputSchemaJson(value) { + outputSchemaJson = value; + }, annotations, icons, execution, @@ -1353,7 +1319,7 @@ export type RegisteredTool = { outputSchema?: StandardSchemaWithJSON; /** * @hidden - * The converted JSON Schema of `outputSchema`, memoised at registration (and on + * The converted JSON Schema of `outputSchema`, memoised on first use (and on * `update({outputSchema})`) so the `tools/call` handler passes the SAME advertised schema * `tools/list` emits to the wire codec's `projectCallToolResult` — the SEP-2106 `{result:…}` * wrap predicate follows the schema's root, never the runtime value shape. `undefined` when @@ -1413,6 +1379,19 @@ const EMPTY_OBJECT_JSON_SCHEMA = { properties: {} }; +/** Converts a tool's `inputSchema` for `tools/list` and warns on an invalid SEP-2243 `x-mcp-header` declaration. */ +function convertListedInputSchema(name: string, inputSchema: StandardSchemaWithJSON): Record { + const json = standardSchemaToJsonSchema(inputSchema, 'input'); + const scan = scanXMcpHeaderDeclarations(json); + if (!scan.valid) { + console.warn( + `[mcp-sdk] tool '${name}' carries an invalid x-mcp-header declaration and will be excluded by ` + + `conforming Streamable HTTP clients: ${scan.reason}` + ); + } + return json; +} + /** * Convert a registered `outputSchema` to JSON Schema, memoised on {@link RegisteredTool.outputSchemaJson} * so `tools/call` passes the SAME advertised schema to the wire codec's `projectCallToolResult` that diff --git a/packages/server/test/server/lazyToolSchemaConversion.test.ts b/packages/server/test/server/lazyToolSchemaConversion.test.ts new file mode 100644 index 0000000000..073ce16f87 --- /dev/null +++ b/packages/server/test/server/lazyToolSchemaConversion.test.ts @@ -0,0 +1,172 @@ +/** + * `registerTool` converts tool schemas to JSON Schema on demand, not at + * registration: `tools/list` converts the schemas it emits, and the memoised + * `toolInputSchemaJson()` / `outputSchemaJson` slots fill on first use. A server + * built per request therefore no longer converts every tool on every request. + */ +import type { StandardSchemaWithJSON } from '@modelcontextprotocol/core-internal'; +import { scanXMcpHeaderDeclarations, standardSchemaToJsonSchema } from '@modelcontextprotocol/core-internal'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import { fromJsonSchema } from '../../src/fromJsonSchema'; +import { invoke } from '../../src/server/invoke'; +import { McpServer } from '../../src/server/mcp'; + +const LEGACY = { classification: { era: 'legacy' as const } }; + +type Counted = { schema: StandardSchemaWithJSON; calls: { input: number; output: number } }; + +/** Wraps a schema so every `~standard.jsonSchema` conversion is counted. */ +function counted(inner: StandardSchemaWithJSON): Counted { + const calls = { input: 0, output: 0 }; + const std = inner['~standard']; + const schema = { + '~standard': { + ...std, + jsonSchema: { + input: (options: Parameters[0]) => { + calls.input++; + return std.jsonSchema.input(options); + }, + output: (options: Parameters[0]) => { + calls.output++; + return std.jsonSchema.output(options); + } + } + } + } as unknown as StandardSchemaWithJSON; + return { schema, calls }; +} + +const listTools = async (server: McpServer): Promise<{ status: number; body: Record }> => { + const response = await invoke(server, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, LEGACY); + return { status: response.status, body: (await response.json()) as Record }; +}; + +const INVALID_HEADER_SCHEMA = fromJsonSchema({ + type: 'object', + properties: { a: { type: 'object', 'x-mcp-header': 'Data' } as Record } +}); + +describe('lazy tool schema conversion', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('registerTool converts neither inputSchema nor outputSchema; tools/list converts both', async () => { + const input = counted(z.object({ a: z.string() })); + const output = counted(z.object({ b: z.number() })); + const server = new McpServer({ name: 'lazy', version: '0' }); + + server.registerTool('t', { inputSchema: input.schema, outputSchema: output.schema }, async () => ({ content: [] })); + expect(input.calls).toEqual({ input: 0, output: 0 }); + expect(output.calls).toEqual({ input: 0, output: 0 }); + + const { body } = await listTools(server); + expect((body.result as { tools: unknown[] }).tools).toHaveLength(1); + expect(input.calls).toEqual({ input: 1, output: 0 }); + expect(output.calls).toEqual({ input: 0, output: 1 }); + }); + + it('toolInputSchemaJson() converts on first use and memoises', () => { + const input = counted(z.object({ a: z.string() })); + const server = new McpServer({ name: 'lazy', version: '0' }); + server.registerTool('t', { inputSchema: input.schema }, async () => ({ content: [] })); + expect(input.calls.input).toBe(0); + + const first = server.toolInputSchemaJson('t'); + expect(first).toEqual(standardSchemaToJsonSchema(z.object({ a: z.string() }), 'input')); + expect(input.calls.input).toBe(1); + + expect(server.toolInputSchemaJson('t')).toBe(first); + expect(input.calls.input).toBe(1); + }); + + it('outputSchemaJson converts on first read and memoises', () => { + const output = counted(z.object({ b: z.number() })); + const server = new McpServer({ name: 'lazy', version: '0' }); + const tool = server.registerTool('t', { outputSchema: output.schema }, async () => ({ content: [] })); + expect(output.calls.output).toBe(0); + + const first = tool.outputSchemaJson; + expect(first).toEqual(standardSchemaToJsonSchema(z.object({ b: z.number() }), 'output')); + expect(output.calls.output).toBe(1); + + expect(tool.outputSchemaJson).toBe(first); + expect(output.calls.output).toBe(1); + }); + + it('update({ outputSchema }) replaces outputSchemaJson', () => { + const server = new McpServer({ name: 'lazy', version: '0' }); + const tool = server.registerTool('t', { outputSchema: z.object({ b: z.number() }) }, async () => ({ content: [] })); + expect(tool.outputSchemaJson).toEqual(standardSchemaToJsonSchema(z.object({ b: z.number() }), 'output')); + + const replacement = counted(z.object({ c: z.string() })); + tool.update({ outputSchema: replacement.schema }); + expect(tool.outputSchemaJson).toEqual(standardSchemaToJsonSchema(z.object({ c: z.string() }), 'output')); + expect(replacement.calls.output).toBe(1); + // The replacement stays memoised. + void tool.outputSchemaJson; + expect(replacement.calls.output).toBe(1); + }); + + it('the invalid x-mcp-header warning fires each time tools are listed, not at registration', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const server = new McpServer({ name: 'lazy', version: '0' }); + server.registerTool('bad', { inputSchema: INVALID_HEADER_SCHEMA }, async () => ({ content: [] })); + expect(warn).toHaveBeenCalledTimes(0); + + await listTools(server); + await listTools(server); + await listTools(server); + expect(warn).toHaveBeenCalledTimes(3); + const scan = scanXMcpHeaderDeclarations(standardSchemaToJsonSchema(INVALID_HEADER_SCHEMA, 'input')); + expect(scan.valid).toBe(false); + for (const call of warn.mock.calls) { + expect(call[0]).toBe( + `[mcp-sdk] tool 'bad' carries an invalid x-mcp-header declaration and will be excluded by ` + + `conforming Streamable HTTP clients: ${(scan as { reason?: string }).reason}` + ); + } + }); + + it('tools/list emits the same bytes as a direct conversion', async () => { + const server = new McpServer({ name: 'lazy', version: '0' }); + const inputSchema = z.object({ a: z.string().describe('first'), n: z.number().optional() }); + const outputSchema = z.object({ ok: z.boolean() }); + server.registerTool('t', { title: 'T', description: 'd', inputSchema, outputSchema }, async () => ({ content: [] })); + server.registerTool('bare', {}, async () => ({ content: [] })); + + const { body } = await listTools(server); + expect(JSON.stringify(body.result)).toBe( + JSON.stringify({ + tools: [ + { + name: 't', + title: 'T', + description: 'd', + inputSchema: standardSchemaToJsonSchema(inputSchema, 'input'), + outputSchema: standardSchemaToJsonSchema(outputSchema, 'output') + }, + { name: 'bare', inputSchema: { type: 'object', properties: {} } } + ] + }) + ); + }); + + it('a conversion failure still surfaces at tools/list, and toolInputSchemaJson() returns undefined', async () => { + const unconvertible = { + '~standard': { version: 1, vendor: 'custom', validate: (value: unknown) => ({ value }) } + } as unknown as StandardSchemaWithJSON; + const server = new McpServer({ name: 'lazy', version: '0' }); + // Registration does not convert, so it does not throw. + expect(() => server.registerTool('t', { inputSchema: unconvertible }, async () => ({ content: [] }))).not.toThrow(); + + expect(server.toolInputSchemaJson('t')).toBeUndefined(); + + const { body } = await listTools(server); + expect(body.result).toBeUndefined(); + expect(JSON.stringify(body.error)).toContain('does not implement StandardJSONSchemaV1'); + }); +}); diff --git a/packages/server/test/server/lazyToolSchemaConversionRequests.test.ts b/packages/server/test/server/lazyToolSchemaConversionRequests.test.ts new file mode 100644 index 0000000000..8bf9e182be --- /dev/null +++ b/packages/server/test/server/lazyToolSchemaConversionRequests.test.ts @@ -0,0 +1,141 @@ +// Regression test for #2838: each request converts only the tool schemas it needs. +import type { JSONRPCMessage, StandardSchemaWithJSON } from '@modelcontextprotocol/core-internal'; +import { + CLIENT_CAPABILITIES_META_KEY, + CLIENT_INFO_META_KEY, + InMemoryTransport, + PROTOCOL_VERSION_META_KEY +} from '@modelcontextprotocol/core-internal'; +import { describe, expect, it } from 'vitest'; + +import { createMcpHandler } from '../../src/server/createMcpHandler'; +import { McpServer } from '../../src/server/mcp'; + +const conversions: string[] = []; + +function countingSchema(label: string): StandardSchemaWithJSON { + const convert = () => { + conversions.push(label); + return { type: 'object' as const, properties: { value: { type: 'string' as const } } }; + }; + return { + '~standard': { version: 1, vendor: 'counting', validate: value => ({ value }), jsonSchema: { input: convert, output: convert } } + }; +} + +const schemas = [countingSchema('a'), countingSchema('b'), countingSchema('c')]; +const outputOfA = countingSchema('out-a'); + +function buildServer(): McpServer { + const server = new McpServer({ name: 'stateless', version: '0' }); + for (const [i, inputSchema] of schemas.entries()) { + server.registerTool(`tool_${i}`, { inputSchema, ...(i === 0 ? { outputSchema: outputOfA } : {}) }, async () => ({ + content: [{ type: 'text', text: 'ok' }], + structuredContent: { value: 'ok' } + })); + } + return server; +} + +function legacy(method: string, params: Record): Request { + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) + }); +} + +function modern(method: string, params: Record, name?: string): Request { + const envelope = { + [PROTOCOL_VERSION_META_KEY]: '2026-07-28', + [CLIENT_INFO_META_KEY]: { name: 'c', version: '0' }, + [CLIENT_CAPABILITIES_META_KEY]: {} + }; + return new Request('http://localhost/mcp', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json, text/event-stream', + 'mcp-protocol-version': '2026-07-28', + 'mcp-method': method, + ...(name === undefined ? {} : { 'mcp-name': name }) + }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: { ...params, _meta: envelope } }) + }); +} + +async function conversionsFor(request: Request): Promise { + const handler = createMcpHandler(buildServer); + conversions.length = 0; + const response = await handler.fetch(request); + expect(response.status).toBe(200); + expect(await response.text()).toContain('"result"'); + return [...conversions]; +} + +// Connects a 2025-era peer to one long-lived server and returns a function that sends a request and resolves with its result. +async function connect(server: McpServer) { + const [peer, serverSide] = InMemoryTransport.createLinkedPair(); + const waiters = new Map void>(); + peer.onmessage = message => waiters.get((message as { id?: unknown }).id)?.(message); + await server.connect(serverSide); + await peer.start(); + let id = 0; + const request = (method: string, params: Record) => + new Promise>(resolve => { + waiters.set(++id, message => resolve((message as { result?: Record }).result ?? {})); + void peer.send({ jsonrpc: '2.0', id, method, params }); + }); + await request('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '0' } }); + await peer.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + return request; +} + +describe('lazy tool schema conversion, seen through requests (#2838)', () => { + it('a request that needs no tool schema converts nothing', async () => { + const initialize = { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'c', version: '0' } }; + expect(await conversionsFor(legacy('initialize', initialize))).toEqual([]); + expect(await conversionsFor(modern('server/discover', {}))).toEqual([]); + }); + + it('tools/call converts only schemas of the called tool', async () => { + const callB = { name: 'tool_1', arguments: { value: 'x' } }; + expect(await conversionsFor(legacy('tools/call', callB))).toEqual([]); + expect(await conversionsFor(modern('tools/call', callB, 'tool_1'))).toEqual(['b']); + const callA = { name: 'tool_0', arguments: { value: 'x' } }; + expect(await conversionsFor(legacy('tools/call', callA))).toEqual(['out-a']); + expect(await conversionsFor(modern('tools/call', callA, 'tool_0'))).toEqual(['a', 'out-a']); + }); + + it('tools/list converts each listed schema once', async () => { + expect(await conversionsFor(legacy('tools/list', {}))).toEqual(['a', 'out-a', 'b', 'c']); + expect(await conversionsFor(modern('tools/list', {}))).toEqual(['a', 'out-a', 'b', 'c']); + }); + + it('a long-lived server converts an output schema at most once, however often the tool is called', async () => { + const request = await connect(buildServer()); + conversions.length = 0; + for (let i = 0; i < 3; i++) await request('tools/call', { name: 'tool_0', arguments: { value: 'x' } }); + expect(conversions.filter(label => label === 'out-a').length).toBeLessThanOrEqual(1); + }); + + it('update({ outputSchema }) replaces the converted output schema', async () => { + const server = new McpServer({ name: 'long-lived', version: '0' }); + const tool = server.registerTool('tool', { inputSchema: schemas[0]!, outputSchema: outputOfA }, async () => ({ + content: [], + structuredContent: { value: 'ok' } + })); + const request = await connect(server); + const before = await request('tools/call', { name: 'tool', arguments: {} }); + expect(before.structuredContent).toEqual({ value: 'ok' }); + + const root = () => ({ anyOf: [{ type: 'string' as const }, { type: 'object' as const }] }); + const textOrObject: StandardSchemaWithJSON = { + '~standard': { version: 1, vendor: 'counting', validate: value => ({ value }), jsonSchema: { input: root, output: root } } + }; + tool.update({ outputSchema: textOrObject }); + // A 2025-era peer gets the result wrapped as { result } when the converted schema's root is not an object. + const after = await request('tools/call', { name: 'tool', arguments: {} }); + expect(after.structuredContent).toEqual({ result: { value: 'ok' } }); + }); +}); diff --git a/packages/server/test/server/mcpParamValidation.test.ts b/packages/server/test/server/mcpParamValidation.test.ts index 531edf05b0..7800a9d01a 100644 --- a/packages/server/test/server/mcpParamValidation.test.ts +++ b/packages/server/test/server/mcpParamValidation.test.ts @@ -8,7 +8,7 @@ * same `HeaderMismatch` shape the inbound classifier emits for the * standard-header cross-checks. A `null`/absent body value passes regardless * of the header (the spec's "server MUST NOT expect" rows). The - * registration-time declaration-validity check warns on invalid declarations. + * declaration-validity check warns on invalid declarations when tools are listed. */ import { CLIENT_CAPABILITIES_META_KEY, @@ -20,6 +20,7 @@ import { describe, expect, it, vi } from 'vitest'; import { fromJsonSchema } from '../../src/fromJsonSchema'; import { createMcpHandler } from '../../src/server/createMcpHandler'; +import { invoke } from '../../src/server/invoke'; import { McpServer } from '../../src/server/mcp'; const MODERN = '2026-07-28'; @@ -118,7 +119,7 @@ describe('SEP-2243 Mcp-Param-* server validation (createMcpHandler, modern era)' }); describe('SEP-2243 registerTool declaration-validity check', () => { - it('warns on an invalid x-mcp-header declaration at registration time', () => { + it('warns on an invalid x-mcp-header declaration when tools are listed, not at registration', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const s = new McpServer({ name: 'warn-server', version: '1.0.0' }); s.registerTool( @@ -131,6 +132,8 @@ describe('SEP-2243 registerTool declaration-validity check', () => { }, async () => ({ content: [] }) ); + expect(warn).not.toHaveBeenCalled(); + await invoke(s, { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }, { classification: { era: 'legacy' } }); expect(warn).toHaveBeenCalledWith(expect.stringContaining("tool 'bad' carries an invalid x-mcp-header")); warn.mockRestore(); }); From 7f4c12a6ae6b8f22411f7772c88036e1c8055423 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:43:26 +0100 Subject: [PATCH 16/44] fix(node): make hono a regular dependency (#2897) Co-authored-by: Claude --- .changeset/node-hono-regular-dependency.md | 5 ++++ docs/migration/upgrade-to-v2.md | 6 ++-- packages/middleware/node/package.json | 11 ++----- .../node/test/packageManifest.test.ts | 29 +++++++++++++++++++ 4 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 .changeset/node-hono-regular-dependency.md create mode 100644 packages/middleware/node/test/packageManifest.test.ts diff --git a/.changeset/node-hono-regular-dependency.md b/.changeset/node-hono-regular-dependency.md new file mode 100644 index 0000000000..1a70253b0c --- /dev/null +++ b/.changeset/node-hono-regular-dependency.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/node': patch +--- + +`hono` is now a regular dependency of `@modelcontextprotocol/node`, so installs with strict peer-dependency checking no longer fail on the `hono` peer that `@hono/node-server` requires. No runtime change. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 843bbd7638..d7b1485d84 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -207,9 +207,9 @@ The framework adapter packages declare their framework as a **peer dependency** (`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the `@modelcontextprotocol/*` packages your imports use, but does not add the framework peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` -depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) but -does **not** require the `hono` framework — your package manager may emit a harmless -unmet-peer warning for `hono` (upstream `@hono/node-server` declares it). +depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) and +installs `hono` as a regular dependency only because `@hono/node-server` requires it as +a peer — the adapter does not use the `hono` framework itself. v2 requires **Node.js 20+**. It is ESM-first but ships a **CommonJS build alongside ESM**, so CommonJS projects can `require('@modelcontextprotocol/…')` directly — no diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index 5e5cc6a47c..86807ade45 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -49,16 +49,11 @@ "test:watch": "vitest" }, "dependencies": { - "@hono/node-server": "catalog:runtimeServerOnly" - }, - "peerDependencies": { - "@modelcontextprotocol/server": "workspace:^", + "@hono/node-server": "catalog:runtimeServerOnly", "hono": "catalog:runtimeServerOnly" }, - "peerDependenciesMeta": { - "hono": { - "optional": true - } + "peerDependencies": { + "@modelcontextprotocol/server": "workspace:^" }, "devDependencies": { "@modelcontextprotocol/server": "workspace:^", diff --git a/packages/middleware/node/test/packageManifest.test.ts b/packages/middleware/node/test/packageManifest.test.ts new file mode 100644 index 0000000000..41895c6a97 --- /dev/null +++ b/packages/middleware/node/test/packageManifest.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +interface PackageManifest { + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; +} + +const manifestPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'); +const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as PackageManifest; + +describe('package manifest', () => { + // Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/2882: + // `@hono/node-server` declares `hono` as a required peer. If this package only lists + // `hono` as an optional peer, strict pnpm installs fail with ERR_PNPM_PEER_DEP_ISSUES. + it('declares hono as a regular dependency to satisfy the @hono/node-server peer', () => { + expect(manifest.dependencies).toHaveProperty('@hono/node-server'); + expect(manifest.dependencies).toHaveProperty('hono'); + }); + + it('does not declare hono as a peer dependency', () => { + expect(manifest.peerDependencies ?? {}).not.toHaveProperty('hono'); + expect(manifest.peerDependenciesMeta ?? {}).not.toHaveProperty('hono'); + }); +}); From 5238fba4424f82ec1ae9f6f458dd655ab322062d Mon Sep 17 00:00:00 2001 From: freya0926 Date: Wed, 30 Sep 2026 20:40:34 +0900 Subject: [PATCH 17/44] fix(core): serve tasks/get and tasks/cancel on 2026-07-28 with an explicit schema (#2599) Co-authored-by: freya0926 <299410795+freya0926@users.noreply.github.com> Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../era-gate-explicit-schema-handlers.md | 6 + docs/migration/support-2026-07-28.md | 25 ++- packages/core-internal/src/shared/protocol.ts | 30 ++- packages/core-internal/src/wire/codec.ts | 8 +- .../core-internal/test/wire/eraGates.test.ts | 199 +++++++++++++++++- 5 files changed, 250 insertions(+), 18 deletions(-) create mode 100644 .changeset/era-gate-explicit-schema-handlers.md diff --git a/.changeset/era-gate-explicit-schema-handlers.md b/.changeset/era-gate-explicit-schema-handlers.md new file mode 100644 index 0000000000..db051a95b9 --- /dev/null +++ b/.changeset/era-gate-explicit-schema-handlers.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +A server can now serve, and a client can now call, `tasks/get` and `tasks/cancel` of the Tasks extension (SEP-2663) on a 2026-07-28 connection, when the handler is registered and the request is sent with an explicit schema. Every other method that a protocol revision removed is still refused. If one server factory serves both eras and such a handler is meant for 2025-era clients only, register it only when `ctx.era === 'legacy'`. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 01ae6f0542..4ade8cf50e 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -349,11 +349,20 @@ mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol ver for requests; drop + `onerror` for notifications). Methods deleted by a protocol revision are **physically absent** from that era's -registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a -handler is registered, and sending an era-mismatched spec method (e.g. `server/discover` -toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws +registry: an inbound `ping` or `tasks/result` on a 2026-era connection gets `-32601` +even if a handler is registered, and sending an era-mismatched spec method (e.g. +`server/discover` toward a 2025-era peer, or `ping` toward a 2026-era peer) throws `SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. +The one exception is the Tasks extension (SEP-2663), which keeps two names the +2025-11-25 revision had in core: `tasks/get` and `tasks/cancel`. A handler registered +for them with an explicit schema (`setRequestHandler('tasks/get', { params, result }, +handler)`) is served, and a request sent with an explicit result schema +(`request({ method: 'tasks/get', params }, resultSchema)`) is sent, on every era. With +no handler, or with a method-keyed handler, they still get `-32601`. If one factory +serves both eras and such a handler is meant for 2025-era clients only, register it only +when `ctx.era === 'legacy'`; otherwise it answers 2026-07-28 requests too. + If you were on a v2 alpha and consumed wire schemas directly: | v2-alpha pattern | Mechanical fix | @@ -700,9 +709,13 @@ methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` maps still carry the `tasks/*` entries and the `CreateTaskResult` unions; narrow with the `isCallToolResult` guard if you are pinned to one of those alphas. `2.0.0-alpha.4` and later include the exclusion.) Where -task interop is genuinely required, use the explicit-schema custom-method form -(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*` -requests → `-32601`. +task interop is genuinely required, use the explicit-schema custom-method form on both +sides: `request({ method: 'tasks/get', params }, resultSchema)` to send and +`setRequestHandler('tasks/get', { params, result }, handler)` to serve, with the schemas +of the revision you are talking to (`GetTaskResultSchema` is the 2025-11-25 shape). On a +2026-era connection only `tasks/get` and `tasks/cancel`, the two names the Tasks +extension keeps, are served and sent this way; `tasks/result` and `tasks/list` stay +refused there. With no handler registered, inbound `tasks/*` requests get `-32601`. The experimental tasks **interception** layer is removed entirely — see [upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed). diff --git a/packages/core-internal/src/shared/protocol.ts b/packages/core-internal/src/shared/protocol.ts index 8e0bc04034..9d60be8efe 100644 --- a/packages/core-internal/src/shared/protocol.ts +++ b/packages/core-internal/src/shared/protocol.ts @@ -50,7 +50,14 @@ import type { StandardSchemaV1 } from '../util/standardSchema'; import { isStandardSchema, validateStandardSchema } from '../util/standardSchema'; import { bootstrapOutboundCodec } from '../wire/bootstrap'; import type { LiftedWireMaterial, WireCodec } from '../wire/codec'; -import { classifiedWireEra, codecForVersion, isSpecNotificationMethod, isSpecRequestMethod, MODERN_WIRE_REVISION } from '../wire/codec'; +import { + classifiedWireEra, + codecForVersion, + isExtensionReusedRequestMethod, + isSpecNotificationMethod, + isSpecRequestMethod, + MODERN_WIRE_REVISION +} from '../wire/codec'; import { manualInputRequiredValue, partitionInputResponses } from './inputRequiredEngine'; import type { Transport, TransportSendOptions } from './transport'; @@ -559,6 +566,8 @@ export abstract class Protocol { private _transport?: Transport; private _requestMessageId = 0; private _requestHandlers: Map Promise> = new Map(); + /** Methods registered with an explicit schema; the era gate in `_onrequest` reads it for the Tasks extension names. */ + private _customSchemaRequestMethods = new Set(); private _requestHandlerAbortControllers: Map = new Map(); private _notificationHandlers: Map Promise> = new Map(); private _responseHandlers: Map void> = new Map(); @@ -1000,7 +1009,12 @@ export abstract class Protocol { // shadow a deleted spec method across eras). Methods outside the // spec universe are consumer-owned extension methods and stay // era-blind. - if (isSpecRequestMethod(request.method) && !codec.hasRequestMethod(request.method)) { + // Exception: the Tasks extension names (SEP-2663), when registered with an explicit schema. + if ( + isSpecRequestMethod(request.method) && + !codec.hasRequestMethod(request.method) && + !(isExtensionReusedRequestMethod(request.method) && this._customSchemaRequestMethods.has(request.method)) + ) { sendErrorResponse(ProtocolErrorCode.MethodNotFound, 'Method not found'); return; } @@ -1270,10 +1284,12 @@ export abstract class Protocol { ): Promise>; request(request: Request, schemaOrOptions?: StandardSchemaV1 | RequestOptions, maybeOptions?: RequestOptions): Promise { const codec = this._resolveOutboundCodec(request.method); - this._assertOutboundRequestInEra(codec, request.method); if (isStandardSchema(schemaOrOptions)) { + // Only the Tasks extension names skip the era gate here; every other spec method stays gated. + if (!isExtensionReusedRequestMethod(request.method)) this._assertOutboundRequestInEra(codec, request.method); return this._requestWithSchemaViaCodec(codec, request, schemaOrOptions, maybeOptions); } + this._assertOutboundRequestInEra(codec, request.method); const validate = codecResultValidator(codec, request.method); if (validate === undefined) { throw new TypeError(`'${request.method}' is not a spec method; pass a result schema as the second argument to request().`); @@ -1751,6 +1767,13 @@ export abstract class Protocol { throw new TypeError('setRequestHandler: handler is required'); } + // The era gate reads how the handler was registered; a method-keyed re-registration ends the exemption. + if (typeof schemasOrHandler === 'function') { + this._customSchemaRequestMethods.delete(method); + } else { + this._customSchemaRequestMethods.add(method); + } + this._requestHandlers.set(method, this._wrapHandler(method, stored)); } @@ -1787,6 +1810,7 @@ export abstract class Protocol { */ removeRequestHandler(method: RequestMethod | string): void { this._requestHandlers.delete(method); + this._customSchemaRequestMethods.delete(method); } /** diff --git a/packages/core-internal/src/wire/codec.ts b/packages/core-internal/src/wire/codec.ts index cf545c2ebc..03f0933d94 100644 --- a/packages/core-internal/src/wire/codec.ts +++ b/packages/core-internal/src/wire/codec.ts @@ -33,9 +33,10 @@ * Custom-handler shadowing policy (both directions): a method that belongs to * the SPEC-METHOD UNIVERSE — the union of every codec's registry, derived, * not hand-curated — is ALWAYS era-gated, so a custom handler registered for - * a deleted spec method (e.g. `tasks/get`) serves it only on the era that + * a deleted spec method (e.g. `tasks/result`) serves it only on the era that * defines it. Methods outside the universe are consumer-owned extension * methods: they are era-blind and require explicit schemas, exactly as today. + * Exception: `isExtensionReusedRequestMethod`, the Tasks extension names sent or served with an explicit schema. * * Everything in `wire/` is internal to the bundled, `private: true` core — * nothing per-revision is public surface, and nothing here may ever be @@ -335,4 +336,9 @@ export function isSpecNotificationMethod(method: string): boolean { return ALL_CODECS.some(codec => codec.hasNotificationMethod(method)); } +/** The names SEP-2663 re-defines for the Tasks extension on the era that removed them from core. */ +export function isExtensionReusedRequestMethod(method: string): boolean { + return method === 'tasks/get' || method === 'tasks/cancel'; +} + const ALL_CODECS: readonly WireCodec[] = [rev2025Codec, rev2026Codec]; diff --git a/packages/core-internal/test/wire/eraGates.test.ts b/packages/core-internal/test/wire/eraGates.test.ts index fb2eb26d4e..fdba857ce4 100644 --- a/packages/core-internal/test/wire/eraGates.test.ts +++ b/packages/core-internal/test/wire/eraGates.test.ts @@ -11,9 +11,13 @@ * Registry membership is the deletion story, and these tests prove it at the * protocol funnels, in both directions: * - * - inbound: `tasks/get` on a modern-era instance gets −32601 BY ABSENCE — - * even with a handler registered (a custom handler cannot shadow a - * deleted spec method across eras); era-deleted spec notifications are + * - inbound: a handler cannot shadow a deleted spec method across eras — + * `ping` on a modern-era instance still answers −32601 BY ABSENCE, with + * or without an explicit schema. The one exception (issue #2598): the + * Tasks extension, SEP-2663, keeps `tasks/get` and `tasks/cancel` after + * 2026-07-28 moved tasks out of core, so an EXPLICIT-SCHEMA handler + * (`setRequestHandler(method, schemas, handler)`) for those two names is + * served on a modern-era instance. Era-deleted spec notifications are * silently dropped even with a handler registered. * - outbound: an era-mismatched spec method dies locally with * `SdkErrorCode.MethodNotSupportedByProtocolVersion` before anything @@ -109,31 +113,161 @@ const resultOf = (msg: JSONRPCMessage | undefined) => (msg as { result?: Record< describe('inbound era gates — deletions are physical, era is instance state', () => { const registerTasksGetHandler = (onRun: () => void) => (receiver: TestProtocol) => { - // A custom (3-arg) handler deliberately shadowing the deleted - // spec method: it may serve the 2025 era only. + // An explicit-schema (3-arg) handler for a name the Tasks extension + // keeps (#2598): it is served on every era. receiver.setRequestHandler('tasks/get', { params: z.looseObject({ taskId: z.string() }) }, () => { onRun(); return {} as Result; }); }; - test('a modern-era instance answers tasks/get with −32601 BY ABSENCE even with a handler registered', async () => { + test('a modern-era instance still serves tasks/get through an explicit-schema handler (#2598)', async () => { let handlerRan = false; const h = await harness({ era: '2026-07-28', setup: registerTasksGetHandler(() => (handlerRan = true)) }); // A matching modern classification rides along untouched — the - // handoff check accepts it; the era gate still answers by absence. + // handoff check accepts it; the era gate no longer answers by + // absence when an explicit-schema handler is registered. + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(handlerRan).toBe(true); + expect(resultOf(h.sent[0])).toBeDefined(); + }); + + test('a modern-era instance still answers −32601 BY ABSENCE for a deleted spec method with no handler registered', async () => { + const h = await harness({ era: '2026-07-28' }); + h.deliver( { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN ); await h.flush(); - expect(handlerRan).toBe(false); expect(h.sent).toHaveLength(1); expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); }); + // The built-in `ping` handler (registered via the typed 2-arg overload + // in the `Protocol` constructor) demonstrates the typed path stays fully + // era-gated — see 'ping on a modern-era instance is −32601 by absence' + // below. + + test('a modern-era instance serves tasks/cancel through an explicit-schema handler too (#2598)', async () => { + let handlerRan = false; + const h = await harness({ + era: '2026-07-28', + setup: receiver => + receiver.setRequestHandler('tasks/cancel', { params: z.looseObject({ taskId: z.string() }) }, () => { + handlerRan = true; + return {} as Result; + }) + }); + + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tasks/cancel', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(handlerRan).toBe(true); + expect(resultOf(h.sent[0])).toBeDefined(); + }); + + test.each(['ping', 'initialize', 'logging/setLevel', 'resources/subscribe', 'tasks/result', 'tasks/list'])( + 'the exemption is for the Tasks extension names only: an explicit-schema handler for %s stays −32601 on the modern era', + async method => { + let handlerRan = false; + const h = await harness({ + era: '2026-07-28', + setup: receiver => + receiver.setRequestHandler(method, { params: z.looseObject({}) }, () => { + handlerRan = true; + return {} as Result; + }) + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method, params: { _meta: { ...ENVELOPE } } } as JSONRPCMessage, MODERN); + await h.flush(); + + expect(handlerRan).toBe(false); + expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); + } + ); + + test('a method-keyed handler for a deleted spec method stays −32601 on the modern era: only an explicit schema is exempt', async () => { + let handlerRan = false; + const h = await harness({ + era: '2026-07-28', + setup: receiver => + (receiver.setRequestHandler as (method: string, handler: () => Result) => void)('tasks/get', () => { + handlerRan = true; + return {} as Result; + }) + }); + + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(handlerRan).toBe(false); + expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); + }); + + test.each([ + [ + 're-registered method-keyed', + (receiver: TestProtocol) => + (receiver.setRequestHandler as (method: string, handler: () => Result) => void)('tasks/get', () => ({}) as Result) + ], + ['removed', (receiver: TestProtocol) => receiver.removeRequestHandler('tasks/get')] + ])('the exemption follows the registration: an explicit-schema handler that is %s answers −32601 again', async (_label, undo) => { + let served = false; + const h = await harness({ + era: '2026-07-28', + setup: receiver => { + registerTasksGetHandler(() => (served = true))(receiver); + undo(receiver); + // A catch-all must not pick the deleted spec method up either. + receiver.fallbackRequestHandler = async () => { + served = true; + return {} as Result; + }; + } + }); + + h.deliver( + { jsonrpc: '2.0', id: 1, method: 'tasks/get', params: { taskId: 't-1', _meta: { ...ENVELOPE } } } as JSONRPCMessage, + MODERN + ); + await h.flush(); + + expect(served).toBe(false); + expect(errorOf(h.sent[0])).toMatchObject({ code: -32601, message: 'Method not found' }); + }); + + test('the exemption is for the Tasks extension names only: a legacy-era instance still answers a 2026-only name with -32601', async () => { + let handlerRan = false; + const h = await harness({ + setup: receiver => + receiver.setRequestHandler('subscriptions/listen', { params: z.looseObject({}) }, () => { + handlerRan = true; + return {} as Result; + }) + }); + + h.deliver({ jsonrpc: '2.0', id: 1, method: 'subscriptions/listen', params: {} } as JSONRPCMessage); + await h.flush(); + + expect(handlerRan).toBe(false); + expect(errorOf(h.sent[0])).toMatchObject({ code: -32601 }); + }); + test('a legacy-era instance (the default) serves tasks/get with that handler — era is fixed per instance', async () => { let handlerRan = false; const h = await harness({ setup: registerTasksGetHandler(() => (handlerRan = true)) }); @@ -534,6 +668,55 @@ describe('outbound era gates — typed local error before the transport', () => expect(h.sent).toHaveLength(0); }); + test('an explicit schema does not open the other 2025-only spec methods: request() still fails locally on a 2026-era instance', async () => { + const h = await harness({ era: '2026-07-28' }); + + for (const method of [ + 'ping', + 'initialize', + 'logging/setLevel', + 'resources/subscribe', + 'tasks/result', + 'tasks/list', + 'roots/list' + ]) { + const attempt = () => h.receiver.request({ method }, z.looseObject({})); + expect(attempt, method).toThrow(SdkError); + try { + attempt(); + } catch (error) { + expect((error as SdkError).code, method).toBe(SdkErrorCode.MethodNotSupportedByProtocolVersion); + } + } + expect(h.sent).toHaveLength(0); + }); + + test('the public request() overload sends tasks/cancel with an explicit schema on a 2026-era instance too (#2598)', async () => { + const h = await harness({ era: '2026-07-28' }); + + const pending = h.receiver.request({ method: 'tasks/cancel', params: { taskId: 't-1' } }, z.looseObject({})); + await h.flush(); + + expect(h.sent).toHaveLength(1); + expect(h.sent[0]).toMatchObject({ method: 'tasks/cancel', params: { taskId: 't-1' } }); + pending.catch(() => {}); + }); + + test('the public request() overload sends tasks/get with an explicit schema even on a 2026-era instance (#2598)', async () => { + const h = await harness({ era: '2026-07-28' }); + + const pending = h.receiver.request({ method: 'tasks/get', params: { taskId: 't-1' } }, z.looseObject({})); + await h.flush(); + + // Reached the transport (unlike the typed-dispatch case above, which + // never gets past the local era gate) — no peer is listening in this + // harness, so the request itself is left pending; asserting on + // `h.sent` is enough to prove it was NOT rejected locally. + expect(h.sent).toHaveLength(1); + expect(h.sent[0]).toMatchObject({ method: 'tasks/get', params: { taskId: 't-1' } }); + pending.catch(() => {}); + }); + test('pre-negotiation bootstrap pins still route initialize to the 2025 era', async () => { // An instance with NO negotiated version may always send the legacy // handshake; setting a modern version afterwards closes it (the pin From 2237555ed036c3e80341c0f3c28684e9c3ff0728 Mon Sep 17 00:00:00 2001 From: Sharvil Saxena <97365286+sharziki@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:45:08 -0400 Subject: [PATCH 18/44] Typecheck the three test workspace packages, and fix the registerPrompt typing defect they were hiding (#2841) Signed-off-by: Sharvil Saxena Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .../register-prompt-context-only-overload.md | 5 ++++ packages/server/src/server/mcp.ts | 15 +++++++++- .../test/server/registerPromptNoArgs.test.ts | 28 +++++++++++++++++++ pnpm-lock.yaml | 9 ++++++ test/conformance/package.json | 2 ++ test/conformance/src/everythingServer.ts | 5 +++- test/conformance/tsconfig.json | 2 ++ test/helpers/package.json | 4 ++- test/integration/package.json | 2 ++ test/integration/test/client/client.test.ts | 8 ++++-- test/integration/test/server.test.ts | 6 ++-- .../test/server/cloudflareWorkers.test.ts | 2 +- .../test/server/declaredCapabilities.test.ts | 4 +-- .../test/server/dualEraStdio.test.ts | 8 +++--- .../test/server/elicitation.test.ts | 13 +++++---- test/integration/test/server/mcp.test.ts | 25 ++++++++--------- test/integration/test/standardSchema.test.ts | 18 ++++++------ 17 files changed, 113 insertions(+), 43 deletions(-) create mode 100644 .changeset/register-prompt-context-only-overload.md create mode 100644 packages/server/test/server/registerPromptNoArgs.test.ts diff --git a/.changeset/register-prompt-context-only-overload.md b/.changeset/register-prompt-context-only-overload.md new file mode 100644 index 0000000000..26203274ea --- /dev/null +++ b/.changeset/register-prompt-context-only-overload.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`McpServer.registerPrompt()` now types the callback correctly when no `argsSchema` is given: its one parameter is the server context. Before, reading `ctx.mcpReq` there was a type error although it worked at runtime. Prompts registered with an `argsSchema` are unchanged. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 01c63705d4..2aeb657e7e 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1082,6 +1082,19 @@ export class McpServer { * ); * ``` */ + registerPrompt( + name: string, + config: { + title?: string; + description?: string; + argsSchema?: undefined; + icons?: Icon[]; + /** Determines whether this prompt retrieval needs an OAuth scope challenge. */ + scopeChallenge?: ScopeChallengeHandler; + _meta?: Record; + }, + cb: PromptCallback + ): RegisteredPrompt; registerPrompt( name: string, config: { @@ -1118,7 +1131,7 @@ export class McpServer { scopeChallenge?: ScopeChallengeHandler; _meta?: Record; }, - cb: PromptCallback | LegacyPromptCallback + cb: PromptCallback | PromptCallback | LegacyPromptCallback ): RegisteredPrompt { if (this._registeredPrompts[name]) { throw new Error(`Prompt ${name} is already registered`); diff --git a/packages/server/test/server/registerPromptNoArgs.test.ts b/packages/server/test/server/registerPromptNoArgs.test.ts new file mode 100644 index 0000000000..ac012f44b1 --- /dev/null +++ b/packages/server/test/server/registerPromptNoArgs.test.ts @@ -0,0 +1,28 @@ +// Pins the types of registerPrompt() without argsSchema: the callback's one parameter is the server context. +import type { ServerContext } from '@modelcontextprotocol/core-internal'; +import { describe, expect, expectTypeOf, test } from 'vitest'; + +import { McpServer } from '../../src/server/mcp'; + +describe('registerPrompt without argsSchema', () => { + test('types the callback parameter as the server context', () => { + const server = new McpServer({ name: 'test server', version: '1.0' }); + + server.registerPrompt('ctx-only', {}, async ctx => { + expectTypeOf(ctx).toEqualTypeOf(); + return { messages: [{ role: 'assistant' as const, content: { type: 'text' as const, text: String(ctx.mcpReq.id) } }] }; + }); + + expect(server.server).toBeDefined(); + }); + + test('still accepts a callback that ignores the context', () => { + const server = new McpServer({ name: 'test server', version: '1.0' }); + + server.registerPrompt('no-args', { description: 'takes nothing' }, async () => ({ + messages: [{ role: 'assistant' as const, content: { type: 'text' as const, text: 'ok' } }] + })); + + expect(server.server).toBeDefined(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c663ad7086..cb32c9289c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1972,6 +1972,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 cors: specifier: catalog:runtimeServerOnly version: 2.8.6 @@ -2083,6 +2086,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 vitest: specifier: catalog:devTools version: 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)) @@ -2122,6 +2128,9 @@ importers: '@modelcontextprotocol/vitest-config': specifier: workspace:^ version: link:../../common/vitest-config + '@typescript/native-preview': + specifier: catalog:devTools + version: 7.0.0-dev.20260327.2 '@valibot/to-json-schema': specifier: catalog:devTools version: 1.6.0(valibot@1.3.1(typescript@5.9.3)) diff --git a/test/conformance/package.json b/test/conformance/package.json index f51e84d9ea..293180e6e3 100644 --- a/test/conformance/package.json +++ b/test/conformance/package.json @@ -22,6 +22,7 @@ "mcp" ], "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint", @@ -51,6 +52,7 @@ "cors": "catalog:runtimeServerOnly", "express": "catalog:runtimeServerOnly", "tsx": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", "zod": "catalog:runtimeShared" } } diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 25926ba2a7..6ede22b2bd 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -200,7 +200,10 @@ function createMcpServer() { inputSchema: fromJsonSchema<{ region?: string; level?: number }>({ type: 'object', properties: { - region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' }, + region: { type: 'string', description: 'mirrored into Mcp-Param-Region', 'x-mcp-header': 'Region' } as Record< + string, + unknown + >, level: { type: 'integer', description: 'non-mirrored argument' } } }) diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index b424eb35ec..a7617960f8 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -13,7 +13,9 @@ "./node_modules/@modelcontextprotocol/core-internal/src/exports/public/index.ts" ], "@modelcontextprotocol/client": ["./node_modules/@modelcontextprotocol/client/src/index.ts"], + "@modelcontextprotocol/client/_shims": ["./node_modules/@modelcontextprotocol/client/src/shimsNode.ts"], "@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"], + "@modelcontextprotocol/server/_shims": ["./node_modules/@modelcontextprotocol/server/src/shimsNode.ts"], "@modelcontextprotocol/express": ["./node_modules/@modelcontextprotocol/express/src/index.ts"], "@modelcontextprotocol/node": ["./node_modules/@modelcontextprotocol/node/src/index.ts"], "@modelcontextprotocol/vitest-config": ["./node_modules/@modelcontextprotocol/vitest-config/tsconfig.json"], diff --git a/test/helpers/package.json b/test/helpers/package.json index c359fbe6c2..60e0c6cae6 100644 --- a/test/helpers/package.json +++ b/test/helpers/package.json @@ -22,6 +22,7 @@ "mcp" ], "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", "lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint" @@ -32,6 +33,7 @@ "vitest": "catalog:devTools", "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", - "@modelcontextprotocol/eslint-config": "workspace:^" + "@modelcontextprotocol/eslint-config": "workspace:^", + "@typescript/native-preview": "catalog:devTools" } } diff --git a/test/integration/package.json b/test/integration/package.json index ed75fb1b0c..df35ac13a5 100644 --- a/test/integration/package.json +++ b/test/integration/package.json @@ -22,6 +22,7 @@ "mcp" ], "scripts": { + "typecheck": "tsgo -p tsconfig.json --noEmit", "lint": "eslint test/ && prettier --ignore-path ../../.prettierignore --check .", "lint:fix": "eslint test/ --fix && prettier --ignore-path ../../.prettierignore --write .", "check": "npm run typecheck && npm run lint", @@ -42,6 +43,7 @@ "@modelcontextprotocol/tsconfig": "workspace:^", "@modelcontextprotocol/vitest-config": "workspace:^", "@valibot/to-json-schema": "catalog:devTools", + "@typescript/native-preview": "catalog:devTools", "arktype": "catalog:devTools", "supertest": "catalog:devTools", "valibot": "catalog:devTools", diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 92140b531f..bc5d19b8cf 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -73,7 +73,8 @@ test('should initialize with matching protocol version', async () => { * Test: Initialize with Supported Older Protocol Version */ test('should initialize with supported older protocol version', async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + // Index 1 is always present; `noUncheckedIndexedAccess` cannot see that. + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]!; const clientTransport: Transport = { start: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), @@ -277,7 +278,8 @@ test('should reject unsupported protocol version', async () => { * Test: Connect New Client to Old Supported Server Version */ test('should connect new client to old, supported server version', async () => { - const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]; + // Index 1 is always present; `noUncheckedIndexedAccess` cannot see that. + const OLD_VERSION = SUPPORTED_PROTOCOL_VERSIONS[1]!; const server = new Server( { name: 'test server', @@ -725,7 +727,7 @@ test('should only allow setRequestHandler for declared capabilities', () => { // This should throw because roots listing is not a declared capability expect(() => { - client.setRequestHandler('roots/list', () => ({})); + client.setRequestHandler('roots/list', () => ({ roots: [] })); }).toThrow('Client does not support roots capability'); }); diff --git a/test/integration/test/server.test.ts b/test/integration/test/server.test.ts index 51cde29161..0d5faabbe1 100644 --- a/test/integration/test/server.test.ts +++ b/test/integration/test/server.test.ts @@ -352,8 +352,9 @@ test('should respect client elicitation capabilities', async () => { client.setRequestHandler('elicitation/create', params => ({ action: 'accept', + // Omit `username`: content values cannot be `undefined`. content: { - username: params.params.message.includes('username') ? 'test-user' : undefined, + ...(params.params.message.includes('username') ? { username: 'test-user' } : {}), confirmed: true } })); @@ -436,8 +437,9 @@ test('should use elicitInput with mode: "form" by default for backwards compatib client.setRequestHandler('elicitation/create', params => ({ action: 'accept', + // Omit `username`: content values cannot be `undefined`. content: { - username: params.params.message.includes('username') ? 'test-user' : undefined, + ...(params.params.message.includes('username') ? { username: 'test-user' } : {}), confirmed: true } })); diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index 02add64201..45d0117510 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -36,7 +36,7 @@ const SERVER_VERSION_NONCE = randomUUID(); */ const WRANGLER_BIN = (() => { const pkgPath = createRequire(import.meta.url).resolve('wrangler/package.json'); - const bin = (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin: Record }).bin.wrangler; + const bin = (JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { bin: Record }).bin.wrangler!; return path.resolve(path.dirname(pkgPath), bin); })(); diff --git a/test/integration/test/server/declaredCapabilities.test.ts b/test/integration/test/server/declaredCapabilities.test.ts index 60e214d129..dcab27f4a6 100644 --- a/test/integration/test/server/declaredCapabilities.test.ts +++ b/test/integration/test/server/declaredCapabilities.test.ts @@ -140,12 +140,12 @@ describe('deterministic tools/list ordering (draft spec)', () => { const client = await connect(mcpServer); // Disable a tool in the middle: relative order of the remaining tools is unchanged. - registered[2].disable(); + registered[2]!.disable(); const whileDisabled = await client.listTools(); expect(whileDisabled.tools.map(t => t.name)).toEqual(['zeta', 'alpha', 'omega', 'beta']); // Re-enable it: the original insertion order is restored, not appended at the end. - registered[2].enable(); + registered[2]!.enable(); const afterReenable = await client.listTools(); expect(afterReenable.tools.map(t => t.name)).toEqual(names); }); diff --git a/test/integration/test/server/dualEraStdio.test.ts b/test/integration/test/server/dualEraStdio.test.ts index 9cef3b8f7b..04dd468d3e 100644 --- a/test/integration/test/server/dualEraStdio.test.ts +++ b/test/integration/test/server/dualEraStdio.test.ts @@ -53,9 +53,9 @@ function spawnFixtureTransport(): StdioClientTransport { function recordInbound(transport: StdioClientTransport): JSONRPCMessage[] { const inbound: JSONRPCMessage[] = []; const original = transport.onmessage; - transport.onmessage = (message, extra) => { + transport.onmessage = message => { inbound.push(message); - original?.(message, extra); + original?.(message); }; return inbound; } @@ -64,9 +64,9 @@ function recordInbound(transport: StdioClientTransport): JSONRPCMessage[] { function recordOutbound(transport: StdioClientTransport): JSONRPCMessage[] { const outbound: JSONRPCMessage[] = []; const originalSend = transport.send.bind(transport); - transport.send = async (message, options) => { + transport.send = async message => { outbound.push(message); - return originalSend(message, options); + return originalSend(message); }; return outbound; } diff --git a/test/integration/test/server/elicitation.test.ts b/test/integration/test/server/elicitation.test.ts index 13fb77e944..c8819554f7 100644 --- a/test/integration/test/server/elicitation.test.ts +++ b/test/integration/test/server/elicitation.test.ts @@ -8,7 +8,7 @@ */ import { Client } from '@modelcontextprotocol/client'; -import type { ElicitRequestFormParams } from '@modelcontextprotocol/core-internal'; +import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/core-internal'; import { InMemoryTransport } from '@modelcontextprotocol/core-internal'; import { AjvJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/ajv'; import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/core-internal/validators/cfWorker'; @@ -338,7 +338,8 @@ function testElicitationFlow(validatorProvider: typeof ajvProvider | typeof cfWo test(`${validatorName}: should handle multiple sequential elicitation requests`, async () => { let requestCount = 0; - client.setRequestHandler('elicitation/create', request => { + // Annotated so each branch widens to the shared result type. + client.setRequestHandler('elicitation/create', (request): ElicitResult => { requestCount++; if (request.params.message.includes('name')) { return { action: 'accept', content: { name: 'Alice' } }; @@ -996,12 +997,12 @@ describe('declared-dialect requestedSchema (default validator)', () => { const requestedSchema = { $schema: 'http://json-schema.org/draft-07/schema#', - type: 'object', - properties: { name: { type: 'string', minLength: 1 } }, + type: 'object' as const, + properties: { name: { type: 'string' as const, minLength: 1 } }, required: ['name'] - } as const; + }; - let content: Record = { name: 'John' }; + let content: { [key: string]: string | number | boolean | string[] } = { name: 'John' }; client.setRequestHandler('elicitation/create', () => ({ action: 'accept', content })); await expect(server.elicitInput({ mode: 'form', message: 'name?', requestedSchema })).resolves.toMatchObject({ diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 4b9a3865f0..8bd40a8459 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1,5 +1,5 @@ import { Client } from '@modelcontextprotocol/client'; -import type { Notification, TextContent } from '@modelcontextprotocol/core-internal'; +import type { Notification, ServerContext, TextContent } from '@modelcontextprotocol/core-internal'; import { getDisplayName, InMemoryTransport, @@ -411,16 +411,14 @@ describe('Zod v4', () => { expect(template.listCallback).toBe(list); const abortController = new AbortController(); + // A partial context is enough for what the callback reads. const result = await template.listCallback?.({ - signal: abortController.signal, - requestId: 'not-implemented', - sendRequest: () => { - throw new Error('Not implemented'); - }, - sendNotification: () => { - throw new Error('Not implemented'); + mcpReq: { + id: 'not-implemented', + method: 'resources/list', + signal: abortController.signal } - }); + } as unknown as ServerContext); expect(result?.resources).toHaveLength(1); expect(list).toHaveBeenCalled(); }); @@ -598,11 +596,12 @@ describe('Zod v4', () => { name: z.string(), value: z.number() }), - callback: async ({ name, value }) => ({ + // `update()` types `args` as `unknown`; narrow it here. + callback: async args => ({ content: [ { type: 'text', - text: `Updated: ${name}, ${value}` + text: `Updated: ${(args as { name: string }).name}, ${(args as { value: number }).value}` } ] }) @@ -850,7 +849,7 @@ describe('Zod v4', () => { version: '1.0' }); - mcpServer.registerResource('test://resource', 'Test Resource', async () => ({ + mcpServer.registerResource('test-resource', 'test://resource', {}, async () => ({ contents: [{ uri: 'test://resource', text: 'Test' }] })); @@ -871,7 +870,7 @@ describe('Zod v4', () => { version: '1.0' }); - mcpServer.registerPrompt('test-prompt', async () => ({ + mcpServer.registerPrompt('test-prompt', {}, async () => ({ messages: [{ role: 'assistant', content: { type: 'text', text: 'Test' } }] })); diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts index b9ab0284f3..cc82977a9e 100644 --- a/test/integration/test/standardSchema.test.ts +++ b/test/integration/test/standardSchema.test.ts @@ -57,8 +57,8 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('greet'); - expect(result.tools[0].inputSchema).toMatchObject({ + expect(result.tools[0]?.name).toBe('greet'); + expect(result.tools[0]?.inputSchema).toMatchObject({ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', properties: { @@ -67,7 +67,7 @@ describe('Standard Schema Support', () => { } }); // Check required array contains both fields (order may vary by library) - expect(result.tools[0].inputSchema.required).toEqual(expect.arrayContaining(['name', 'age'])); + expect(result.tools[0]?.inputSchema.required).toEqual(expect.arrayContaining(['name', 'age'])); }); test('should register tool with ArkType input and output schemas', async () => { @@ -91,7 +91,7 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); - expect(result.tools[0].outputSchema).toMatchObject({ + expect(result.tools[0]?.outputSchema).toMatchObject({ $schema: 'https://json-schema.org/draft/2020-12/schema', type: 'object', properties: { @@ -99,7 +99,7 @@ describe('Standard Schema Support', () => { operation: { type: 'string' } } }); - expect(result.tools[0].outputSchema!.required).toEqual(expect.arrayContaining(['result', 'operation'])); + expect(result.tools[0]?.outputSchema!.required).toEqual(expect.arrayContaining(['result', 'operation'])); }); }); @@ -212,8 +212,8 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); expect(result.tools).toHaveLength(1); - expect(result.tools[0].name).toBe('greet'); - expect(result.tools[0].inputSchema).toMatchObject({ + expect(result.tools[0]?.name).toBe('greet'); + expect(result.tools[0]?.inputSchema).toMatchObject({ type: 'object', properties: { name: { type: 'string' }, @@ -239,7 +239,7 @@ describe('Standard Schema Support', () => { const result = await client.request({ method: 'tools/list' }); - expect(result.tools[0].inputSchema.properties).toMatchObject({ + expect(result.tools[0]?.inputSchema.properties).toMatchObject({ city: { type: 'string', description: 'The city name' }, country: { type: 'string', description: 'The country code' } }); @@ -396,7 +396,7 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); const listed = await client.request({ method: 'tools/list' }); - expect(listed.tools[0].inputSchema).toMatchObject({ + expect(listed.tools[0]?.inputSchema).toMatchObject({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] From e765b3be84b7eba1837ba8da84acd6898cc47c65 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:59:15 +0100 Subject: [PATCH 19/44] fix(client): say so when the version probe gets an unusable reply (#2903) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Co-authored-by: Claude --- .../probe-unusable-2xx-reply-message.md | 8 + docs/migration/support-2026-07-28.md | 2 +- docs/protocol-versions.md | 2 + docs/troubleshooting.md | 1 + packages/client/src/client/probeClassifier.ts | 14 ++ .../client/src/client/versionNegotiation.ts | 12 ++ .../test/client/probeClassifier.test.ts | 37 +++++ .../test/client/probeUnusable2xx.wire.test.ts | 156 ++++++++++++++++++ .../test/client/versionNegotiation.test.ts | 78 +++++++++ 9 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 .changeset/probe-unusable-2xx-reply-message.md create mode 100644 packages/client/test/client/probeUnusable2xx.wire.test.ts diff --git a/.changeset/probe-unusable-2xx-reply-message.md b/.changeset/probe-unusable-2xx-reply-message.md new file mode 100644 index 0000000000..d96e183ec8 --- /dev/null +++ b/.changeset/probe-unusable-2xx-reply-message.md @@ -0,0 +1,8 @@ +--- +'@modelcontextprotocol/client': patch +--- + +With `versionNegotiation` in `'auto'` or pin mode, a `server/discover` probe answered with a 2xx that carries no usable reply (an empty or +non-JSON body, a `204`, a missing or unexpected content type) still rejects `connect()` with `EraNegotiationFailed`. The message now says +`the server answered with an unusable reply (...)` instead of reading like a network failure. To connect to a 2025 server behind a front that +answers the probe this way, pass `connect(transport, { prior: { kind: 'legacy' } })` or use `mode: 'legacy'`. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 4ade8cf50e..7c83465749 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -79,7 +79,7 @@ falling back — see [Protocol versions](../protocol-versions.md). This holds ev a front that answers the probe `403` but would pass `initialize`: relying on a legacy fallback there is a deliberate non-goal — fix the auth wall (or pass credentials), because auth status never selects an era. A `5xx` on the probe is a server failure, -also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. Probe timeouts are **transport-aware**: on **stdio** a server that does not +also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. A `2xx` probe answer that carries no usable reply (an empty or non-JSON body, a `204`, a missing or unexpected content type) is not era evidence either: `connect()` rejects with `SdkError(EraNegotiationFailed)`. Probe timeouts are **transport-aware**: on **stdio** a server that does not answer within `timeoutMs` is treated as legacy and the client falls back to `initialize` (some legacy servers never respond to unknown pre-`initialize` requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` — diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 06e3d174f2..5972385c42 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -99,6 +99,8 @@ const cli = new Client( ); ``` +A `2xx` answer that carries no usable reply (an empty or non-JSON body, a `204`, a missing or unexpected content type) is not era evidence: `connect()` rejects with `SdkError(EraNegotiationFailed)` and the message says `the server answered with an unusable reply`. To connect to a 2025 server behind a front that answers the probe this way, pass `connect(transport, { prior: { kind: 'legacy' } })`. + A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize`; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers. Auth statuses are not era evidence either. An HTTP `401` or `403` rejecting the probe surfaces as a typed authorization failure, never the legacy fallback — and never as `EraNegotiationFailed`, so era-recovery flows keyed on that code (the [gateway guide](./advanced/gateway.md) recipe) cannot consume an auth wall. A plain `401` or `403` — with or without an `authProvider` — rejects `connect()` with an `SdkHttpError` carrying the status: code `ClientHttpAuthentication` for `401`, `ClientHttpForbidden` for `403`. One 403 shape is different: a `WWW-Authenticate` challenge with `error="insufficient_scope"` enters the Streamable HTTP transport's step-up flow regardless of provider, and with none it rejects with the flow's typed `InsufficientScopeError`. With a provider, a `401` (and a `403` `insufficient_scope` challenge) runs the auth flow first, and whatever escapes it reaches you as thrown, identity intact — the transport stamps errors at its auth seams (the `token()` read, `onUnauthorized` including custom callbacks, step-up), so `UnauthorizedError` for `finishAuth()`, the flow's typed failures (`OAuthError`, `InsufficientScopeError`, the 401-after-re-authentication diagnostic), and even an untyped crash inside a callback all propagate unchanged. A `5xx` rejecting the probe is a server failure, not era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)` naming the status. These status-keyed rows read the transport's typed HTTP rejections — the SDK's Streamable HTTP transport surfaces them with the status attached; the legacy SSE client transport reports a non-2xx POST as a generic error, so over SSE a probe rejection surfaces as the generic `Version negotiation probe failed` connect error instead. Auth settles first, era second: a `401` never decides the era — the auth wall answers before the MCP layer ever sees `server/discover` — and the post-auth re-probe supplies the real era evidence. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d9e6e7824d..b422d2ced1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -74,6 +74,7 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan - `the server gave no modern evidence and this client supports no pre-2026-07-28 protocol version to fall back to` — or its `the connection closed during the server/discover probe and this client supports no ...` variant — `mode: 'auto'` with a modern-only `supportedProtocolVersions` list removes the legacy fallback: restore a pre-2026 entry. - `the connection closed during the server/discover probe (this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)` — a subclass of `StdioClientTransport`, or a custom stdio-shaped transport, probed in place and met a server that exits on any pre-`initialize` request: use the base `StdioClientTransport` (which probes on a disposable sibling), or `mode: 'legacy'`. - `the transport was closed during the server/discover probe` — the caller closed the transport while the probe was in flight; the connect aborted deliberately and the session child was never spawned. +- `Version negotiation probe failed: the server answered with an unusable reply (...)` — the server, or something in front of it, answered the probe with a `2xx` that carries no usable reply (an empty or non-JSON body, a `204`, a missing or unexpected content type). That is not era evidence, so no legacy fallback is attempted: check the URL and what sits in front of the server. To connect to a 2025 server behind such a front, pass `connect(transport, { prior: { kind: 'legacy' } })` or use `mode: 'legacy'`. - `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry. - `the server answered the probe with HTTP 5xx` — the server or a proxy in front of it failed (mid-deploy, crashed backend); not era evidence, so no legacy fallback is attempted: retry once the deployment is healthy. diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts index f73d57b393..8753f8e694 100644 --- a/packages/client/src/client/probeClassifier.ts +++ b/packages/client/src/client/probeClassifier.ts @@ -51,6 +51,8 @@ export type ProbeOutcome = /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text and `statusText` the HTTP reason phrase, when available. */ | { kind: 'http-error'; status: number; body?: string; statusText?: string } | { kind: 'network-error'; error: unknown } + /** The probe was answered 2xx without a usable reply (body is not JSON, or an unaccepted media type); `error` is the transport's failure. */ + | { kind: 'unusable-reply'; error: unknown } /** The transport's auth flow challenged or failed during the probe send — an error stamped at a transport auth seam, or an `UnauthorizedError` (the foreign-transport contract). `error` propagates unchanged. */ | { kind: 'auth-required'; error: Error } /** The transport reported close while the probe awaited its reply. */ @@ -139,6 +141,18 @@ export function classifyProbeOutcome(outcome: ProbeOutcome, context: ProbeClassi case 'network-error': { return classifyNetworkError(outcome.error, context); } + case 'unusable-reply': { + // An unreadable 2xx is not era evidence: reject and say what was answered, never fall back. + return { + kind: 'error', + error: new SdkError( + SdkErrorCode.EraNegotiationFailed, + `Version negotiation probe failed: the server answered with an unusable reply (${describeError(outcome.error)})`, + { cause: outcome.error }, + { cause: outcome.error } + ) + }; + } case 'auth-required': { // Not era evidence: propagate the auth challenge unchanged so the // caller can run finishAuth() and reconnect — the reconnect probes diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 9b89d962ba..52d8bf3f00 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -375,6 +375,15 @@ export function buildProbeRequest( }; } +/** True for what the transport throws when the probe was answered 2xx without a usable reply. */ +function isUnusableReplyError(error: unknown): boolean { + if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpUnexpectedContent) { + return true; + } + // By name, not `instanceof`: an injected `fetch` can produce a SyntaxError from another realm. + return error instanceof Error && error.name === 'SyntaxError'; +} + function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { switch (reply.kind) { case 'response': { @@ -409,6 +418,9 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome { statusText: error.data.statusText }; } + if (isUnusableReplyError(error)) { + return { kind: 'unusable-reply', error }; + } return { kind: 'network-error', error }; } case 'closed': { diff --git a/packages/client/test/client/probeClassifier.test.ts b/packages/client/test/client/probeClassifier.test.ts index ccc1efd54a..52dd23a59d 100644 --- a/packages/client/test/client/probeClassifier.test.ts +++ b/packages/client/test/client/probeClassifier.test.ts @@ -346,3 +346,40 @@ describe('row: browser opaque CORS/preflight TypeError, PROBE PHASE ONLY → leg expect(verdict.kind).toBe('error'); }); }); + +describe('row: unusable-reply — the HTTP layer answered 2xx with an unusable body → typed connect error', () => { + const jsonSyntaxError = () => { + try { + JSON.parse(''); + throw new Error('unreachable'); + } catch (error) { + return error; + } + }; + + test.each([true, false])('an empty JSON body is never an era verdict (fallbackAvailable: %s)', fallbackAvailable => { + const cause = jsonSyntaxError(); + const verdict = classify({ kind: 'unusable-reply', error: cause }, { fallbackAvailable }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect(verdict.error).toBeInstanceOf(SdkError); + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect(verdict.error.message).toContain('the server answered with an unusable reply'); + expect((verdict.error as SdkError).data).toMatchObject({ cause }); + expect(verdict.error.cause).toBe(cause); + } + }); + + test('an unaccepted media type (bare 204, text/plain) is never an era verdict', () => { + const cause = new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'Unexpected content type: text/plain', { + contentType: 'text/plain' + }); + const verdict = classify({ kind: 'unusable-reply', error: cause }); + expect(verdict.kind).toBe('error'); + if (verdict.kind === 'error') { + expect((verdict.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect(verdict.error.message).toContain('the server answered with an unusable reply (Unexpected content type: text/plain)'); + expect(verdict.error.cause).toBe(cause); + } + }); +}); diff --git a/packages/client/test/client/probeUnusable2xx.wire.test.ts b/packages/client/test/client/probeUnusable2xx.wire.test.ts new file mode 100644 index 0000000000..659b552260 --- /dev/null +++ b/packages/client/test/client/probeUnusable2xx.wire.test.ts @@ -0,0 +1,156 @@ +// #2619: the real StreamableHTTPClientTransport against a fetch stub that plays a gateway in front of a 2025 server. +import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal'; +import { describe, expect, test } from 'vitest'; + +import { Client } from '../../src/client/client'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp'; + +const JSON_CT = { 'content-type': 'application/json' }; +const UNUSABLE = 'the server answered with an unusable reply'; + +function gatewayInFrontOfLegacy(probeAnswer: () => Response | Promise) { + const seen: string[] = []; + const fetchStub = async (_input: Parameters[0], init?: Parameters[1]): Promise => { + if (init?.method !== 'POST') return new Response(null, { status: 405 }); + const message = JSON.parse(String(init.body)) as { id?: unknown; method?: string }; + seen.push(message.method ?? '(response)'); + if (message.method === 'server/discover') return probeAnswer(); + if (message.method === 'initialize') { + return Response.json( + { + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: '2025-11-25', + capabilities: {}, + serverInfo: { name: 'legacy-behind-gateway', version: '1.0.0' } + } + }, + { status: 200, headers: JSON_CT } + ); + } + return new Response(null, { status: 202 }); + }; + return { fetchStub: fetchStub as typeof fetch, seen }; +} + +async function connect( + options: ConstructorParameters[1], + probeAnswer: () => Response | Promise, + connectOptions?: Parameters[1] +) { + const { fetchStub, seen } = gatewayInFrontOfLegacy(probeAnswer); + const client = new Client({ name: 'repro-2619', version: '1.0.0' }, options); + const transport = new StreamableHTTPClientTransport(new URL('http://gateway.invalid/mcp'), { fetch: fetchStub }); + const outcome = await client.connect(transport, connectOptions).then( + () => 'connected' as const, + (error: unknown) => error + ); + const era = outcome === 'connected' ? client.getProtocolEra() : undefined; + const server = outcome === 'connected' ? client.getServerVersion()?.name : undefined; + await client.close().catch(() => {}); + return { outcome, era, server, seen }; +} + +const AUTO = { versionNegotiation: { mode: 'auto' as const } }; +const emptyJson = () => new Response('', { status: 200, headers: JSON_CT }); + +describe('#2619: the four rows of the issue table (real transport, injected fetch)', () => { + const rows: Array<[string, () => Response, string]> = [ + ['200, application/json, empty body', emptyJson, 'SyntaxError'], + ['200, application/json, whitespace body', () => new Response(' \n', { status: 200, headers: JSON_CT }), 'SyntaxError'], + ['204, no content-type', () => new Response(null, { status: 204 }), 'SdkError'], + ['200, text/plain', () => new Response('OK', { status: 200, headers: { 'content-type': 'text/plain' } }), 'SdkError'] + ]; + + test.each(rows)('%s -> connect() rejects, says what was answered, and never sends initialize', async (_label, probeAnswer, cause) => { + const { outcome, seen } = await connect(AUTO, probeAnswer); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).toContain(UNUSABLE); + expect(((outcome as Error).cause as Error | undefined)?.name).toBe(cause); + expect(seen).toEqual(['server/discover']); + }); +}); + +describe('#2619: the way out the message points to', () => { + test('a known-legacy verdict skips the probe and connects', async () => { + const { outcome, era, server, seen } = await connect(AUTO, emptyJson, { prior: { kind: 'legacy' } }); + expect(outcome).toBe('connected'); + expect(era).toBe('legacy'); + expect(server).toBe('legacy-behind-gateway'); + expect(seen.slice(0, 2)).toEqual(['initialize', 'notifications/initialized']); + }); + + test('the default mode sends no probe and connects', async () => { + const { outcome, era, seen } = await connect(undefined, emptyJson); + expect(outcome).toBe('connected'); + expect(era).toBe('legacy'); + expect(seen).not.toContain('server/discover'); + }); +}); + +describe('#2619: what must not move', () => { + test('control from the issue: a 400 with an unrecognized body already falls back', async () => { + const { outcome, era } = await connect(AUTO, () => new Response('Bad Request', { status: 400 })); + expect(outcome).toBe('connected'); + expect(era).toBe('legacy'); + }); + + test('a network failure on the probe keeps its own message, and initialize is never sent', async () => { + const { outcome, seen } = await connect(AUTO, () => Promise.reject(new TypeError('fetch failed'))); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).not.toContain(UNUSABLE); + expect(seen).toEqual(['server/discover']); + }); + + test('pin mode on an empty 200 rejects with the same message and keeps Error.cause', async () => { + const { outcome, seen } = await connect({ versionNegotiation: { mode: { pin: '2026-07-28' } } }, emptyJson); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).toContain(UNUSABLE); + expect(((outcome as Error).cause as Error | undefined)?.name).toBe('SyntaxError'); + expect(seen).toEqual(['server/discover']); + }); + + test('a modern-only client on an empty 200 rejects with the same message and keeps Error.cause', async () => { + const { outcome, seen } = await connect( + { versionNegotiation: { mode: 'auto' }, supportedProtocolVersions: ['2026-07-28'] }, + emptyJson + ); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).toContain(UNUSABLE); + expect(((outcome as Error).cause as Error | undefined)?.name).toBe('SyntaxError'); + expect(seen).toEqual(['server/discover']); + }); + + test('a 401 on the probe stays an auth failure', async () => { + const { outcome, seen } = await connect(AUTO, () => new Response('Unauthorized', { status: 401 })); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect(seen).toEqual(['server/discover']); + }); +}); + +describe('#2619: when every request is answered the same way', () => { + test('connect() rejects after exactly one request, typed', async () => { + const seen: string[] = []; + const fetchStub = (async (_input: Parameters[0], init?: Parameters[1]) => { + seen.push((JSON.parse(String(init?.body)) as { method?: string }).method ?? '(response)'); + return new Response('sign in', { status: 200, headers: { 'content-type': 'text/html' } }); + }) as typeof fetch; + const client = new Client({ name: 'repro-2619', version: '1.0.0' }, AUTO); + const transport = new StreamableHTTPClientTransport(new URL('http://gateway.invalid/mcp'), { fetch: fetchStub }); + const outcome = await client.connect(transport).then( + () => 'connected' as const, + (error: unknown) => error + ); + await client.close().catch(() => {}); + expect(outcome).toBeInstanceOf(SdkError); + expect((outcome as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((outcome as SdkError).message).toContain(UNUSABLE); + expect(seen).toEqual(['server/discover']); + }); +}); diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts index 60c9a2aaba..ed87a774c3 100644 --- a/packages/client/test/client/versionNegotiation.test.ts +++ b/packages/client/test/client/versionNegotiation.test.ts @@ -1390,3 +1390,81 @@ describe('probe window preserves pre-set transport handlers', () => { await client.close(); }); }); + +// A probe answered 2xx without a usable reply is not era evidence: connect() rejects and says so. +describe('probe unusable-reply classification', () => { + /** Rejects the probe send with `probeError`, then serves legacy initialize. */ + class UnusableReplyTransport extends ScriptedTransport { + constructor(private readonly probeError: Error) { + super(legacyServerScript); + } + + override async send(message: JSONRPCMessage): Promise { + if (isJSONRPCRequest(message) && message.method === 'server/discover') { + throw this.probeError; + } + await super.send(message); + } + } + + const emptyJsonBody = (): Error => { + try { + JSON.parse(''); + throw new Error('unreachable'); + } catch (error) { + return error as Error; + } + }; + const unusableReplies: Array<[string, () => Error]> = [ + ['an empty 2xx body parsed as application/json (a gateway swallowing the probe)', emptyJsonBody], + [ + 'a bare 204 / text-plain answer the transport does not accept', + () => + new SdkError(SdkErrorCode.ClientHttpUnexpectedContent, 'Unexpected content type: text/plain', { + contentType: 'text/plain' + }) + ] + ]; + const rejectionOf = (connecting: Promise) => + connecting.then( + () => { + throw new Error('connect unexpectedly resolved'); + }, + (error: unknown) => error + ); + + test.each(unusableReplies)('%s rejects connect() with its own message and never sends initialize', async (_label, makeError) => { + const transport = new UnusableReplyTransport(makeError()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const rejection = await rejectionOf(client.connect(transport)); + + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).toContain('the server answered with an unusable reply'); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); + }); + + test('a known-legacy verdict skips the probe, so the same server connects', async () => { + const transport = new UnusableReplyTransport(emptyJsonBody()); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + await client.connect(transport, { prior: { kind: 'legacy' } }); + + expect(requests(transport.sent).some(r => r.method === 'server/discover')).toBe(false); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(true); + await client.close(); + }); + + test('a genuine network failure keeps its own message (not reported as an unusable reply)', async () => { + const transport = new UnusableReplyTransport(new TypeError('fetch failed')); + const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } }); + + const rejection = await rejectionOf(client.connect(transport)); + + expect(rejection).toBeInstanceOf(SdkError); + expect((rejection as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed); + expect((rejection as SdkError).message).not.toContain('unusable reply'); + expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false); + }); +}); From 9350fe0dc91693bd1de8e2d1810859597f179205 Mon Sep 17 00:00:00 2001 From: squarepots <46488165+squarepots@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:22:40 +0800 Subject: [PATCH 20/44] fix(spec): freeze 2026-07-28 release references (#2858) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .github/workflows/update-spec-types.yml | 95 ------------------- REVIEW.md | 4 +- packages/core-internal/src/types/README.md | 12 +-- .../src/types/spec.types.2026-07-28.ts | 29 ++++-- .../src/wire/rev2026-07-28/buildSchemas.ts | 6 +- .../src/wire/rev2026-07-28/schemas.ts | 1 + .../listen-closed-response.json | 10 ++ .../corpus/fixtures/2026-07-28/manifest.json | 11 ++- .../schema-twins/2026-07-28.schema.json | 25 ++++- .../test/corpus/schema-twins/manifest.json | 13 +-- .../test/corpus/specCorpus.test.ts | 16 ++-- .../test/spec.types.2026-07-28.test.ts | 11 ++- .../test/wire/schemaTwinConformance.test.ts | 5 +- packages/server/src/server/listenRouter.ts | 4 +- packages/server/src/server/server.ts | 2 +- .../server/createMcpHandlerListen.test.ts | 2 +- .../test/server/serveStdioListen.test.ts | 2 +- scripts/fetch-schema-twins.ts | 36 +++++-- scripts/fetch-spec-examples.ts | 31 +++--- scripts/fetch-spec-types.ts | 23 ++--- 20 files changed, 152 insertions(+), 186 deletions(-) delete mode 100644 .github/workflows/update-spec-types.yml create mode 100644 packages/core-internal/test/corpus/fixtures/2026-07-28/SubscriptionsListenResultResponse/listen-closed-response.json diff --git a/.github/workflows/update-spec-types.yml b/.github/workflows/update-spec-types.yml deleted file mode 100644 index c62aca8907..0000000000 --- a/.github/workflows/update-spec-types.yml +++ /dev/null @@ -1,95 +0,0 @@ -# Nightly refresh of the draft-tracking spec anchor (2026-07-28). -# -# Anchor lifecycle (see packages/core-internal/src/types/README.md for the full policy): -# - Draft anchors float: this job regenerates the draft-tracking anchor from the -# latest upstream draft schema and, on drift, opens a refresh PR for review. -# It only ever proposes — it never merges. -# - Released anchors are frozen: generation for released revisions is pinned in -# scripts/fetch-spec-types.ts (RELEASED_REVISION_PINS) and is not refreshed by -# this job. Repinning a released revision — including the freeze of a newly -# published revision, when its schema moves out of schema/draft/ — must land -# in the same commit that retargets this workflow. -name: Update Spec Types - -on: - schedule: - # Run nightly at 4 AM UTC - - cron: '0 4 * * *' - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -jobs: - update-spec-types: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v7 - - - name: Install pnpm - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - id: pnpm-install - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 24 - cache: pnpm - cache-dependency-path: pnpm-lock.yaml - - - name: Install dependencies - run: pnpm install - - - name: Fetch latest spec types - run: pnpm run fetch:spec-types 2026-07-28 - - - name: Check for changes - id: check_changes - run: | - if git diff --quiet packages/core-internal/src/types/spec.types.2026-07-28.ts; then - echo "has_changes=false" >> $GITHUB_OUTPUT - else - echo "has_changes=true" >> $GITHUB_OUTPUT - LATEST_SHA=$(grep "Last updated from commit:" packages/core-internal/src/types/spec.types.2026-07-28.ts | cut -d: -f2 | tr -d ' ') - echo "sha=$LATEST_SHA" >> $GITHUB_OUTPUT - fi - - - name: Create Pull Request - if: steps.check_changes.outputs.has_changes == 'true' - env: - GH_TOKEN: ${{ github.token }} - # Skip lefthook pre-push (typecheck/lint/build); spec drift that breaks - # typecheck should still open a PR so it can be fixed there. - LEFTHOOK: 0 - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git checkout -B update-spec-types - git add packages/core-internal/src/types/spec.types.2026-07-28.ts - git commit -m "chore: update spec.types.2026-07-28.ts from upstream" - git push -f --no-verify origin update-spec-types - - # Create PR if it doesn't exist, or update if it does - PR_BODY="This PR updates \`packages/core-internal/src/types/spec.types.2026-07-28.ts\` from the Model Context Protocol specification. - - Source file: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/${{ steps.check_changes.outputs.sha }}/schema/draft/schema.ts - - This is an automated update triggered by the nightly cron job." - - # `gh pr view ` matches closed PRs too, so check for an *open* PR explicitly. - EXISTING_PR=$(gh pr list --head update-spec-types --state open --json number --jq '.[0].number // empty') - if [ -n "$EXISTING_PR" ]; then - echo "PR #$EXISTING_PR already exists, updating description..." - gh pr edit "$EXISTING_PR" --body "$PR_BODY" - else - gh pr create \ - --title "chore: update spec.types.2026-07-28.ts from upstream" \ - --body "$PR_BODY" \ - --base main \ - --head update-spec-types - fi diff --git a/REVIEW.md b/REVIEW.md index 9047acb1c2..65b4c35935 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -32,7 +32,7 @@ review rounds and grows over time. ## Checklist **Protocol & spec** -- Types match [`schema.ts`](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) exactly (optional vs required fields) +- Types match the schema for the protocol revision being changed (`schema//schema.ts` for released revisions; `schema/draft/schema.ts` only for unreleased work) - Correct `ProtocolError` codes (enum `ProtocolErrorCode`); HTTP status codes match spec (e.g., 404 vs 410) - Works for both stdio and Streamable HTTP transports — no transport-specific assumptions - Cross-SDK consistency: check what `python-sdk` does for the same feature @@ -61,7 +61,7 @@ When verifying spec compliance, consult the spec directly rather than relying on - MCP documentation server: `https://modelcontextprotocol.io/mcp` - Full spec text (single file, LLM-friendly): `https://modelcontextprotocol.io/llms-full.txt` — fetch to a temp file and grep for the relevant section -- Schema source of truth: [`schema.ts`](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/schema/draft/schema.ts) +- Schema source of truth: the revision-matched `schema.ts` in `modelcontextprotocol/modelcontextprotocol` (`schema//schema.ts` for released revisions; `schema/draft/schema.ts` only for unreleased work) ## Recurring Catches diff --git a/packages/core-internal/src/types/README.md b/packages/core-internal/src/types/README.md index fda450e8e9..c0aba1bd43 100644 --- a/packages/core-internal/src/types/README.md +++ b/packages/core-internal/src/types/README.md @@ -8,11 +8,9 @@ They are reference-only test oracles: the comparison suites in `packages/core-in ## Lifecycle policy 1. **Released revisions are frozen.** Once a protocol revision is published under `schema//` in the spec repository, its anchor regenerates only from the pinned spec commit recorded in `RELEASED_REVISION_PINS` (`scripts/fetch-spec-types.ts`) — never from the latest - upstream commit. Moving that pin, including the freeze of a newly published revision (when its generation source switches from `schema/draft/` to `schema//`), must land in the same commit that retargets the nightly update workflow - (`.github/workflows/update-spec-types.yml`), so the anchor and the automation that maintains it can never disagree about the source of truth. + upstream commit. Moving that pin, including the freeze of a newly published revision (when its generation source switches from `schema/draft/` to `schema//`), must land atomically with every checked-in oracle derived from that revision. -2. **Draft anchors float only via reviewed refresh PRs.** The anchor for an unreleased revision tracks the spec repository's `schema/draft/schema.ts`. The nightly workflow regenerates it from the latest upstream commit and, when the result differs from what is checked in, opens - (or updates) a refresh PR. Manual refreshes follow the same path: regenerate, then propose the diff in a PR. +2. **Draft anchors float only while they have an explicit consumer.** An unreleased revision may track the spec repository's `schema/draft/schema.ts`, but refreshes must go through reviewed PRs and update all derived oracles atomically. No currently supported revision uses this mode. 3. **The bot proposes; it never auto-merges.** Automated refreshes always go through a pull request that a maintainer reviews and merges. No automation pushes anchor changes directly to `main` or merges its own PRs. A refresh PR that breaks the comparison suites is the desired signal — it is fixed in that PR, not bypassed. @@ -20,7 +18,7 @@ They are reference-only test oracles: the comparison suites in `packages/core-in 4. **Generated twins update atomically with their anchor.** If artifacts derived from an anchor (for example vendored JSON schemas or generated validators) are checked into this repository, any refresh that changes the anchor must regenerate those artifacts in the same commit. The anchor and its derived twins must never be out of sync at any commit on `main`. - **This clause is OPERATIVE.** The vendored twins are the per-revision `schema.json` copies under `packages/core-internal/test/corpus/schema-twins/` (`.schema.json` + `manifest.json` recording the source commit and content hashes). They are TEST-ONLY oracles consumed by the - schema-twin conformance lock (`test/wire/schemaTwinConformance.test.ts`) — never bundled, never imported by runtime code, and the JSON Schema engines stay optional peer dependencies. A refresh of `spec.types..ts` must copy the matching upstream - `schema//schema.json` (same spec commit) over the twin and update `manifest.json` in the same commit; the spec example corpus manifest (`test/corpus/fixtures//manifest.json`) records its own source commit and follows the same atomicity rule when the examples + **This clause is OPERATIVE.** The vendored twins are the per-revision `schema.json` copies under `packages/core-internal/test/corpus/schema-twins/` (`.schema.json` + `manifest.json` recording each twin's source commit and content hashes). They are TEST-ONLY oracles consumed by the + schema-twin conformance lock (`test/wire/schemaTwinConformance.test.ts`) — never bundled, never imported by runtime code, and the JSON Schema engines stay optional peer dependencies. A refresh of `spec.types..ts` must refresh the matching upstream + `schema//schema.json` from that twin's recorded source commit and update `manifest.json` in the same change; the spec example corpus manifest (`test/corpus/fixtures//manifest.json`) records its own source commit and follows the same atomicity rule when the examples are re-vendored. The conformance lock failing after an anchor-only refresh is the desired loud signal of a missed twin update. diff --git a/packages/core-internal/src/types/spec.types.2026-07-28.ts b/packages/core-internal/src/types/spec.types.2026-07-28.ts index f4430b850f..36ce3b043d 100644 --- a/packages/core-internal/src/types/spec.types.2026-07-28.ts +++ b/packages/core-internal/src/types/spec.types.2026-07-28.ts @@ -2,8 +2,8 @@ * This file is automatically generated from the Model Context Protocol specification. * * Source: https://github.com/modelcontextprotocol/modelcontextprotocol - * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/draft/schema.ts - * Last updated from commit: 71e306956a4959c9655e5036be215d41986596e6 + * Pulled from: https://raw.githubusercontent.com/modelcontextprotocol/modelcontextprotocol/main/schema/2026-07-28/schema.ts + * Last updated from commit: 271ecc9accafdd9b83a3c869fa67c22953b2af80 * * DO NOT EDIT THIS FILE MANUALLY. Changes will be overwritten by automated updates. * To update this file, run: pnpm run fetch:spec-types 2026-07-28 @@ -55,7 +55,7 @@ export const JSONRPC_VERSION = '2.0'; * - Unless empty, MUST start and end with an alphanumeric character (`[a-z0-9A-Z]`). * - Interior characters may be alphanumeric, hyphens (`-`), underscores (`_`), or dots (`.`). * - * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @see [General fields: `_meta`](/specification/2026-07-28/basic/index#meta) for more details. * @category Common Types */ export type MetaObject = Record; @@ -64,7 +64,7 @@ export type MetaObject = Record; * Extends {@link MetaObject} with additional request-specific fields. All key naming rules from `MetaObject` apply. * * @see {@link MetaObject} for key naming rules and reserved prefixes. - * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @see [General fields: `_meta`](/specification/2026-07-28/basic/index#meta) for more details. * @category Common Types */ export interface RequestMetaObject extends MetaObject { @@ -121,7 +121,7 @@ export interface RequestMetaObject extends MetaObject { * Extends {@link MetaObject} with additional notification-specific fields. All key naming rules from `MetaObject` apply. * * @see {@link MetaObject} for key naming rules and reserved prefixes. - * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @see [General fields: `_meta`](/specification/2026-07-28/basic/index#meta) for more details. * @category Common Types */ export interface NotificationMetaObject extends MetaObject { @@ -144,7 +144,7 @@ export interface NotificationMetaObject extends MetaObject { * Extends {@link MetaObject} with additional result-specific fields. All key naming rules from `MetaObject` apply. * * @see {@link MetaObject} for key naming rules and reserved prefixes. - * @see [General fields: `_meta`](/specification/draft/basic/index#meta) for more details. + * @see [General fields: `_meta`](/specification/2026-07-28/basic/index#meta) for more details. * @category Common Types */ export interface ResultMetaObject extends MetaObject { @@ -1317,7 +1317,7 @@ export interface SubscriptionsListenRequest extends JSONRPCRequest { * @see {@link MetaObject} for key naming rules and reserved prefixes. * @category `subscriptions/listen` */ -export interface SubscriptionsListenResultMeta extends ResultMetaObject { +export interface SubscriptionsListenResultMetaObject extends ResultMetaObject { /** * Identifies the subscription stream this response closes, so the client can * correlate it with the originating subscription — mirroring the same key on @@ -1341,7 +1341,20 @@ export interface SubscriptionsListenResultMeta extends ResultMetaObject { * @category `subscriptions/listen` */ export interface SubscriptionsListenResult extends Result { - _meta: SubscriptionsListenResultMeta; + _meta: SubscriptionsListenResultMetaObject; +} + +/** + * A successful response from the server for a {@link SubscriptionsListenRequest | subscriptions/listen} + * request, sent when the server tears the subscription down gracefully. + * + * @example Subscription closed gracefully response + * {@includeCode ./examples/SubscriptionsListenResultResponse/listen-closed-response.json} + * + * @category `subscriptions/listen` + */ +export interface SubscriptionsListenResultResponse extends JSONRPCResultResponse { + result: SubscriptionsListenResult; } /** diff --git a/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts b/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts index 6831062fd3..f286b3cffa 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/buildSchemas.ts @@ -1070,7 +1070,7 @@ function build() { const SubscriptionsListenRequestSchema = wireRequest('subscriptions/listen', subscriptionsListenParamsShape); /** - * Anchor SubscriptionsListenResultMeta — required subscriptionId stamp on + * Anchor SubscriptionsListenResultMetaObject — required subscriptionId stamp on * the graceful-close result. Extends `ResultMetaObject` since spec PR * #3002 (composed, so the serverInfo key and its leniency stay single-sourced). */ @@ -1276,6 +1276,7 @@ function build() { const ReadResourceResultResponseSchema = wireResultResponse(z.union([ReadResourceResultSchema, InputRequiredResultSchema])); const CompleteResultResponseSchema = wireResultResponse(CompleteResultSchema); const DiscoverResultResponseSchema = wireResultResponse(DiscoverResultSchema); + const SubscriptionsListenResultResponseSchema = wireResultResponse(SubscriptionsListenResultSchema); return { JSONValueSchema, @@ -1411,7 +1412,8 @@ function build() { ListResourceTemplatesResultResponseSchema, ReadResourceResultResponseSchema, CompleteResultResponseSchema, - DiscoverResultResponseSchema + DiscoverResultResponseSchema, + SubscriptionsListenResultResponseSchema }; } diff --git a/packages/core-internal/src/wire/rev2026-07-28/schemas.ts b/packages/core-internal/src/wire/rev2026-07-28/schemas.ts index b342ded914..d1ff969736 100644 --- a/packages/core-internal/src/wire/rev2026-07-28/schemas.ts +++ b/packages/core-internal/src/wire/rev2026-07-28/schemas.ts @@ -156,3 +156,4 @@ export const ListResourceTemplatesResultResponseSchema = s.ListResourceTemplates export const ReadResourceResultResponseSchema = s.ReadResourceResultResponseSchema; export const CompleteResultResponseSchema = s.CompleteResultResponseSchema; export const DiscoverResultResponseSchema = s.DiscoverResultResponseSchema; +export const SubscriptionsListenResultResponseSchema = s.SubscriptionsListenResultResponseSchema; diff --git a/packages/core-internal/test/corpus/fixtures/2026-07-28/SubscriptionsListenResultResponse/listen-closed-response.json b/packages/core-internal/test/corpus/fixtures/2026-07-28/SubscriptionsListenResultResponse/listen-closed-response.json new file mode 100644 index 0000000000..9441979a42 --- /dev/null +++ b/packages/core-internal/test/corpus/fixtures/2026-07-28/SubscriptionsListenResultResponse/listen-closed-response.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "id": "listen-1", + "result": { + "resultType": "complete", + "_meta": { + "io.modelcontextprotocol/subscriptionId": "listen-1" + } + } +} diff --git a/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json b/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json index eeeae659cd..b47980fe67 100644 --- a/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json +++ b/packages/core-internal/test/corpus/fixtures/2026-07-28/manifest.json @@ -2,12 +2,12 @@ "revision": "2026-07-28", "source": { "repo": "modelcontextprotocol/modelcontextprotocol", - "path": "schema/draft/examples", - "commit": "71e306956a4959c9655e5036be215d41986596e6" + "path": "schema/2026-07-28/examples", + "commit": "271ecc9accafdd9b83a3c869fa67c22953b2af80" }, "regenerate": "pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub", - "directoryCount": 87, - "fileCount": 128, + "directoryCount": 88, + "fileCount": 129, "directories": { "AudioContent": [ "audio-wav-content.json" @@ -273,6 +273,9 @@ "SubscriptionsListenResult": [ "listen-closed.json" ], + "SubscriptionsListenResultResponse": [ + "listen-closed-response.json" + ], "TextContent": [ "text-content.json" ], diff --git a/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json b/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json index cc44564e33..213c58f6d9 100644 --- a/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json +++ b/packages/core-internal/test/corpus/schema-twins/2026-07-28.schema.json @@ -3400,7 +3400,7 @@ "description": "The response to a {@link SubscriptionsListenRequestsubscriptions/listen}\nrequest, signalling that the subscription has ended gracefully (for example,\nduring server shutdown). Because the listen stream is long-lived, this result\nis sent only when the server tears the subscription down; an abrupt transport\nclose carries no response. The result body is otherwise empty.", "properties": { "_meta": { - "$ref": "#/$defs/SubscriptionsListenResultMeta" + "$ref": "#/$defs/SubscriptionsListenResultMetaObject" }, "resultType": { "description": "Indicates the type of the result, which allows the client to determine\nhow to parse the result object.\n\nServers implementing this protocol version MUST include this field.\nFor backward compatibility, when a client receives a result from a\nserver implementing an earlier protocol version (which does not include\n`resultType`), the client MUST treat the absent field as `\"complete\"`.", @@ -3413,7 +3413,7 @@ ], "type": "object" }, - "SubscriptionsListenResultMeta": { + "SubscriptionsListenResultMetaObject": { "description": "Extends {@link ResultMetaObject} with the subscription-stream identifier carried by a\n{@link SubscriptionsListenResult}. All key naming rules from `MetaObject` apply.", "properties": { "io.modelcontextprotocol/serverInfo": { @@ -3430,6 +3430,27 @@ ], "type": "object" }, + "SubscriptionsListenResultResponse": { + "description": "A successful response from the server for a {@link SubscriptionsListenRequestsubscriptions/listen}\nrequest, sent when the server tears the subscription down gracefully.", + "properties": { + "id": { + "$ref": "#/$defs/RequestId" + }, + "jsonrpc": { + "const": "2.0", + "type": "string" + }, + "result": { + "$ref": "#/$defs/SubscriptionsListenResult" + } + }, + "required": [ + "id", + "jsonrpc", + "result" + ], + "type": "object" + }, "TextContent": { "description": "Text provided to or from an LLM.", "properties": { diff --git a/packages/core-internal/test/corpus/schema-twins/manifest.json b/packages/core-internal/test/corpus/schema-twins/manifest.json index 9047083294..b2f097ce2c 100644 --- a/packages/core-internal/test/corpus/schema-twins/manifest.json +++ b/packages/core-internal/test/corpus/schema-twins/manifest.json @@ -1,16 +1,17 @@ { - "comment": "Vendored schema.json twins (TEST-ONLY conformance oracles; never bundled, never runtime). RAW upstream bytes - never reformat: each file is locked to the sha256/bytes below by schemaTwinConformance. Refresh via `pnpm fetch:schema-twins [sha]`, ATOMICALLY with the matching spec.types anchor (see packages/core-internal/src/types/README.md lifecycle rule 4).", + "comment": "Vendored schema.json twins (TEST-ONLY conformance oracles; never bundled, never runtime). RAW upstream bytes - never reformat: each file is locked to its sourceCommit/sha256/bytes by schemaTwinConformance. Refresh via `pnpm fetch:schema-twins [sha]`; with no SHA each twin uses its recorded sourceCommit.", "source": { - "repository": "modelcontextprotocol/modelcontextprotocol", - "commit": "71e306956a4959c9655e5036be215d41986596e6" + "repository": "modelcontextprotocol/modelcontextprotocol" }, "files": { "2026-07-28": { - "sha256": "9281c4890630e2d1e61792fa23b4084c4ea360cd58519610cd050545ab7b8708", - "bytes": 180695, - "upstreamPath": "schema/draft/schema.json" + "sourceCommit": "271ecc9accafdd9b83a3c869fa67c22953b2af80", + "sha256": "ef70b61f99b6d2e5e3b46863822eab08dff6a45bedc7a08914e0e5b133f40203", + "bytes": 181474, + "upstreamPath": "schema/2026-07-28/schema.json" }, "2025-11-25": { + "sourceCommit": "71e306956a4959c9655e5036be215d41986596e6", "sha256": "7b2d96fd95efd2216aa953606b83f5a740ddeaa5ebd3a5d27b45a8296545a118", "bytes": 174326, "upstreamPath": "schema/2025-11-25/schema.json" diff --git a/packages/core-internal/test/corpus/specCorpus.test.ts b/packages/core-internal/test/corpus/specCorpus.test.ts index 64a5bd1fb2..5c8a0393fa 100644 --- a/packages/core-internal/test/corpus/specCorpus.test.ts +++ b/packages/core-internal/test/corpus/specCorpus.test.ts @@ -3,8 +3,8 @@ * * Two corpora, one harness: * - * - `fixtures/2026-07-28/` is VENDORED from the spec repository's draft - * example set (`schema/draft/examples/`), regenerated only via + * - `fixtures/2026-07-28/` is VENDORED from the released spec repository + * example set (`schema/2026-07-28/examples/`), regenerated only via * `pnpm fetch:spec-examples` (provenance in its manifest.json). Every * example directory is named after a spec type; each file is a canonical * instance of that type. @@ -59,7 +59,7 @@ const ERROR_OBJECT_DIRS = new Set([ ]); /** - * Draft (2026-07-28) vocabulary the SDK does not model yet, at directory + * 2026-07-28 vocabulary the SDK does not model yet, at directory * granularity. Each entry names the reason; the harness asserts the schema is * genuinely absent so a stale entry (vocabulary landed but still listed) * fails loudly. These burn down as the corresponding features land. @@ -72,7 +72,7 @@ const PENDING_2026: Record = { /** * Individual draft examples whose vocabulary the SDK does not accept yet * (file granularity — the directory's schema exists but this instance uses a - * draft-only widening). Stale-checked: each listed file must actually FAIL to + * revision-specific widening). Stale-checked: each listed file must actually FAIL to * parse, so the entry is removed the moment the widening lands. */ const PENDING_2026_FILES: Record = { @@ -172,12 +172,14 @@ describe('corpus inventory pins', () => { test('the vendored 2026-07-28 corpus matches its manifest (provenance + drift pin)', () => { const manifest = JSON.parse(readFileSync(join(FIXTURES_ROOT, '2026-07-28', 'manifest.json'), 'utf8')) as { revision: string; - source: { commit: string }; + source: { path: string; commit: string }; directoryCount: number; fileCount: number; directories: Record; }; expect(manifest.revision).toBe('2026-07-28'); + expect(manifest.source.path).toBe('schema/2026-07-28/examples'); + expect(manifest.source.commit).toBe('271ecc9accafdd9b83a3c869fa67c22953b2af80'); const dirs = listTypeDirs('2026-07-28'); expect(dirs).toEqual(Object.keys(manifest.directories).sort()); @@ -186,8 +188,8 @@ describe('corpus inventory pins', () => { // The corpus size at the pinned spec commit. A change here means the // vendored corpus was regenerated — review the delta deliberately. - expect(manifest.directoryCount).toBe(87); - expect(manifest.fileCount).toBe(128); + expect(manifest.directoryCount).toBe(88); + expect(manifest.fileCount).toBe(129); }); test('the frozen 2025-11-25 corpus keeps its inventory', () => { diff --git a/packages/core-internal/test/spec.types.2026-07-28.test.ts b/packages/core-internal/test/spec.types.2026-07-28.test.ts index 4bdc3d11fc..be1aa51eab 100644 --- a/packages/core-internal/test/spec.types.2026-07-28.test.ts +++ b/packages/core-internal/test/spec.types.2026-07-28.test.ts @@ -91,6 +91,7 @@ type WSubscriptionsAcknowledgedNotification = z4.infer; type WSubscriptionsListenResultMeta = z4.infer; +type WSubscriptionsListenResultResponse = z4.infer; // The anchor's ClientRequest union, composed from the era module's wire requests. type WClientRequest = | WCompleteRequest @@ -804,7 +805,11 @@ const wireParityChecks = { sdk = spec; spec = sdk; }, - SubscriptionsListenResultMeta: (sdk: WSubscriptionsListenResultMeta, spec: SpecTypes.SubscriptionsListenResultMeta) => { + SubscriptionsListenResultMetaObject: (sdk: WSubscriptionsListenResultMeta, spec: SpecTypes.SubscriptionsListenResultMetaObject) => { + sdk = spec; + spec = sdk; + }, + SubscriptionsListenResultResponse: (sdk: WSubscriptionsListenResultResponse, spec: SpecTypes.SubscriptionsListenResultResponse) => { sdk = spec; spec = sdk; }, @@ -874,8 +879,10 @@ describe('Spec Types (2026-07-28)', () => { expect(specTypes).toContain('InputRequiredResult'); expect(specTypes).toContain('SubscriptionsListenRequest'); expect(specTypes).toContain('SubscriptionsListenResult'); + expect(specTypes).toContain('SubscriptionsListenResultMetaObject'); + expect(specTypes).toContain('SubscriptionsListenResultResponse'); expect(specTypes).toContain('ResultMetaObject'); - expect(specTypes).toHaveLength(154); + expect(specTypes).toHaveLength(155); }); it('should only allowlist types that exist in the 2026-07-28 schema', () => { diff --git a/packages/core-internal/test/wire/schemaTwinConformance.test.ts b/packages/core-internal/test/wire/schemaTwinConformance.test.ts index 6d7945a425..05c4433c5a 100644 --- a/packages/core-internal/test/wire/schemaTwinConformance.test.ts +++ b/packages/core-internal/test/wire/schemaTwinConformance.test.ts @@ -32,8 +32,8 @@ const FIXTURES_ROOT = join(__dirname, '../corpus/fixtures'); const TWINS_ROOT = join(__dirname, '../corpus/schema-twins'); interface TwinManifest { - source: { repository: string; commit: string }; - files: Record; + source: { repository: string }; + files: Record; } const TWIN_MANIFEST = JSON.parse(readFileSync(join(TWINS_ROOT, 'manifest.json'), 'utf8')) as TwinManifest; @@ -47,6 +47,7 @@ describe('twin provenance integrity (the manifest lock)', () => { // fetched bytes), atomically with the matching spec.types anchor. test.each(Object.keys(TWIN_MANIFEST.files))('%s twin is byte-identical to the upstream artifact pinned in the manifest', revision => { const entry = TWIN_MANIFEST.files[revision]!; + expect(entry.sourceCommit).toMatch(/^[0-9a-f]{40}$/); const raw = readFileSync(join(TWINS_ROOT, `${revision}.schema.json`)); expect(raw.byteLength, `byte size drifted for ${revision} — the vendored twin was rewritten`).toBe(entry.bytes); expect( diff --git a/packages/server/src/server/listenRouter.ts b/packages/server/src/server/listenRouter.ts index 32ae8f26df..e58807d5c1 100644 --- a/packages/server/src/server/listenRouter.ts +++ b/packages/server/src/server/listenRouter.ts @@ -105,7 +105,7 @@ export interface ListenRouter { * against what the serving instance advertises (honoring a filter without * capabilities would fail open and deliver unadvertised types). * `serverInfo` is the serving instance's identity, stamped onto the - * graceful-close result's `_meta` (the spec's `SubscriptionsListenResultMeta` + * graceful-close result's `_meta` (the spec's `SubscriptionsListenResultMetaObject` * extends `ResultMetaObject`, so the serverInfo SHOULD applies there too). */ serve(message: JSONRPCRequest, signal: AbortSignal | undefined, capabilities: ServerCapabilities, serverInfo: Implementation): Response; @@ -311,7 +311,7 @@ export class StdioListenRouter { private _serverCapabilities: ServerCapabilities | undefined; /** * The serving instance's identity, stamped onto the graceful-close - * results' `_meta` (the spec's `SubscriptionsListenResultMeta` extends + * results' `_meta` (the spec's `SubscriptionsListenResultMetaObject` extends * `ResultMetaObject`). Handed over together with the capabilities. */ private _serverInfo: Implementation | undefined; diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 5de0d8919c..2eee0b9ff4 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -244,7 +244,7 @@ export function installModernOnlyHandlers(server: Server, servedModernVersions: * Package-internal: the instance's implementation identity, for the serving * entries to stamp onto entry-built results (the `subscriptions/listen` * graceful-close result — built outside the encode seam, but the spec's - * `SubscriptionsListenResultMeta` extends `ResultMetaObject`, so it carries + * `SubscriptionsListenResultMetaObject` extends `ResultMetaObject`, so it carries * the serverInfo SHOULD like every other result). Not public API. */ export function serverIdentityOf(server: Server): Implementation { diff --git a/packages/server/test/server/createMcpHandlerListen.test.ts b/packages/server/test/server/createMcpHandlerListen.test.ts index 392887f0b3..579e61d854 100644 --- a/packages/server/test/server/createMcpHandlerListen.test.ts +++ b/packages/server/test/server/createMcpHandlerListen.test.ts @@ -256,7 +256,7 @@ describe('createMcpHandler — subscriptions/listen', () => { _meta: { 'io.modelcontextprotocol/subscriptionId': 1, // #3002: the close result carries the serving instance's - // identity like every other result (SubscriptionsListenResultMeta + // identity like every other result (SubscriptionsListenResultMetaObject // extends ResultMetaObject). 'io.modelcontextprotocol/serverInfo': { name: 'listen-test-server', version: '1.0.0' } } diff --git a/packages/server/test/server/serveStdioListen.test.ts b/packages/server/test/server/serveStdioListen.test.ts index 44deb64396..c084f87169 100644 --- a/packages/server/test/server/serveStdioListen.test.ts +++ b/packages/server/test/server/serveStdioListen.test.ts @@ -122,7 +122,7 @@ describe('serveStdio — subscriptions/listen', () => { const results = inbound.filter(m => 'result' in m) as { id: unknown; result: unknown }[]; expect(results.map(m => m.id)).toEqual(['s1', 's2']); // #3002: each close result carries the serving instance's identity - // (SubscriptionsListenResultMeta extends ResultMetaObject). + // (SubscriptionsListenResultMetaObject extends ResultMetaObject). const serverInfo = { name: 's', version: '1' }; expect(results.map(m => m.result)).toEqual([ { resultType: 'complete', _meta: { [SUBSCRIPTION_ID_META_KEY]: 's1', 'io.modelcontextprotocol/serverInfo': serverInfo } }, diff --git a/scripts/fetch-schema-twins.ts b/scripts/fetch-schema-twins.ts index d693f45905..b13ffecdce 100644 --- a/scripts/fetch-schema-twins.ts +++ b/scripts/fetch-schema-twins.ts @@ -7,18 +7,21 @@ * generated validators and locks the hand-written wire layer to them. Their * authority rests on provenance, so they are vendored verbatim — no * formatting of any kind (the directory is .prettierignore'd) — and each file - * is locked to the manifest's sha256/byte values at test time. Any rewrite - * (prettier, an editor, a manual touch-up) turns CI red. + * is locked to the manifest's source commit, sha256, and byte count. + * + * Each revision records its own source commit. Released revision directories + * can continue to change upstream, so freezing one revision must not move a + * different revision's oracle. * * Refresh ATOMICALLY with the matching spec.types anchor (see * packages/core-internal/src/types/README.md lifecycle rule 4). * * Usage: - * pnpm fetch:schema-twins [sha] # default: the manifest's current source commit + * pnpm fetch:schema-twins [sha] # default: each manifest entry's current source commit * - * Sources are fetched from GitHub at the given commit, mirroring - * scripts/fetch-spec-types.ts; the manifest's provenance values (source - * commit, sha256, byte size) are recomputed from the fetched bytes. + * Passing a SHA preserves the previous CLI behavior and repins all twins to + * that repository snapshot. With no SHA, each twin regenerates from its own + * recorded source commit. */ import { createHash } from 'node:crypto'; @@ -33,10 +36,17 @@ const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol'; const TWINS_DIR = join(PROJECT_ROOT, 'packages', 'core-internal', 'test', 'corpus', 'schema-twins'); const MANIFEST_PATH = join(TWINS_DIR, 'manifest.json'); +interface TwinEntry { + sourceCommit: string; + sha256: string; + bytes: number; + upstreamPath: string; +} + interface TwinManifest { comment: string; - source: { repository: string; commit: string }; - files: Record; + source: { repository: string }; + files: Record; } async function fetchRawBytes(sha: string, upstreamPath: string): Promise { @@ -50,19 +60,25 @@ async function fetchRawBytes(sha: string, upstreamPath: string): Promise async function main(): Promise { const manifest = JSON.parse(readFileSync(MANIFEST_PATH, 'utf8')) as TwinManifest; - const sha = process.argv[2] ?? manifest.source.commit; + if (manifest.source.repository !== SPEC_REPO) { + throw new Error(`Unexpected schema-twin source repository: ${manifest.source.repository}`); + } + + const providedSHA = process.argv[2]; for (const [revision, entry] of Object.entries(manifest.files)) { + const sha = providedSHA ?? entry.sourceCommit; console.log(`[${revision}] Fetching ${entry.upstreamPath} at ${sha}`); const bytes = await fetchRawBytes(sha, entry.upstreamPath); + // Verbatim: the twin IS the upstream artifact, byte for byte. writeFileSync(join(TWINS_DIR, `${revision}.schema.json`), bytes); + entry.sourceCommit = sha; entry.sha256 = createHash('sha256').update(bytes).digest('hex'); entry.bytes = bytes.byteLength; console.log(`[${revision}] ${entry.bytes} bytes, sha256 ${entry.sha256}`); } - manifest.source = { repository: SPEC_REPO, commit: sha }; writeFileSync(MANIFEST_PATH, `${JSON.stringify(manifest, null, 4)}\n`, 'utf8'); console.log(`Updated ${MANIFEST_PATH}`); } diff --git a/scripts/fetch-spec-examples.ts b/scripts/fetch-spec-examples.ts index bc279551d9..005627b4c8 100644 --- a/scripts/fetch-spec-examples.ts +++ b/scripts/fetch-spec-examples.ts @@ -1,9 +1,9 @@ /** - * Vendors the draft-revision (2026-07-28) example corpus from the spec - * repository into `packages/core-internal/test/corpus/fixtures/2026-07-28/`. + * Vendors the released 2026-07-28 example corpus from the spec repository + * into `packages/core-internal/test/corpus/fixtures/2026-07-28/`. * - * The spec repository ships canonical example instances for the draft schema - * (`schema/draft/examples//*.json`). The corpus harness + * The spec repository ships canonical example instances for the released schema + * (`schema/2026-07-28/examples//*.json`). The corpus harness * (`packages/core-internal/test/corpus/specCorpus.test.ts`) parses every vendored * example through the SDK's wire schemas, so accept-side drift between the * SDK and the specification turns CI red. @@ -13,7 +13,7 @@ * * Usage: * pnpm fetch:spec-examples --spec-dir - * pnpm fetch:spec-examples [sha] # fetch from GitHub (default: latest main) + * pnpm fetch:spec-examples [sha] # fetch from GitHub (default: released pin) * * With `--spec-dir`, examples are read from a local checkout of * modelcontextprotocol/modelcontextprotocol (provenance is the checkout's @@ -30,10 +30,10 @@ const __filename = fileURLToPath(import.meta.url); const PROJECT_ROOT = join(dirname(__filename), '..'); const SPEC_REPO = 'modelcontextprotocol/modelcontextprotocol'; -/** The upcoming protocol revision; its examples live in the spec repo's draft directory. */ -const DRAFT_REVISION = '2026-07-28'; -const EXAMPLES_PATH = 'schema/draft/examples'; -const OUTPUT_DIR = join(PROJECT_ROOT, 'packages', 'core-internal', 'test', 'corpus', 'fixtures', DRAFT_REVISION); +const REVISION = '2026-07-28'; +const RELEASED_REVISION_PIN = '271ecc9accafdd9b83a3c869fa67c22953b2af80'; +const EXAMPLES_PATH = `schema/${REVISION}/examples`; +const OUTPUT_DIR = join(PROJECT_ROOT, 'packages', 'core-internal', 'test', 'corpus', 'fixtures', REVISION); interface ExampleFile { /** `/.json` relative to the examples root. */ @@ -41,15 +41,6 @@ interface ExampleFile { content: string; } -async function fetchLatestSHA(): Promise { - const url = `https://api.github.com/repos/${SPEC_REPO}/commits?path=${EXAMPLES_PATH}&per_page=1`; - const response = await fetch(url); - if (!response.ok) throw new Error(`Failed to fetch commit info: ${response.status} ${response.statusText}`); - const commits = (await response.json()) as Array<{ sha: string }>; - if (!commits?.length) throw new Error('No commits found for the examples path'); - return commits[0].sha; -} - async function listExamplesFromGitHub(sha: string): Promise { const url = `https://api.github.com/repos/${SPEC_REPO}/git/trees/${sha}?recursive=1`; const response = await fetch(url); @@ -115,7 +106,7 @@ function writeCorpus(files: ExampleFile[], sha: string): void { } const manifest = { - revision: DRAFT_REVISION, + revision: REVISION, source: { repo: SPEC_REPO, path: EXAMPLES_PATH, commit: sha }, regenerate: 'pnpm fetch:spec-examples --spec-dir # or [sha] to fetch from GitHub', directoryCount: Object.keys(dirs).length, @@ -139,7 +130,7 @@ async function main(): Promise { return; } - const sha = args[0] ?? (await fetchLatestSHA()); + const sha = args[0] ?? RELEASED_REVISION_PIN; const files = await fetchExamplesFromGitHub(sha); writeCorpus(files, sha); } diff --git a/scripts/fetch-spec-types.ts b/scripts/fetch-spec-types.ts index c4fc9818ec..06b426f23d 100644 --- a/scripts/fetch-spec-types.ts +++ b/scripts/fetch-spec-types.ts @@ -10,38 +10,33 @@ const PROJECT_ROOT = join(__dirname, '..'); /** * The protocol revisions the SDK keeps reference types for: * - `2025-11-25`: the frozen, released schema. - * - `2026-07-28`: the upcoming protocol revision. + * - `2026-07-28`: the frozen, released schema. * * Each is written to `packages/core-internal/src/types/spec.types..ts`. */ const SUPPORTED_VERSIONS = ['2025-11-25', '2026-07-28'] as const; type SpecVersion = (typeof SUPPORTED_VERSIONS)[number]; -/** - * Upstream schema directory per revision. Until the 2026-07-28 revision is - * published, its schema lives in the spec repository's draft directory; this - * mapping drops once `schema/2026-07-28/` exists upstream. - */ +/** Upstream schema directory for each supported protocol revision. */ const UPSTREAM_SCHEMA_DIRS: Record = { '2025-11-25': '2025-11-25', - '2026-07-28': 'draft' + '2026-07-28': '2026-07-28' }; /** * Generation pin per released revision. Released revisions are frozen: without * an explicit SHA argument, their types are regenerated from the pinned spec * commit below — never from the latest upstream commit — so a released anchor - * can only change through a deliberate, reviewed repin. Moving a pin (or - * freezing a newly released revision) must land in the same commit that - * retargets `.github/workflows/update-spec-types.yml`. + * can only change through a deliberate, reviewed repin. * - * Draft-tracking revisions have no entry and float to the latest upstream - * commit via the nightly workflow's refresh PRs. + * Draft-tracking revisions have no entry and may float to the latest upstream + * commit only while the SDK has an explicit consumer for that unreleased revision. * * See `packages/core-internal/src/types/README.md` for the full lifecycle policy. */ const RELEASED_REVISION_PINS: Partial> = { - '2025-11-25': '0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65' + '2025-11-25': '0168c57fc74aba6e6dcf8f0b7191db3caaa5ad65', + '2026-07-28': '271ecc9accafdd9b83a3c869fa67c22953b2af80' }; interface GitHubCommit { @@ -127,7 +122,7 @@ function isSupportedVersion(value: string): value is SpecVersion { async function main() { try { // Usage: fetch-spec-types.ts [version] [sha] - // With no version, all supported versions are fetched at their latest upstream SHA. + // With no version, all supported versions are regenerated from their configured authority. const providedVersion = process.argv[2]; const providedSHA = process.argv[3]; From aca8ba39400fdbd714144086b143fc0dc74ad6b3 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:31:59 +0100 Subject: [PATCH 21/44] docs(client): scope the probe unusable-reply wording (#2904) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/probe-unusable-2xx-reply-message.md | 5 +++-- docs/migration/support-2026-07-28.md | 2 +- docs/protocol-versions.md | 2 +- docs/troubleshooting.md | 2 +- packages/client/src/client/versionNegotiation.ts | 2 +- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.changeset/probe-unusable-2xx-reply-message.md b/.changeset/probe-unusable-2xx-reply-message.md index d96e183ec8..13309768ff 100644 --- a/.changeset/probe-unusable-2xx-reply-message.md +++ b/.changeset/probe-unusable-2xx-reply-message.md @@ -2,7 +2,8 @@ '@modelcontextprotocol/client': patch --- -With `versionNegotiation` in `'auto'` or pin mode, a `server/discover` probe answered with a 2xx that carries no usable reply (an empty or -non-JSON body, a `204`, a missing or unexpected content type) still rejects `connect()` with `EraNegotiationFailed`. The message now says +With `versionNegotiation` in `'auto'` or pin mode, a `server/discover` probe answered with a 2xx that carries no usable reply (a body that +is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type) still rejects `connect()` with +`EraNegotiationFailed`; an empty SSE stream or a `202` surfaces as the probe timeout instead. The message now says `the server answered with an unusable reply (...)` instead of reading like a network failure. To connect to a 2025 server behind a front that answers the probe this way, pass `connect(transport, { prior: { kind: 'legacy' } })` or use `mode: 'legacy'`. diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 7c83465749..c7b087a3ef 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -79,7 +79,7 @@ falling back — see [Protocol versions](../protocol-versions.md). This holds ev a front that answers the probe `403` but would pass `initialize`: relying on a legacy fallback there is a deliberate non-goal — fix the auth wall (or pass credentials), because auth status never selects an era. A `5xx` on the probe is a server failure, -also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. A `2xx` probe answer that carries no usable reply (an empty or non-JSON body, a `204`, a missing or unexpected content type) is not era evidence either: `connect()` rejects with `SdkError(EraNegotiationFailed)`. Probe timeouts are **transport-aware**: on **stdio** a server that does not +also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. A `2xx` probe answer that carries no usable reply (a body that is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type) is not era evidence either: `connect()` rejects with `SdkError(EraNegotiationFailed)`; an empty SSE stream or a `202` surfaces as the probe timeout instead. Probe timeouts are **transport-aware**: on **stdio** a server that does not answer within `timeoutMs` is treated as legacy and the client falls back to `initialize` (some legacy servers never respond to unknown pre-`initialize` requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` — diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 5972385c42..02f65efd60 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -99,7 +99,7 @@ const cli = new Client( ); ``` -A `2xx` answer that carries no usable reply (an empty or non-JSON body, a `204`, a missing or unexpected content type) is not era evidence: `connect()` rejects with `SdkError(EraNegotiationFailed)` and the message says `the server answered with an unusable reply`. To connect to a 2025 server behind a front that answers the probe this way, pass `connect(transport, { prior: { kind: 'legacy' } })`. +A `2xx` answer that carries no usable reply (a body that is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type) is not era evidence: `connect()` rejects with `SdkError(EraNegotiationFailed)` and the message says `the server answered with an unusable reply`. An empty SSE stream or a `202` surfaces as the probe timeout instead. To connect to a 2025 server behind a front that answers the probe this way, pass `connect(transport, { prior: { kind: 'legacy' } })`. A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize`; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b422d2ced1..f17adf26e6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -74,7 +74,7 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan - `the server gave no modern evidence and this client supports no pre-2026-07-28 protocol version to fall back to` — or its `the connection closed during the server/discover probe and this client supports no ...` variant — `mode: 'auto'` with a modern-only `supportedProtocolVersions` list removes the legacy fallback: restore a pre-2026 entry. - `the connection closed during the server/discover probe (this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)` — a subclass of `StdioClientTransport`, or a custom stdio-shaped transport, probed in place and met a server that exits on any pre-`initialize` request: use the base `StdioClientTransport` (which probes on a disposable sibling), or `mode: 'legacy'`. - `the transport was closed during the server/discover probe` — the caller closed the transport while the probe was in flight; the connect aborted deliberately and the session child was never spawned. -- `Version negotiation probe failed: the server answered with an unusable reply (...)` — the server, or something in front of it, answered the probe with a `2xx` that carries no usable reply (an empty or non-JSON body, a `204`, a missing or unexpected content type). That is not era evidence, so no legacy fallback is attempted: check the URL and what sits in front of the server. To connect to a 2025 server behind such a front, pass `connect(transport, { prior: { kind: 'legacy' } })` or use `mode: 'legacy'`. +- `Version negotiation probe failed: the server answered with an unusable reply (...)` — the server, or something in front of it, answered the probe with a `2xx` that carries no usable reply (a body that is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type; an empty SSE stream or a `202` surfaces as the probe timeout instead). That is not era evidence, so no legacy fallback is attempted: check the URL and what sits in front of the server. To connect to a 2025 server behind such a front, pass `connect(transport, { prior: { kind: 'legacy' } })` or use `mode: 'legacy'`. - `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry. - `the server answered the probe with HTTP 5xx` — the server or a proxy in front of it failed (mid-deploy, crashed backend); not era evidence, so no legacy fallback is attempted: retry once the deployment is healthy. diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts index 52d8bf3f00..5dfced8c7f 100644 --- a/packages/client/src/client/versionNegotiation.ts +++ b/packages/client/src/client/versionNegotiation.ts @@ -380,7 +380,7 @@ function isUnusableReplyError(error: unknown): boolean { if (error instanceof SdkError && error.code === SdkErrorCode.ClientHttpUnexpectedContent) { return true; } - // By name, not `instanceof`: an injected `fetch` can produce a SyntaxError from another realm. + // Matched by name: this is what response.json() throws for a body that is not JSON. return error instanceof Error && error.name === 'SyntaxError'; } From c0cd01a21d867e57b29d7216416b25bf36898292 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 14:53:34 +0100 Subject: [PATCH 22/44] fix(client): retry the SSE connection once after onUnauthorized (#2905) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Co-authored-by: Claude --- .changeset/sse-connect-retry-once.md | 5 ++ packages/client/src/client/sse.ts | 19 +++++- packages/client/test/client/sse.test.ts | 82 +++++++++++++++++++++---- 3 files changed, 92 insertions(+), 14 deletions(-) create mode 100644 .changeset/sse-connect-retry-once.md diff --git a/.changeset/sse-connect-retry-once.md b/.changeset/sse-connect-retry-once.md new file mode 100644 index 0000000000..cb90c3aa39 --- /dev/null +++ b/.changeset/sse-connect-retry-once.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +`SSEClientTransport` now retries the SSE connection once after `onUnauthorized()` resolves, as documented. If the retry is also answered with 401, `start()` rejects with `SdkHttpError` (`ClientHttpAuthentication`) instead of calling `onUnauthorized()` again. A 401 on a later reconnect of a stream that had opened still gets one refresh. diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index e0b320b107..e249636ed2 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -175,6 +175,8 @@ export class SSEClientTransport implements Transport { } private _last401Response?: Response; + // True between a 401-triggered reconnect and the next successful open. + private _connectAuthRetried = false; private async _commonHeaders(): Promise { // Start from the caller-supplied `requestInit.headers` and `set()` the @@ -239,9 +241,10 @@ export class SSEClientTransport implements Transport { this._eventSource.onerror = event => { if (event.code === 401 && this._authProvider) { - if (this._authProvider.onUnauthorized && this._last401Response) { + if (this._authProvider.onUnauthorized && this._last401Response && !this._connectAuthRetried) { const response = this._last401Response; this._last401Response = undefined; + this._connectAuthRetried = true; this._eventSource?.close(); this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then( // onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject. @@ -250,6 +253,7 @@ export class SSEClientTransport implements Transport { // stamp: covers the SDK's OAuth flow and custom // callbacks alike. (error: unknown) => { + this._connectAuthRetried = false; markAuthSeamEscape(error); this.onerror?.(error as Error); reject(error); @@ -257,7 +261,17 @@ export class SSEClientTransport implements Transport { ); return; } - const error = markAuthSeamEscape(new UnauthorizedError()); + const retried = this._connectAuthRetried; + this._connectAuthRetried = false; + const error = markAuthSeamEscape( + retried + ? new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', { + status: 401, + statusText: this._last401Response?.statusText ?? '' + }) + : new UnauthorizedError() + ); + this._last401Response = undefined; reject(error); this.onerror?.(error); return; @@ -270,6 +284,7 @@ export class SSEClientTransport implements Transport { this._eventSource.onopen = () => { // The connection is open, but we need to wait for the endpoint to be received. + this._connectAuthRetried = false; }; this._eventSource.addEventListener('endpoint', (event: Event) => { diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index 6952af8f76..8b4a8edae8 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -1783,10 +1783,43 @@ describe('SSEClientTransport', () => { await expect(transport.finishAuth('auth-code')).rejects.toThrow('finishAuth requires an OAuthClientProvider'); }); - it('SSE connect 401 retry does not poison future 401s — onUnauthorized called on each attempt', async () => { + it('SSE connect: a second 401 right after a refresh rejects, onUnauthorized called once', async () => { + await resourceServer.close(); + + let getAttempt = 0; + resourceServer = createServer((req, res) => { + if (req.method === 'GET') { + getAttempt++; + res.writeHead(401).end(); // always 401 + } + }); + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + // Backstop so an unbounded retry ends the test with a clear failure instead of a hang. + let calls = 0; + const authProvider: AuthProvider = { + token: vi.fn(async () => 'still-bad'), + onUnauthorized: vi.fn(async () => { + if (++calls >= 10) throw new Error('backstop: onUnauthorized called 10 times'); + }) + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + const onerror = vi.fn(); + transport.onerror = onerror; + + const error = await transport.start().catch(e => e); + expect(error).toBeInstanceOf(SdkHttpError); + expect((error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication); + expect((error as SdkHttpError).status).toBe(401); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + expect(getAttempt).toBe(2); + expect(onerror).toHaveBeenCalledTimes(1); + }); + + it('SSE connect 401 retry does not poison future 401s — a 401 on a later reconnect refreshes again', async () => { // Regression: _startOrAuth(true) baked isAuthRetry=true into the retry EventSource's // onerror closure, so a subsequent 401 (token expiry on reconnect) would throw - // instead of refreshing. Fix: retry always calls _startOrAuth() fresh. + // instead of refreshing. The retry guard resets once the stream opens. await resourceServer.close(); let getAttempt = 0; @@ -1796,7 +1829,8 @@ describe('SSEClientTransport', () => { return; } getAttempt++; - if (getAttempt < 3) { + // 1: 401, 2: opens then drops, 3: 401 on the automatic reconnect, 4: opens and stays. + if (getAttempt === 1 || getAttempt === 3) { res.writeHead(401).end(); return; } @@ -1805,8 +1839,12 @@ describe('SSEClientTransport', () => { 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' }); + res.write('retry: 10\n'); res.write('event: endpoint\n'); res.write(`data: ${resourceBaseUrl.href}post\n\n`); + if (getAttempt === 2) { + res.end(); + } }); resourceBaseUrl = await listenOnRandomPort(resourceServer); @@ -1816,17 +1854,17 @@ describe('SSEClientTransport', () => { }; transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); - await transport.start(); // should resolve on attempt 3 + await transport.start(); // resolves on attempt 2 + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(getAttempt).toBe(4), { timeout: 3000 }); expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); - expect(getAttempt).toBe(3); }); it('retry failure during SSE connect fires onerror exactly once', async () => { // Regression: when the retry EventSource rejected, its onerror fired inside, then // the outer .then() rejection handler fired onerror AGAIN for the same error. // Fix: inner retry chains to .then(resolve, reject) — no outer onerror call. - // onUnauthorized's own failure is handled separately and fires onerror once. await resourceServer.close(); resourceServer = createServer((req, res) => { @@ -1836,20 +1874,40 @@ describe('SSEClientTransport', () => { }); resourceBaseUrl = await listenOnRandomPort(resourceServer); - const onUnauthorized: AuthProvider['onUnauthorized'] = vi - .fn() - .mockResolvedValueOnce(undefined) // first call succeeds → triggers retry - .mockRejectedValueOnce(new Error('refresh failed')); // second call (in retry) throws const authProvider: AuthProvider = { token: vi.fn(async () => 'token'), - onUnauthorized + onUnauthorized: vi.fn(async () => {}) + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + const onerror = vi.fn(); + transport.onerror = onerror; + + await expect(transport.start()).rejects.toThrow('Server returned 401 after re-authentication'); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onerror.mock.calls[0]![0].message).toBe('Server returned 401 after re-authentication'); + }); + + it('a failing onUnauthorized during SSE connect fires onerror exactly once', async () => { + await resourceServer.close(); + + resourceServer = createServer((req, res) => { + if (req.method === 'GET') { + res.writeHead(401).end(); // always 401 + } + }); + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + const authProvider: AuthProvider = { + token: vi.fn(async () => 'token'), + onUnauthorized: vi.fn().mockRejectedValueOnce(new Error('refresh failed')) }; transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); const onerror = vi.fn(); transport.onerror = onerror; await expect(transport.start()).rejects.toThrow('refresh failed'); - expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); expect(onerror).toHaveBeenCalledTimes(1); expect(onerror.mock.calls[0]![0].message).toBe('refresh failed'); }); From 376ff90eba34c569a98f7fabecc75704291269e8 Mon Sep 17 00:00:00 2001 From: Sravan Vadla <44364763+sravan-v@users.noreply.github.com> Date: Wed, 30 Sep 2026 07:01:33 -0700 Subject: [PATCH 23/44] docs: validate input-required elicitation examples (#2756) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- docs/servers/input-required.md | 21 ++++++++++++++----- .../guides/servers/input-required.examples.ts | 21 ++++++++++++++----- examples/mrtr/server.ts | 6 +++--- .../core-internal/src/shared/inputRequired.ts | 7 ++++--- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/docs/servers/input-required.md b/docs/servers/input-required.md index 1fc42383f1..d17731d43c 100644 --- a/docs/servers/input-required.md +++ b/docs/servers/input-required.md @@ -174,6 +174,9 @@ Sampling and roots are deprecated as of protocol revision 2026-07-28 (SEP-2577) To run rounds in sequence, return an opaque `requestState` string alongside the requests. The client echoes it back byte-for-byte on the retry, and `ctx.mcpReq.requestState()` reads its decoded payload on re-entry. Mint it with the codec from the next section. ```ts source="../../examples/guides/servers/input-required.examples.ts#requestState_mint" +const cacheConfirmationSchema = z.object({ confirm: z.boolean() }); +const cacheScopeSchema = z.object({ scope: z.string() }); + server.registerTool( 'wipe-cache', { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, @@ -181,13 +184,13 @@ server.registerTool( const state = ctx.mcpReq.requestState<{ step: string }>(); if (state?.step !== 'confirmed') { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', cacheConfirmationSchema); if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: 'Really wipe the cache?', - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + requestedSchema: cacheConfirmationSchema }) } }); @@ -197,15 +200,23 @@ server.registerTool( inputRequests: { scope: inputRequired.elicit({ message: 'Which scope?', - requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } + requestedSchema: cacheScopeSchema }) }, requestState: await stateCodec.mint({ step: 'confirmed' }) }); } - const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); - return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; + const scope = acceptedContent(ctx.mcpReq.inputResponses, 'scope', cacheScopeSchema); + if (scope === undefined) { + return inputRequired({ + inputRequests: { + scope: inputRequired.elicit({ message: 'Which scope?', requestedSchema: cacheScopeSchema }) + }, + requestState: await stateCodec.mint({ step: 'confirmed' }) + }); + } + return { content: [{ type: 'text', text: `Wiped ${scope.scope}` }] }; } ); ``` diff --git a/examples/guides/servers/input-required.examples.ts b/examples/guides/servers/input-required.examples.ts index 8b3755ca17..e7702ba34f 100644 --- a/examples/guides/servers/input-required.examples.ts +++ b/examples/guides/servers/input-required.examples.ts @@ -125,6 +125,9 @@ server.registerTool( // "Carry state across rounds with `requestState`" — two sequential rounds. //#region requestState_mint +const cacheConfirmationSchema = z.object({ confirm: z.boolean() }); +const cacheScopeSchema = z.object({ scope: z.string() }); + server.registerTool( 'wipe-cache', { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, @@ -132,13 +135,13 @@ server.registerTool( const state = ctx.mcpReq.requestState<{ step: string }>(); if (state?.step !== 'confirmed') { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', cacheConfirmationSchema); if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: 'Really wipe the cache?', - requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + requestedSchema: cacheConfirmationSchema }) } }); @@ -148,15 +151,23 @@ server.registerTool( inputRequests: { scope: inputRequired.elicit({ message: 'Which scope?', - requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } + requestedSchema: cacheScopeSchema }) }, requestState: await stateCodec.mint({ step: 'confirmed' }) }); } - const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); - return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; + const scope = acceptedContent(ctx.mcpReq.inputResponses, 'scope', cacheScopeSchema); + if (scope === undefined) { + return inputRequired({ + inputRequests: { + scope: inputRequired.elicit({ message: 'Which scope?', requestedSchema: cacheScopeSchema }) + }, + requestState: await stateCodec.mint({ step: 'confirmed' }) + }); + } + return { content: [{ type: 'text', text: `Wiped ${scope.scope}` }] }; } ); //#endregion requestState_mint diff --git a/examples/mrtr/server.ts b/examples/mrtr/server.ts index 1f89fe236d..2eaa9b7bd1 100644 --- a/examples/mrtr/server.ts +++ b/examples/mrtr/server.ts @@ -30,7 +30,7 @@ import { acceptedContent, createMcpHandler, createRequestStateCodec, inputRequir import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; -const CONFIRM_SCHEMA = { type: 'object' as const, properties: { confirm: { type: 'boolean' as const } }, required: ['confirm'] }; +const CONFIRM_SCHEMA = z.object({ confirm: z.boolean() }); type DeployState = { step: 'confirm' | 'signed-in'; env: string }; @@ -67,8 +67,8 @@ function buildServer(): McpServer { console.error(`[server] tools/call deploy(${env}) step=${step}`); if (step === 'confirm') { - const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); - if (!confirmed?.confirm) { + const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', CONFIRM_SCHEMA); + if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: CONFIRM_SCHEMA }) diff --git a/packages/core-internal/src/shared/inputRequired.ts b/packages/core-internal/src/shared/inputRequired.ts index b2736e23b6..bf518e39b0 100644 --- a/packages/core-internal/src/shared/inputRequired.ts +++ b/packages/core-internal/src/shared/inputRequired.ts @@ -113,13 +113,14 @@ function buildInputRequired(spec: InputRequiredSpec): InputRequiredResult { * @example Write-once tool requesting confirmation * ```ts * server.registerTool('deploy', { inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx) => { - * const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); - * if (!confirmed) { + * const confirmationSchema = z.object({ confirm: z.boolean() }); + * const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema); + * if (confirmed?.confirm !== true) { * return inputRequired({ * inputRequests: { * confirm: inputRequired.elicit({ * message: `Deploy to ${env}?`, - * requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } + * requestedSchema: confirmationSchema * }) * } * }); From e55f9ac1b1cae413599b499cbaa45c0378edba78 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 15:37:15 +0100 Subject: [PATCH 24/44] feat(server): accept ://* entries in allowedOrigins (#2907) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> Co-authored-by: Claude --- .changeset/origin-scheme-entries.md | 5 ++++ docs/serving/express.md | 2 +- docs/serving/fastify.md | 2 +- docs/serving/hono.md | 2 +- docs/serving/web-standard.md | 2 +- packages/middleware/express/src/express.ts | 5 ++++ .../src/middleware/originValidation.ts | 2 +- .../express/test/originValidation.test.ts | 20 +++++++++++++ packages/middleware/fastify/src/fastify.ts | 5 ++++ .../src/middleware/originValidation.ts | 2 +- .../fastify/test/originValidation.test.ts | 14 ++++++++++ packages/middleware/hono/src/hono.ts | 5 ++++ .../hono/test/originValidation.test.ts | 14 ++++++++++ .../node/src/middleware/originValidation.ts | 2 +- .../src/server/middleware/originValidation.ts | 13 +++++++-- .../test/server/originValidation.test.ts | 28 +++++++++++++++++++ 16 files changed, 114 insertions(+), 9 deletions(-) create mode 100644 .changeset/origin-scheme-entries.md diff --git a/.changeset/origin-scheme-entries.md b/.changeset/origin-scheme-entries.md new file mode 100644 index 0000000000..2f3513dc0a --- /dev/null +++ b/.changeset/origin-scheme-entries.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +`allowedOrigins` and `validateOriginHeader` accept lowercase entries of the form `://*`, such as `moz-extension://*` or `chrome-extension://*`, which admit every origin of that scheme. This lets a server admit MCP clients that run as a browser extension when the extension ID cannot be listed, as on Firefox, where it differs on every install. `http://*` and `https://*` are not honoured, and the defaults are unchanged. diff --git a/docs/serving/express.md b/docs/serving/express.md index 1d04189a97..f634ac918a 100644 --- a/docs/serving/express.md +++ b/docs/serving/express.md @@ -42,7 +42,7 @@ Binding to all interfaces drops that default — name the hosts you serve instea const publicApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); ``` -`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. +`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. Passing `allowedOrigins` replaces the default list, so start from `localhostAllowedOrigins()` (from `@modelcontextprotocol/server`) to keep it. A client that runs as a browser extension sends its extension ID as `Origin`: add that ID to admit one extension, or a lowercase scheme entry such as `'moz-extension://*'` to admit every extension of that browser, which Firefox needs because its IDs differ on every install. `http://*` and `https://*` are not honoured. ## Forward auth and the parsed body diff --git a/docs/serving/fastify.md b/docs/serving/fastify.md index 1643ec4d48..aebbb7d52d 100644 --- a/docs/serving/fastify.md +++ b/docs/serving/fastify.md @@ -42,7 +42,7 @@ Binding to all interfaces drops that default — name the hosts you serve instea const publicApp = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); ``` -`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. +`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. Passing `allowedOrigins` replaces the default list, so start from `localhostAllowedOrigins()` (from `@modelcontextprotocol/server`) to keep it. A client that runs as a browser extension sends its extension ID as `Origin`: add that ID to admit one extension, or a lowercase scheme entry such as `'moz-extension://*'` to admit every extension of that browser, which Firefox needs because its IDs differ on every install. `http://*` and `https://*` are not honoured. ## Forward auth and the parsed body diff --git a/docs/serving/hono.md b/docs/serving/hono.md index a2adfde9c8..257d6974c2 100644 --- a/docs/serving/hono.md +++ b/docs/serving/hono.md @@ -47,7 +47,7 @@ Binding to all interfaces drops that default — name the hosts you serve instea const publicApp = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); ``` -`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. +`allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. Passing `allowedOrigins` replaces the default list, so start from `localhostAllowedOrigins()` (from `@modelcontextprotocol/server`) to keep it. A client that runs as a browser extension sends its extension ID as `Origin`: add that ID to admit one extension, or a lowercase scheme entry such as `'moz-extension://*'` to admit every extension of that browser, which Firefox needs because its IDs differ on every install. `http://*` and `https://*` are not honoured. ## Forward auth and the parsed body diff --git a/docs/serving/web-standard.md b/docs/serving/web-standard.md index 87d7439eb3..cfc7b128ea 100644 --- a/docs/serving/web-standard.md +++ b/docs/serving/web-standard.md @@ -44,7 +44,7 @@ const guarded = { }; ``` -A request whose `Host` is not on the list gets `403` before `handler.fetch` runs; both helpers take hostnames, port-agnostic, and a request without an `Origin` header always passes. For a localhost-only process, `localhostAllowedHostnames()` and `localhostAllowedOrigins()` (same package) replace the explicit lists. +A request whose `Host` is not on the list gets `403` before `handler.fetch` runs; both helpers take hostnames, port-agnostic, and a request without an `Origin` header always passes. For a localhost-only process, `localhostAllowedHostnames()` and `localhostAllowedOrigins()` (same package) replace the explicit lists. A client that runs as a browser extension sends its extension ID as `Origin`: add that ID to the `originValidationResponse` list to admit one extension, or a lowercase scheme entry such as `'moz-extension://*'` to admit every extension of that browser, which Firefox needs because its IDs differ on every install. `http://*` and `https://*` are not honoured. ## Forward auth and the parsed body diff --git a/packages/middleware/express/src/express.ts b/packages/middleware/express/src/express.ts index 0d3137f549..1cd060858a 100644 --- a/packages/middleware/express/src/express.ts +++ b/packages/middleware/express/src/express.ts @@ -33,6 +33,11 @@ export interface CreateMcpExpressAppOptions { * binds (the same condition as host validation): requests without an `Origin` * header pass, while a present `Origin` whose hostname is not localhost-class * is rejected with `403`. + * + * A client that runs as a browser extension sends its extension ID as the origin + * hostname: list that ID to admit one extension. A lowercase entry of the form + * `://*` (e.g. `'moz-extension://*'`) admits every extension of that browser. + * `http://*` and `https://*` are not honoured. */ allowedOrigins?: string[]; diff --git a/packages/middleware/express/src/middleware/originValidation.ts b/packages/middleware/express/src/middleware/originValidation.ts index d92513ae6c..dbac2b6618 100644 --- a/packages/middleware/express/src/middleware/originValidation.ts +++ b/packages/middleware/express/src/middleware/originValidation.ts @@ -11,7 +11,7 @@ import type { NextFunction, Request, RequestHandler, Response } from 'express'; * `Origin` header pass (non-browser MCP clients do not send one); a present * value that is not allowed, or that cannot be parsed, is rejected with `403`. * - * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port). + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port), or `://*` entries. * For IPv6, provide the address with brackets (e.g., `[::1]`). * @returns Express middleware function * diff --git a/packages/middleware/express/test/originValidation.test.ts b/packages/middleware/express/test/originValidation.test.ts index cfcbaba806..654ed86c4d 100644 --- a/packages/middleware/express/test/originValidation.test.ts +++ b/packages/middleware/express/test/originValidation.test.ts @@ -118,6 +118,26 @@ describe('@modelcontextprotocol/express origin validation', () => { expect(noOrigin.status).toBe(200); }); + test('admits a browser-extension origin once its scheme is listed in allowedOrigins', async () => { + const extension = 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; + + const byDefault = createMcpExpressApp(); + byDefault.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + const rejected1 = await supertest(byDefault).get('/health').set('Origin', extension); + expect(rejected1.status).toBe(403); + + const withScheme = createMcpExpressApp({ allowedOrigins: ['localhost', 'chrome-extension://*'] }); + withScheme.get('/health', (_req, res) => { + res.json({ ok: true }); + }); + const admitted2 = await supertest(withScheme).get('/health').set('Origin', extension); + expect(admitted2.status).toBe(200); + const rejected3 = await supertest(withScheme).get('/health').set('Origin', 'https://evil.example.com'); + expect(rejected3.status).toBe(403); + }); + test('an explicit allowedOrigins list replaces the default allowlist (validation stays armed)', async () => { const app = createMcpExpressApp({ host: '0.0.0.0', diff --git a/packages/middleware/fastify/src/fastify.ts b/packages/middleware/fastify/src/fastify.ts index b010188250..ec27feefa3 100644 --- a/packages/middleware/fastify/src/fastify.ts +++ b/packages/middleware/fastify/src/fastify.ts @@ -33,6 +33,11 @@ export interface CreateMcpFastifyAppOptions { * binds (the same condition as host validation): requests without an `Origin` * header pass, while a present `Origin` whose hostname is not localhost-class * is rejected with `403`. + * + * A client that runs as a browser extension sends its extension ID as the origin + * hostname: list that ID to admit one extension. A lowercase entry of the form + * `://*` (e.g. `'moz-extension://*'`) admits every extension of that browser. + * `http://*` and `https://*` are not honoured. */ allowedOrigins?: string[]; } diff --git a/packages/middleware/fastify/src/middleware/originValidation.ts b/packages/middleware/fastify/src/middleware/originValidation.ts index aad855885c..f8e68f6f52 100644 --- a/packages/middleware/fastify/src/middleware/originValidation.ts +++ b/packages/middleware/fastify/src/middleware/originValidation.ts @@ -11,7 +11,7 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; * `Origin` header pass (non-browser MCP clients do not send one); a present * value that is not allowed, or that cannot be parsed, is rejected with `403`. * - * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port). + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port), or `://*` entries. * For IPv6, provide the address with brackets (e.g., `[::1]`). * @returns Fastify onRequest hook handler * diff --git a/packages/middleware/fastify/test/originValidation.test.ts b/packages/middleware/fastify/test/originValidation.test.ts index f18fe247b3..29f2ead844 100644 --- a/packages/middleware/fastify/test/originValidation.test.ts +++ b/packages/middleware/fastify/test/originValidation.test.ts @@ -82,6 +82,20 @@ describe('@modelcontextprotocol/fastify origin validation', () => { expect(good.statusCode).toBe(200); }); + test('admits a browser-extension origin once its scheme is listed in allowedOrigins', async () => { + const headers = { host: 'localhost:3000', origin: 'chrome-extension://abcdefghijklmnopabcdefghijklmnop' }; + + const byDefault = createMcpFastifyApp(); + byDefault.get('/health', async () => 'ok'); + const rejected1 = await byDefault.inject({ method: 'GET', url: '/health', headers }); + expect(rejected1.statusCode).toBe(403); + + const withScheme = createMcpFastifyApp({ allowedOrigins: ['localhost', 'chrome-extension://*'] }); + withScheme.get('/health', async () => 'ok'); + const admitted2 = await withScheme.inject({ method: 'GET', url: '/health', headers }); + expect(admitted2.statusCode).toBe(200); + }); + test('uses allowedOrigins when provided', async () => { const app = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); app.get('/health', async () => 'ok'); diff --git a/packages/middleware/hono/src/hono.ts b/packages/middleware/hono/src/hono.ts index a72cd93fa0..7cc03a3a0a 100644 --- a/packages/middleware/hono/src/hono.ts +++ b/packages/middleware/hono/src/hono.ts @@ -34,6 +34,11 @@ export interface CreateMcpHonoAppOptions { * binds (the same condition as host validation): requests without an `Origin` * header pass, while a present `Origin` whose hostname is not localhost-class * is rejected with `403`. + * + * A client that runs as a browser extension sends its extension ID as the origin + * hostname: list that ID to admit one extension. A lowercase entry of the form + * `://*` (e.g. `'moz-extension://*'`) admits every extension of that browser. + * `http://*` and `https://*` are not honoured. */ allowedOrigins?: string[]; diff --git a/packages/middleware/hono/test/originValidation.test.ts b/packages/middleware/hono/test/originValidation.test.ts index eb85cec1b2..ec818898cc 100644 --- a/packages/middleware/hono/test/originValidation.test.ts +++ b/packages/middleware/hono/test/originValidation.test.ts @@ -59,6 +59,20 @@ describe('@modelcontextprotocol/hono origin validation', () => { expect(noOrigin.status).toBe(200); }); + test('createMcpHonoApp admits a browser-extension origin once its scheme is listed in allowedOrigins', async () => { + const headers = { Host: 'localhost:3000', Origin: 'moz-extension://0a1b2c3d-0000-4000-8000-000000000000' }; + + const byDefault = createMcpHonoApp(); + byDefault.get('/health', c => c.text('ok')); + const rejected1 = await byDefault.request('http://localhost/health', { headers }); + expect(rejected1.status).toBe(403); + + const withScheme = createMcpHonoApp({ allowedOrigins: ['localhost', 'moz-extension://*'] }); + withScheme.get('/health', c => c.text('ok')); + const admitted2 = await withScheme.request('http://localhost/health', { headers }); + expect(admitted2.status).toBe(200); + }); + test('createMcpHonoApp uses allowedOrigins when provided', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] }); diff --git a/packages/middleware/node/src/middleware/originValidation.ts b/packages/middleware/node/src/middleware/originValidation.ts index a38fc05144..a0f4299dd2 100644 --- a/packages/middleware/node/src/middleware/originValidation.ts +++ b/packages/middleware/node/src/middleware/originValidation.ts @@ -12,7 +12,7 @@ import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprot * it returns `false` it has already answered the request and the caller must * not handle it further. * - * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port). + * @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port), or `://*` entries. * For IPv6, provide the address with brackets (e.g., `[::1]`). * * @example diff --git a/packages/server/src/server/middleware/originValidation.ts b/packages/server/src/server/middleware/originValidation.ts index 9b8b68c11e..83147cea3f 100644 --- a/packages/server/src/server/middleware/originValidation.ts +++ b/packages/server/src/server/middleware/originValidation.ts @@ -32,6 +32,11 @@ export type OriginValidationResult = * and only browser-originated requests carry the header this check defends against. * - Allowlist items are hostnames only (no scheme, no port), the same convention as * `validateHostHeader`. For IPv6, include brackets (e.g. `[::1]`). + * - A browser extension's origin carries its extension ID as the hostname; list the + * ID to allow one extension. An allowlist item of the form `://*` + * (lowercase, e.g. `moz-extension://*`) allows every origin of that scheme, which + * Firefox needs because its extension IDs differ on every install. + * `http://*` and `https://*` are not honoured: list websites by hostname. * - Any present value that cannot be parsed as an origin URL — including the literal * `null` origin browsers send for opaque contexts — is rejected (deny on failure). */ @@ -41,8 +46,11 @@ export function validateOriginHeader(originHeader: string | null | undefined, al } let hostname: string; + let scheme: string; try { - hostname = new URL(originHeader).hostname; + const url = new URL(originHeader); + hostname = url.hostname; + scheme = url.protocol; } catch { return { ok: false, errorCode: 'invalid_origin_header', message: `Invalid Origin header: ${originHeader}`, originHeader }; } @@ -52,7 +60,8 @@ export function validateOriginHeader(originHeader: string | null | undefined, al return { ok: false, errorCode: 'invalid_origin_header', message: `Invalid Origin header: ${originHeader}`, originHeader }; } - if (!allowedOriginHostnames.includes(hostname)) { + const schemeAllowed = scheme !== 'http:' && scheme !== 'https:' && allowedOriginHostnames.includes(`${scheme}//*`); + if (!schemeAllowed && !allowedOriginHostnames.includes(hostname)) { return { ok: false, errorCode: 'invalid_origin', message: `Invalid Origin: ${hostname}`, originHeader, hostname }; } diff --git a/packages/server/test/server/originValidation.test.ts b/packages/server/test/server/originValidation.test.ts index e76a2f5c3f..b0e194d70b 100644 --- a/packages/server/test/server/originValidation.test.ts +++ b/packages/server/test/server/originValidation.test.ts @@ -33,6 +33,34 @@ describe('validateOriginHeader', () => { expect(validateOriginHeader('http://localhost.evil.example.com', localhostAllowedOrigins()).ok).toBe(false); }); + it('allows one browser extension when its ID is listed', () => { + const allowed = ['abcdefghijklmnopabcdefghijklmnop']; + expect(validateOriginHeader('chrome-extension://abcdefghijklmnopabcdefghijklmnop', allowed).ok).toBe(true); + expect(validateOriginHeader('chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba', allowed).ok).toBe(false); + }); + + it('allows every origin of a scheme listed as `://*`', () => { + const allowed = ['myapp.local', 'chrome-extension://*']; + expect(validateOriginHeader('chrome-extension://abcdefghijklmnopabcdefghijklmnop', allowed).ok).toBe(true); + expect(validateOriginHeader('moz-extension://0a1b2c3d-0000-4000-8000-000000000000', allowed).ok).toBe(false); + expect(validateOriginHeader('https://evil.example.com', allowed).ok).toBe(false); + expect(validateOriginHeader('https://chrome-extension', allowed).ok).toBe(false); + expect(validateOriginHeader('null', allowed).ok).toBe(false); + }); + + it('does not honour `http://*` or `https://*`: websites are listed by hostname', () => { + const allowed = ['http://*', 'https://*', 'myapp.local']; + expect(validateOriginHeader('https://evil.example.com', allowed).ok).toBe(false); + expect(validateOriginHeader('http://evil.example.com', allowed).ok).toBe(false); + expect(validateOriginHeader('https://myapp.local', allowed).ok).toBe(true); + }); + + it('localhostAllowedOrigins rejects browser-extension origins unless their scheme is added', () => { + const extension = 'moz-extension://0a1b2c3d-0000-4000-8000-000000000000'; + expect(validateOriginHeader(extension, localhostAllowedOrigins()).ok).toBe(false); + expect(validateOriginHeader(extension, [...localhostAllowedOrigins(), 'moz-extension://*']).ok).toBe(true); + }); + it('denies on failure: unparseable Origin values and the opaque null origin are rejected, never passed through', () => { for (const malformed of ['null', 'not a url', 'evil.example.com', 'about:blank']) { const result = validateOriginHeader(malformed, localhostAllowedOrigins()); From 633dd3e12bff6869c932c4a526341622320912b1 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:19:07 +0100 Subject: [PATCH 25/44] chore: set the package license field to Apache-2.0 (#2908) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/package-license-field.md | 13 +++++++++++++ README.md | 2 +- package.json | 2 +- packages/client/package.json | 2 +- packages/codemod/package.json | 2 +- packages/core-internal/package.json | 2 +- packages/core/package.json | 2 +- packages/middleware/express/package.json | 2 +- packages/middleware/fastify/package.json | 2 +- packages/middleware/hono/package.json | 2 +- packages/middleware/node/package.json | 2 +- packages/server-legacy/package.json | 2 +- packages/server/package.json | 2 +- 13 files changed, 25 insertions(+), 12 deletions(-) create mode 100644 .changeset/package-license-field.md diff --git a/.changeset/package-license-field.md b/.changeset/package-license-field.md new file mode 100644 index 0000000000..68cd32c989 --- /dev/null +++ b/.changeset/package-license-field.md @@ -0,0 +1,13 @@ +--- +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/codemod': patch +'@modelcontextprotocol/core': patch +'@modelcontextprotocol/server': patch +'@modelcontextprotocol/server-legacy': patch +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/express': patch +'@modelcontextprotocol/fastify': patch +'@modelcontextprotocol/hono': patch +--- + +The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. diff --git a/README.md b/README.md index 6d5e2328ef..749070e8c5 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ > [Issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml) are the most useful feedback right now — we'll reopen PRs as v2 stabilizes. [![NPM Version - Server](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fserver?label=%40modelcontextprotocol%2Fserver)](https://www.npmjs.com/package/@modelcontextprotocol/server) -[![NPM Version - Client](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fclient?label=%40modelcontextprotocol%2Fclient)](https://www.npmjs.com/package/@modelcontextprotocol/client) ![MIT licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fserver) +[![NPM Version - Client](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fclient?label=%40modelcontextprotocol%2Fclient)](https://www.npmjs.com/package/@modelcontextprotocol/client) ![Apache-2.0 licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fserver)
Table of Contents diff --git a/package.json b/package.json index 71424cb8f3..3072a5bfea 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "version": "2.0.0-alpha.0", "description": "Model Context Protocol implementation for TypeScript", - "license": "SEE LICENSE IN LICENSE", + "license": "Apache-2.0", "author": "Model Context Protocol a Series of LF Projects, LLC.", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/client/package.json b/packages/client/package.json index 23c9a6dd82..66621972f9 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/client", "version": "2.2.0", "description": "Model Context Protocol implementation for TypeScript - Client package", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 030315eaeb..cb8fe9b436 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/codemod", "version": "2.2.0", "description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 1febcabe68..07754feab9 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -3,7 +3,7 @@ "private": true, "version": "2.0.2", "description": "Model Context Protocol implementation for TypeScript - Core package", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/core/package.json b/packages/core/package.json index a321becd5a..8ba5d6c88b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/core", "version": "2.2.0", "description": "Model Context Protocol for TypeScript — public Zod schemas (spec + OAuth/OpenID)", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index 92fe653f5c..7cc6b47a6e 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -3,7 +3,7 @@ "private": false, "version": "2.0.1", "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json index d15134ef19..09ad7230dd 100644 --- a/packages/middleware/fastify/package.json +++ b/packages/middleware/fastify/package.json @@ -3,7 +3,7 @@ "private": false, "version": "2.0.0", "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json index 188f210e4d..89d52003b2 100644 --- a/packages/middleware/hono/package.json +++ b/packages/middleware/hono/package.json @@ -3,7 +3,7 @@ "private": false, "version": "2.0.1", "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index 86807ade45..25ef2cabdb 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/node", "version": "2.1.0", "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index f89b1a6dea..4ee45161c2 100644 --- a/packages/server-legacy/package.json +++ b/packages/server-legacy/package.json @@ -4,7 +4,7 @@ "version": "2.2.0", "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", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", diff --git a/packages/server/package.json b/packages/server/package.json index a7f0d2d68f..84f2d331a7 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -2,7 +2,7 @@ "name": "@modelcontextprotocol/server", "version": "2.2.0", "description": "Model Context Protocol implementation for TypeScript - Server package", - "license": "MIT", + "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", "homepage": "https://modelcontextprotocol.io", "bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues", From 433eb413dc305ebeb93b8ebcde0095a8fc0d5fa0 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 16:44:10 +0100 Subject: [PATCH 26/44] fix(client): follow redirects only within the origin of the request (#2901) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/redirects-within-origin.md | 5 + packages/client/src/client/auth.ts | 18 +- packages/client/src/client/crossAppAccess.ts | 5 +- packages/client/src/client/sse.ts | 57 +- packages/client/src/client/streamableHttp.ts | 53 +- packages/client/test/client/redirects.test.ts | 606 ++++++++++++++++++ .../core-internal/src/shared/transport.ts | 66 ++ .../test/shared/fetchWithinOrigin.test.ts | 203 ++++++ 8 files changed, 989 insertions(+), 24 deletions(-) create mode 100644 .changeset/redirects-within-origin.md create mode 100644 packages/client/test/client/redirects.test.ts create mode 100644 packages/core-internal/test/shared/fetchWithinOrigin.test.ts diff --git a/.changeset/redirects-within-origin.md b/.changeset/redirects-within-origin.md new file mode 100644 index 0000000000..7d1ff8593c --- /dev/null +++ b/.changeset/redirects-within-origin.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': minor +--- + +The HTTP client transports and the OAuth client helpers now follow a redirect only when it stays within the origin of the request (same scheme, host and port, or http to https on the same host with default ports) and keeps the method (a 307 or 308, or any redirect of a GET). Any other redirect is not followed. A transport then fails the request with an error that names the target; the session is kept and later messages still send. OAuth metadata discovery moves on to the next well-known URL, and any other OAuth request fails with an error that gives the status. Same-origin redirects that keep the method keep working on Node, up to five in a row, and no code changes are needed there. If your endpoint redirects to another origin, configure the transport with the URL it redirects to. A `requestInit.redirect` of `'error'` or `'manual'` is passed to fetch as it is for the requests a transport sends to the server (POST, GET and DELETE of the Streamable HTTP transport, POST of the SSE transport); for its OAuth requests, and for any other value, `requestInit.redirect` is not consulted by default. Browsers do not expose the target of a redirect to a page, so there a redirected request fails instead of being followed. Setting `redirectPolicy: 'follow'` on a transport leaves its redirects to the fetch implementation, as before this change. diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 7bd8930a49..0696eb240b 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -15,6 +15,7 @@ import type { import { brandedHasInstance, checkResourceAllowed, + fetchWithinOrigin, LATEST_PROTOCOL_VERSION, OAuthClientInformationFullSchema, OAuthError, @@ -1672,8 +1673,9 @@ export async function discoverOAuthProtectedResourceMetadata( * error object alone, so the swallow-and-fallthrough heuristic is preserved there. */ async function fetchWithCorsRetry(url: URL, headers?: Record, fetchFn: FetchLike = fetch): Promise { + const withinOrigin = fetchWithinOrigin(fetchFn); try { - return await fetchFn(url, { headers }); + return await withinOrigin(url, { headers }); } catch (error) { if (!(error instanceof TypeError) || !CORS_IS_POSSIBLE) { throw error; @@ -1682,7 +1684,7 @@ async function fetchWithCorsRetry(url: URL, headers?: Record, fe // Could be a CORS preflight rejection caused by our custom header. Retry as a simple // request: if that succeeds, we've sidestepped the preflight. try { - return await fetchFn(url, {}); + return await withinOrigin(url, {}); } catch (retryError) { if (!(retryError instanceof TypeError)) { throw retryError; @@ -1728,7 +1730,7 @@ async function tryMetadataDiscovery(url: URL, protocolVersion: string, fetchFn: function shouldAttemptFallback(response: Response | undefined, pathname: string): boolean { if (!response) return true; // CORS error — always try fallback if (pathname === '/') return false; // Already at root - return (response.status >= 400 && response.status < 500) || response.status === 502; + return (!response.ok && response.status < 500) || response.status === 502; } /** @@ -1755,7 +1757,7 @@ async function discoverMetadataWithFallback( let response = await tryMetadataDiscovery(url, protocolVersion, fetchFn); - // If path-aware discovery fails (4xx or 502 Bad Gateway) and we're not already at root, try fallback to root discovery + // If path-aware discovery fails (4xx, 502 or a redirect not followed) and we're not already at root, try fallback to root discovery if (!opts?.metadataUrl && shouldAttemptFallback(response, issuer.pathname)) { const rootUrl = new URL(`/.well-known/${wellKnownType}`, issuer); response = await tryMetadataDiscovery(rootUrl, protocolVersion, fetchFn); @@ -1933,8 +1935,8 @@ export async function discoverAuthorizationServerMetadata( if (!response.ok) { await response.text?.().catch(() => {}); - if ((response.status >= 400 && response.status < 500) || response.status === 502) { - continue; // Try next URL for 4xx or 502 (Bad Gateway) + if (response.status < 500 || response.status === 502) { + continue; // Try next URL for 4xx, 502 (Bad Gateway) or a redirect that was not followed } throw new Error( `HTTP ${response.status} trying to load ${type === 'oauth' ? 'OAuth' : 'OpenID provider'} metadata from ${endpointUrl}` @@ -2224,7 +2226,7 @@ export async function executeTokenRequest( // presented, and the token request is presenting credentials to *obtain* one. requestHeaders.set('DPoP', await dpop.buildProof({ htm: 'POST', htu: tokenUrl })); } - return (fetchFn ?? fetch)(tokenUrl, { + return fetchWithinOrigin(fetchFn ?? fetch)(tokenUrl, { method: 'POST', headers: requestHeaders, body: tokenRequestParams @@ -2547,7 +2549,7 @@ export async function registerClient( ...(scope === undefined ? {} : { scope }) }; - const response = await (fetchFn ?? fetch)(registrationUrl, { + const response = await fetchWithinOrigin(fetchFn ?? fetch)(registrationUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts index a5491258ec..6eac2353d3 100644 --- a/packages/client/src/client/crossAppAccess.ts +++ b/packages/client/src/client/crossAppAccess.ts @@ -10,6 +10,7 @@ import type { FetchLike } from '@modelcontextprotocol/core-internal'; import { + fetchWithinOrigin, IdJagTokenExchangeResponseSchema, OAuthErrorResponseSchema, OAuthTokensSchema, @@ -152,7 +153,7 @@ export async function requestJwtAuthorizationGrant(options: RequestJwtAuthGrantO params.set('scope', scope); } - const response = await fetchFn(tokenUrl, { + const response = await fetchWithinOrigin(fetchFn)(tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' @@ -281,7 +282,7 @@ export async function exchangeJwtAuthGrant(options: { applyClientAuthentication(authMethod, { client_id: clientId, client_secret: clientSecret }, headers, params); - const response = await fetchFn(tokenUrl, { + const response = await fetchWithinOrigin(fetchFn)(tokenUrl, { method: 'POST', headers, body: params.toString() diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index e249636ed2..43c9c7c5db 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -2,11 +2,14 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol import { brandedHasInstance, createFetchWithInit, + fetchLeavingRedirects, + fetchWithinOrigin, JSONRPCMessageSchema, SdkError, SdkErrorCode, SdkHttpError, - stampErrorBrands + stampErrorBrands, + unfollowedRedirect } from '@modelcontextprotocol/core-internal'; import type { ErrorEvent, EventSourceInit } from 'eventsource'; import { EventSource } from 'eventsource'; @@ -113,6 +116,12 @@ export type SSEClientTransportOptions = { * `mcp-protocol-version`. A caller-supplied `Authorization` value is therefore only sent * while the provider has no token, which lets a static API key fall back to OAuth once * the provider obtains one. + * + * A `redirect` of `'error'` or `'manual'` is passed to fetch as it is for the `POST` + * requests that carry messages. For the OAuth requests, and for any other value, + * `redirect` is consulted only when + * {@linkcode SSEClientTransportOptions.redirectPolicy | redirectPolicy} is `'follow'`. The + * request that begins the stream does not read it. */ requestInit?: RequestInit; @@ -120,6 +129,22 @@ export type SSEClientTransportOptions = { * Custom fetch implementation used for all network requests. */ fetch?: FetchLike; + + /** + * How a redirect of one of the transport's requests is handled, including the OAuth + * requests it makes for {@linkcode SSEClientTransportOptions.authProvider | authProvider}. + * The option does not reach requests that a provider makes with a fetch of its own, such as + * those of the `assertion` callback of a `CrossAppAccessProvider`. + * + * - `'same-origin'` (default): a redirect is followed only when it stays within the origin + * of the request and keeps the method, and any other redirect fails the request. + * - `'follow'`: redirects are left to the fetch implementation, as in earlier versions. It + * follows them to any origin unless `requestInit.redirect` says otherwise, which the + * request that begins the stream does not read. + * + * @default 'same-origin' + */ + redirectPolicy?: 'same-origin' | 'follow'; }; /** @@ -141,6 +166,7 @@ export class SSEClientTransport implements Transport { private _skipIssuerMetadataValidation?: boolean; private _fetch?: FetchLike; private _fetchWithInit: FetchLike; + private _redirectPolicy?: 'same-origin' | 'follow'; private _dpop?: Middleware; private _protocolVersion?: string; @@ -171,13 +197,26 @@ export class SSEClientTransport implements Transport { } else { this._authProvider = opts?.authProvider; } + this._redirectPolicy = opts?.redirectPolicy; this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + if (this._redirectPolicy === 'follow') this._fetchWithInit = fetchLeavingRedirects(this._fetchWithInit); } private _last401Response?: Response; // True between a 401-triggered reconnect and the next successful open. private _connectAuthRetried = false; + /** `baseFetch` with redirects handled as `redirectPolicy` says. */ + private _redirects(baseFetch: FetchLike): FetchLike { + return this._redirectPolicy === 'follow' ? baseFetch : fetchWithinOrigin(baseFetch); + } + + /** Error text for a redirect `response` that was not followed, or `undefined` for any other response. */ + private _unfollowedRedirect(url: string | URL, response: Response): string | undefined { + const text = this._redirectPolicy === 'follow' ? undefined : unfollowedRedirect(url, response); + return text && `${text} (redirectPolicy: 'same-origin')`; + } + private async _commonHeaders(): Promise { // Start from the caller-supplied `requestInit.headers` and `set()` the // transport-managed headers on top. `Headers.set` compares names @@ -211,9 +250,10 @@ export class SSEClientTransport implements Transport { private _startOrAuth(): Promise { const eventSourceFetch = this._eventSourceInit?.fetch; - const fetchImpl = ( - eventSourceFetch ? (this._dpop?.(eventSourceFetch as FetchLike) ?? eventSourceFetch) : (this._fetch ?? fetch) - ) as typeof fetch; + const fetchImpl = this._redirects( + (eventSourceFetch ? (this._dpop?.(eventSourceFetch as FetchLike) ?? eventSourceFetch) : (this._fetch ?? fetch)) as FetchLike + ); + let redirect: string | undefined; return new Promise((resolve, reject) => { this._eventSource = new EventSource(this._url.href, { ...this._eventSourceInit, @@ -224,6 +264,7 @@ export class SSEClientTransport implements Transport { ...init, headers }); + redirect = this._unfollowedRedirect(url, response); if (response.status === 401) { this._last401Response = response; @@ -277,7 +318,7 @@ export class SSEClientTransport implements Transport { return; } - const error = new SseError(event.code, event.message, event); + const error = new SseError(event.code, redirect ?? event.message, event); reject(error); this.onerror?.(error); }; @@ -404,7 +445,7 @@ export class SSEClientTransport implements Transport { signal: this._abortController?.signal }; - const response = await (this._fetch ?? fetch)(this._endpoint, init); + const response = await this._redirects(this._fetch ?? fetch)(this._endpoint, init); if (!response.ok) { if (response.status === 401 && this._authProvider) { if (response.headers.has('www-authenticate')) { @@ -442,7 +483,9 @@ export class SSEClientTransport implements Transport { } const text = await response.text?.().catch(() => null); - throw new Error(`Error POSTing to endpoint (HTTP ${response.status}): ${text}`); + throw new Error( + `Error POSTing to endpoint (HTTP ${response.status}): ${this._unfollowedRedirect(this._endpoint, response) ?? text}` + ); } // Release connection - POST responses don't have content we need diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index c91c6db008..e7a08c8dd4 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -4,6 +4,8 @@ import type { FetchLike, JSONRPCMessage, Transport } from '@modelcontextprotocol import { createFetchWithInit, encodeMcpParamValue, + fetchLeavingRedirects, + fetchWithinOrigin, isInitializedNotification, isInitializeRequest, isJSONRPCErrorResponse, @@ -16,7 +18,8 @@ import { PROTOCOL_VERSION_META_KEY, SdkError, SdkErrorCode, - SdkHttpError + SdkHttpError, + unfollowedRedirect } from '@modelcontextprotocol/core-internal'; import { EventSourceParserStream } from 'eventsource-parser/stream'; @@ -189,6 +192,11 @@ export type StreamableHTTPClientTransportOptions = { * token, `mcp-session-id`, and `mcp-protocol-version`. A caller-supplied `Authorization` * value is therefore only sent while the provider has no token, which lets a static API * key fall back to OAuth once the provider obtains one. + * + * A `redirect` of `'error'` or `'manual'` is passed to fetch as it is for the POST, GET and + * DELETE requests to the server URL. For the OAuth requests, and for any other value, + * `redirect` is consulted only when + * {@linkcode StreamableHTTPClientTransportOptions.redirectPolicy | redirectPolicy} is `'follow'`. */ requestInit?: RequestInit; @@ -197,6 +205,21 @@ export type StreamableHTTPClientTransportOptions = { */ fetch?: FetchLike; + /** + * How a redirect of one of the transport's requests is handled, including the OAuth + * requests it makes for {@linkcode StreamableHTTPClientTransportOptions.authProvider | authProvider}. + * The option does not reach requests that a provider makes with a fetch of its own, such as + * those of the `assertion` callback of a `CrossAppAccessProvider`. + * + * - `'same-origin'` (default): a redirect is followed only when it stays within the origin + * of the request and keeps the method, and any other redirect fails the request. + * - `'follow'`: redirects are left to the fetch implementation, as in earlier versions. It + * follows them to any origin unless `requestInit.redirect` says otherwise. + * + * @default 'same-origin' + */ + redirectPolicy?: 'same-origin' | 'follow'; + /** * Options to configure the reconnection behavior. */ @@ -328,6 +351,7 @@ export class StreamableHTTPClientTransport implements Transport { private _skipIssuerMetadataValidation?: boolean; private _fetch?: FetchLike; private _fetchWithInit: FetchLike; + private _redirectPolicy?: 'same-origin' | 'follow'; private _sessionId?: string; private _reconnectionOptions: StreamableHTTPReconnectionOptions; private _protocolVersion?: string; @@ -371,7 +395,9 @@ export class StreamableHTTPClientTransport implements Transport { } else { this._authProvider = opts?.authProvider; } + this._redirectPolicy = opts?.redirectPolicy; this._fetchWithInit = createFetchWithInit(opts?.fetch, opts?.requestInit); + if (this._redirectPolicy === 'follow') this._fetchWithInit = fetchLeavingRedirects(this._fetchWithInit); this._sessionId = opts?.sessionId; this._protocolVersion = opts?.protocolVersion; this._reconnectionOptions = opts?.reconnectionOptions ?? DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS; @@ -448,6 +474,17 @@ export class StreamableHTTPClientTransport implements Transport { }); } + /** `baseFetch` with redirects handled as `redirectPolicy` says. */ + private _redirects(baseFetch: FetchLike): FetchLike { + return this._redirectPolicy === 'follow' ? baseFetch : fetchWithinOrigin(baseFetch); + } + + /** Error text for a redirect `response` that was not followed, or `undefined` for any other response. */ + private _unfollowedRedirect(url: string | URL, response: Response): string | undefined { + const text = this._redirectPolicy === 'follow' ? undefined : unfollowedRedirect(url, response); + return text && `${text} (redirectPolicy: 'same-origin')`; + } + private async _commonHeaders(): Promise { // Start from the caller-supplied `requestInit.headers` and `set()` the // transport-managed headers on top. `Headers.set` compares names @@ -562,7 +599,7 @@ export class StreamableHTTPClientTransport implements Transport { requestSignal !== undefined && transportSignal !== undefined ? anySignal(transportSignal, requestSignal) : (requestSignal ?? transportSignal); - const response = await (this._fetch ?? fetch)(this._url, { + const response = await this._redirects(this._fetch ?? fetch)(this._url, { ...this._requestInit, method: 'GET', headers, @@ -639,7 +676,8 @@ export class StreamableHTTPClientTransport implements Transport { return; } - throw new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${response.statusText}`, { + const reason = this._unfollowedRedirect(this._url, response) ?? response.statusText; + throw new SdkHttpError(SdkErrorCode.ClientHttpFailedToOpenStream, `Failed to open SSE stream: ${reason}`, { status: response.status, statusText: response.statusText }); @@ -1022,7 +1060,7 @@ export class StreamableHTTPClientTransport implements Transport { signal }; - const response = await (this._fetch ?? fetch)(this._url, init); + const response = await this._redirects(this._fetch ?? fetch)(this._url, init); // The spec assigns the session id "at initialization time … on the HTTP response containing the InitializeResult"; it is ignored everywhere else. // Clients include only an id "returned by the server during initialization", so a sessionless handshake clears any stale id. @@ -1116,7 +1154,8 @@ export class StreamableHTTPClientTransport implements Transport { } } - throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${text}`, { + const reason = this._unfollowedRedirect(this._url, response) ?? text; + throw new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, `Error POSTing to endpoint: ${reason}`, { status: response.status, statusText: response.statusText, text @@ -1222,7 +1261,7 @@ export class StreamableHTTPClientTransport implements Transport { signal: this._abortController?.signal }; - const response = await (this._fetch ?? fetch)(this._url, init); + const response = await this._redirects(this._fetch ?? fetch)(this._url, init); await response.text?.().catch(() => {}); // We specifically handle 405 as a valid response according to the spec, @@ -1230,7 +1269,7 @@ export class StreamableHTTPClientTransport implements Transport { if (!response.ok && response.status !== 405) { throw new SdkHttpError( SdkErrorCode.ClientHttpFailedToTerminateSession, - `Failed to terminate session: ${response.statusText}`, + `Failed to terminate session: ${this._unfollowedRedirect(this._url, response) ?? response.statusText}`, { status: response.status, statusText: response.statusText diff --git a/packages/client/test/client/redirects.test.ts b/packages/client/test/client/redirects.test.ts new file mode 100644 index 0000000000..02354e6e80 --- /dev/null +++ b/packages/client/test/client/redirects.test.ts @@ -0,0 +1,606 @@ +import type { IncomingMessage, Server, ServerResponse } from 'node:http'; +import { createServer } from 'node:http'; + +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; + +import type { OAuthClientProvider } from '../../src/client/auth'; +import { + discoverAuthorizationServerMetadata, + discoverOAuthProtectedResourceMetadata, + exchangeAuthorization, + refreshAuthorization, + registerClient +} from '../../src/client/auth'; +import { exchangeJwtAuthGrant, requestJwtAuthorizationGrant } from '../../src/client/crossAppAccess'; +import type { SSEClientTransportOptions } from '../../src/client/sse'; +import { SSEClientTransport } from '../../src/client/sse'; +import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp'; +import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp'; + +type Recorded = { method: string; url: string; headers: IncomingMessage['headers']; body: string }; +type Handler = (req: IncomingMessage, res: ServerResponse, recorded: Recorded) => void; + +const REDIRECT_STATUSES = [301, 302, 303, 307, 308]; +const request: JSONRPCMessage = { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} }; +const notification: JSONRPCMessage = { jsonrpc: '2.0', method: 'notifications/roots/list_changed' }; +const clientInformation = { client_id: 'client-1', client_secret: 'client-secret-1' }; + +/** Two loopback servers: `endpoint` is what the client is configured with, `other` is a different origin. */ +describe('redirects', () => { + let endpoint: Server; + let other: Server; + let endpointUrl: URL; + let otherUrl: URL; + let endpointRequests: Recorded[]; + let otherRequests: Recorded[]; + let handle: Handler; + let transport: StreamableHTTPClientTransport | SSEClientTransport | undefined; + + const record = (requests: Recorded[], respond: Handler) => + createServer((req, res) => { + let body = ''; + req.on('data', chunk => (body += chunk)); + req.on('end', () => { + const recorded = { method: req.method ?? '', url: req.url ?? '', headers: req.headers, body }; + requests.push(recorded); + respond(req, res, recorded); + }); + }); + + const redirectTo = (location: string | URL, status = 307): Handler => { + return (_req, res) => void res.writeHead(status, { location: String(location) }).end(); + }; + + beforeEach(async () => { + endpointRequests = []; + otherRequests = []; + handle = (_req, res) => void res.writeHead(404).end(); + endpoint = record(endpointRequests, (req, res, recorded) => handle(req, res, recorded)); + other = record(otherRequests, (_req, res) => void res.writeHead(200, { 'content-type': 'application/json' }).end('{}')); + endpointUrl = await listenOnRandomPort(endpoint); + otherUrl = await listenOnRandomPort(other); + }); + + afterEach(async () => { + await transport?.close(); + transport = undefined; + endpoint.closeAllConnections(); + other.closeAllConnections(); + await new Promise(resolve => endpoint.close(resolve)); + await new Promise(resolve => other.close(resolve)); + }); + + const streamableHttp = async (): Promise => { + const created = new StreamableHTTPClientTransport(new URL('/mcp', endpointUrl), { + requestInit: { headers: { 'x-api-key': 'key-1' } }, + sessionId: 'session-1' + }); + created.onerror = () => {}; + transport = created; + await created.start(); + return created; + }; + + describe('to another origin', () => { + test('Streamable HTTP POST is not followed, and the next message still sends', async () => { + const client = await streamableHttp(); + handle = redirectTo(new URL('/mcp?from=endpoint', otherUrl)); + + const error = await client.send(request).catch((error_: Error) => error_); + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain(`Redirect to ${new URL('/mcp', otherUrl)} not followed`); + expect((error as Error).message).not.toContain('from=endpoint'); + expect(otherRequests).toEqual([]); + expect(endpointRequests).toHaveLength(1); + + handle = (_req, res) => void res.writeHead(202).end(); + await client.send(notification); + expect(client.sessionId).toBe('session-1'); + expect(endpointRequests).toHaveLength(2); + expect(endpointRequests[1]!.headers['mcp-session-id']).toBe('session-1'); + expect(endpointRequests[1]!.headers['x-api-key']).toBe('key-1'); + expect(otherRequests).toEqual([]); + }); + + test('Streamable HTTP GET stream is not followed', async () => { + const client = await streamableHttp(); + handle = redirectTo(new URL('/mcp', otherUrl)); + + await expect(client.resumeStream('event-1')).rejects.toThrow(`Redirect to ${new URL('/mcp', otherUrl)} not followed`); + expect(otherRequests).toEqual([]); + expect(endpointRequests.map(r => r.method)).toEqual(['GET']); + }); + + test('Streamable HTTP DELETE is not followed, and the session is kept', async () => { + const client = await streamableHttp(); + handle = redirectTo(new URL('/mcp', otherUrl)); + + await expect(client.terminateSession()).rejects.toThrow(`Redirect to ${new URL('/mcp', otherUrl)} not followed`); + expect(otherRequests).toEqual([]); + expect(endpointRequests.map(r => r.method)).toEqual(['DELETE']); + expect(client.sessionId).toBe('session-1'); + }); + + test('SSE stream is not followed', async () => { + handle = redirectTo(new URL('/sse', otherUrl)); + const client = new SSEClientTransport(new URL('/sse', endpointUrl), { requestInit: { headers: { 'x-api-key': 'key-1' } } }); + transport = client; + + await expect(client.start()).rejects.toThrow(`Redirect to ${new URL('/sse', otherUrl)} not followed`); + expect(otherRequests).toEqual([]); + expect(endpointRequests.map(r => r.method)).toEqual(['GET']); + }); + + test('SSE stream given its own fetch is not followed', async () => { + handle = redirectTo(new URL('/sse', otherUrl)); + const client = new SSEClientTransport(new URL('/sse', endpointUrl), { + eventSourceInit: { fetch: (url, init) => fetch(url, { ...init, headers: { ...init.headers, 'x-api-key': 'key-1' } }) } + }); + transport = client; + + await expect(client.start()).rejects.toThrow(`Redirect to ${new URL('/sse', otherUrl)} not followed`); + expect(otherRequests).toEqual([]); + expect(endpointRequests.map(r => r.headers['x-api-key'])).toEqual(['key-1']); + }); + + test('SSE POST is not followed, and the next message still sends', async () => { + handle = (req, res) => { + if (req.method === 'GET') { + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write('event: endpoint\ndata: /messages\n\n'); + return; + } + redirectTo(new URL('/messages', otherUrl))(req, res, undefined as never); + }; + const client = new SSEClientTransport(new URL('/sse', endpointUrl), { requestInit: { headers: { 'x-api-key': 'key-1' } } }); + client.onerror = () => {}; + transport = client; + await client.start(); + + await expect(client.send(request)).rejects.toThrow(`Redirect to ${new URL('/messages', otherUrl)} not followed`); + expect(otherRequests).toEqual([]); + + const redirecting = handle; + handle = (req, res, recorded) => (req.method === 'POST' ? void res.writeHead(202).end() : redirecting(req, res, recorded)); + await client.send(notification); + expect(endpointRequests.at(-1)).toMatchObject({ method: 'POST', url: '/messages', body: JSON.stringify(notification) }); + expect(otherRequests).toEqual([]); + }); + + test('a protocol-relative Location is not followed', async () => { + const client = await streamableHttp(); + handle = redirectTo(`//${otherUrl.host}/mcp`); + + await expect(client.send(request)).rejects.toThrow(`Redirect to ${new URL('/mcp', otherUrl)} not followed`); + expect(otherRequests).toEqual([]); + }); + + test('from https to http names the https form of the target', async () => { + const fetchStub = vi.fn(async () => new Response(null, { status: 307, headers: { location: 'http://example.com/mcp/' } })); + const client = new StreamableHTTPClientTransport(new URL('https://example.com/mcp'), { fetch: fetchStub }); + client.onerror = () => {}; + transport = client; + await client.start(); + + const error = await client.send(request).catch((error_: Error) => error_); + + expect((error as Error).message).toContain('https://example.com/mcp/'); + expect((error as Error).message).not.toContain('http://'); + expect(fetchStub).toHaveBeenCalledTimes(1); + }); + + test('in a browser, where the target is not exposed, the request fails', async () => { + const opaque = { type: 'opaqueredirect', status: 0, ok: false, statusText: '', headers: new Headers() } as Response; + const fetchStub = vi.fn(async () => opaque); + const client = new StreamableHTTPClientTransport(new URL('https://example.com/mcp'), { fetch: fetchStub }); + client.onerror = () => {}; + transport = client; + await client.start(); + + await expect(client.send(request)).rejects.toThrow('Redirect not followed'); + expect(fetchStub).toHaveBeenCalledTimes(1); + }); + + test('protected resource metadata discovery is not followed', async () => { + handle = redirectTo(new URL('/.well-known/oauth-protected-resource', otherUrl)); + + await expect(discoverOAuthProtectedResourceMetadata(endpointUrl)).rejects.toThrow('HTTP 307'); + expect(otherRequests).toEqual([]); + }); + + test('authorization server metadata discovery is not followed', async () => { + handle = redirectTo(new URL('/.well-known/oauth-authorization-server', otherUrl)); + + await expect(discoverAuthorizationServerMetadata(endpointUrl)).resolves.toBeUndefined(); + expect(endpointRequests.map(r => r.url)).toEqual([ + '/.well-known/oauth-authorization-server', + '/.well-known/openid-configuration' + ]); + expect(otherRequests).toEqual([]); + }); + + test('authorization server metadata discovery tries the next well-known URL', async () => { + const metadata = { + issuer: endpointUrl.origin, + authorization_endpoint: `${endpointUrl.origin}/authorize`, + token_endpoint: `${endpointUrl.origin}/token`, + jwks_uri: `${endpointUrl.origin}/jwks`, + response_types_supported: ['code'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['RS256'] + }; + handle = (req, res, recorded) => + req.url === '/.well-known/openid-configuration' + ? void res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(metadata)) + : redirectTo(new URL('/metadata', otherUrl), 302)(req, res, recorded); + + await expect(discoverAuthorizationServerMetadata(endpointUrl)).resolves.toMatchObject(metadata); + expect(otherRequests).toEqual([]); + }); + + test('protected resource metadata discovery tries the root well-known URL', async () => { + const metadata = { resource: new URL('/mcp', endpointUrl).href, authorization_servers: [endpointUrl.origin] }; + handle = (req, res, recorded) => + req.url === '/.well-known/oauth-protected-resource' + ? void res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(metadata)) + : redirectTo(new URL('/metadata', otherUrl), 302)(req, res, recorded); + + await expect(discoverOAuthProtectedResourceMetadata(new URL('/mcp', endpointUrl))).resolves.toEqual(metadata); + expect(endpointRequests.map(r => r.url)).toEqual([ + '/.well-known/oauth-protected-resource/mcp', + '/.well-known/oauth-protected-resource' + ]); + expect(otherRequests).toEqual([]); + }); + + test('client registration is not followed', async () => { + handle = redirectTo(new URL('/register', otherUrl)); + + await expect( + registerClient(endpointUrl, { clientMetadata: { redirect_uris: ['http://localhost:3000/callback'] } }) + ).rejects.toThrow('HTTP 307'); + expect(otherRequests).toEqual([]); + }); + + test('authorization code exchange is not followed', async () => { + handle = redirectTo(new URL('/token', otherUrl)); + + await expect( + exchangeAuthorization(endpointUrl, { + clientInformation, + authorizationCode: 'code-1', + codeVerifier: 'verifier-1', + redirectUri: 'http://localhost:3000/callback' + }) + ).rejects.toThrow('HTTP 307'); + expect(otherRequests).toEqual([]); + }); + + test('token refresh is not followed', async () => { + handle = redirectTo(new URL('/token', otherUrl)); + + await expect(refreshAuthorization(endpointUrl, { clientInformation, refreshToken: 'refresh-1' })).rejects.toThrow('HTTP 307'); + expect(otherRequests).toEqual([]); + }); + + test('JWT authorization grant request is not followed', async () => { + handle = redirectTo(new URL('/token', otherUrl)); + + await expect( + requestJwtAuthorizationGrant({ + tokenEndpoint: new URL('/token', endpointUrl), + audience: 'https://auth.example.com', + resource: 'https://mcp.example.com', + idToken: 'id-token-1', + clientId: 'client-1' + }) + ).rejects.toThrow('307'); + expect(otherRequests).toEqual([]); + }); + + test('JWT authorization grant exchange is not followed', async () => { + handle = redirectTo(new URL('/token', otherUrl)); + + await expect( + exchangeJwtAuthGrant({ + tokenEndpoint: new URL('/token', endpointUrl), + jwtAuthGrant: 'grant-1', + clientId: 'client-1', + clientSecret: 'client-secret-1' + }) + ).rejects.toThrow('307'); + expect(otherRequests).toEqual([]); + }); + }); + + describe('that changes the method', () => { + test.each([301, 302, 303])('a %i of a POST within the origin is not followed', async status => { + const client = await streamableHttp(); + handle = redirectTo('/mcp/', status); + + await expect(client.send(request)).rejects.toThrow(`Redirect to ${new URL('/mcp/', endpointUrl)} not followed`); + expect(endpointRequests.map(r => `${r.method} ${r.url}`)).toEqual(['POST /mcp']); + }); + + test('after a followed redirect, a relative Location is named from the URL that answered', async () => { + const client = await streamableHttp(); + handle = (req, res, recorded) => + req.url === '/mcp' ? redirectTo('/a/b/mcp', 307)(req, res, recorded) : redirectTo('login', 302)(req, res, recorded); + + await expect(client.send(request)).rejects.toThrow(`Redirect to ${new URL('/a/b/login', endpointUrl)} not followed`); + expect(endpointRequests.map(r => `${r.method} ${r.url}`)).toEqual(['POST /mcp', 'POST /a/b/mcp']); + }); + }); + + describe('within the origin', () => { + test.each([307, 308])('a %i of a POST is followed with its headers and body', async status => { + const client = await streamableHttp(); + handle = (req, res, recorded) => + req.url === '/mcp' ? redirectTo('/mcp/', status)(req, res, recorded) : void res.writeHead(202).end(); + + await client.send(request); + + expect(endpointRequests.map(r => `${r.method} ${r.url}`)).toEqual(['POST /mcp', 'POST /mcp/']); + expect(endpointRequests[1]!.body).toBe(JSON.stringify(request)); + expect(endpointRequests[1]!.headers).toMatchObject({ + 'x-api-key': 'key-1', + 'mcp-session-id': 'session-1', + 'content-type': 'application/json' + }); + }); + + test.each(REDIRECT_STATUSES)('a %i of the GET stream is followed', async status => { + const client = await streamableHttp(); + const received = new Promise(resolve => (client.onmessage = resolve)); + handle = (req, res, recorded) => { + if (req.url === '/mcp') return redirectTo('/mcp/', status)(req, res, recorded); + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write(`id: event-2\ndata: ${JSON.stringify(notification)}\n\n`); + }; + + await client.resumeStream('event-1'); + + await expect(received).resolves.toEqual(notification); + expect(endpointRequests.map(r => `${r.method} ${r.url}`)).toEqual(['GET /mcp', 'GET /mcp/']); + expect(endpointRequests[1]!.headers).toMatchObject({ 'x-api-key': 'key-1', 'last-event-id': 'event-1' }); + }); + + test('a 307 of the token request is followed', async () => { + const tokens = { access_token: 'access-1', token_type: 'Bearer' }; + handle = (req, res, recorded) => { + if (req.url === '/token') return redirectTo('/token/', 307)(req, res, recorded); + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(tokens)); + }; + + await expect(refreshAuthorization(endpointUrl, { clientInformation, refreshToken: 'refresh-1' })).resolves.toMatchObject( + tokens + ); + expect(endpointRequests.map(r => `${r.method} ${r.url}`)).toEqual(['POST /token', 'POST /token/']); + expect(endpointRequests[1]!.body).toContain('refresh_token=refresh-1'); + }); + }); + + describe('redirectPolicy', () => { + const requestInit = { headers: { 'x-api-key': 'key-1' } }; + const sent = { 'x-api-key': 'key-1', 'mcp-session-id': 'session-1' }; + const toOther: Handler = (req, res, recorded) => redirectTo(new URL(req.url ?? '/', otherUrl))(req, res, recorded); + /** Answers the GET of the SSE transport with its endpoint event and hands every other request to `rest`. */ + const sseEndpoint = + (rest: Handler): Handler => + (req, res, recorded) => { + if (req.method !== 'GET') return rest(req, res, recorded); + res.writeHead(200, { 'content-type': 'text/event-stream' }); + res.write('event: endpoint\ndata: /messages\n\n'); + }; + /** Answers OAuth discovery for an authorization server at the endpoint and hands every other request to `rest`. */ + const oauthDiscovery = + (rest: Handler): Handler => + (req, res, recorded) => { + const origin = endpointUrl.origin; + const metadata = req.url?.startsWith('/.well-known/oauth-protected-resource') + ? { resource: origin, authorization_servers: [origin] } + : req.url?.startsWith('/.well-known/oauth-authorization-server') + ? { + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + } + : undefined; + if (!metadata) return rest(req, res, recorded); + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(metadata)); + }; + const authProvider = (): OAuthClientProvider => ({ + redirectUrl: 'http://localhost:3000/callback', + clientMetadata: { redirect_uris: ['http://localhost:3000/callback'] }, + clientInformation: () => clientInformation, + tokens: vi.fn(), + saveTokens: () => {}, + redirectToAuthorization: () => {}, + saveCodeVerifier: () => {}, + codeVerifier: () => 'verifier-1' + }); + + type Options = StreamableHTTPClientTransportOptions & SSEClientTransportOptions; + const streamableHttpWith = async (options: StreamableHTTPClientTransportOptions): Promise => { + const created = new StreamableHTTPClientTransport(new URL('/mcp', endpointUrl), { + requestInit, + sessionId: 'session-1', + ...options + }); + created.onerror = () => {}; + transport = created; + await created.start(); + return created; + }; + const sseWith = (options: SSEClientTransportOptions): SSEClientTransport => { + const created = new SSEClientTransport(new URL('/sse', endpointUrl), { requestInit, ...options }); + created.onerror = () => {}; + transport = created; + return created; + }; + + const sseStarted = async (options: SSEClientTransportOptions): Promise => { + const created = sseWith(options); + await created.start(); + return created; + }; + const started: [string, (options: Options) => Promise][] = [ + ['Streamable HTTP', streamableHttpWith], + ['SSE', sseStarted] + ]; + + describe("'follow' leaves redirects to fetch", () => { + const follow = { redirectPolicy: 'follow' } as const; + + test('Streamable HTTP POST to another origin is followed with its headers and body', async () => { + const client = await streamableHttpWith(follow); + handle = toOther; + + await client.send(notification); + + expect(otherRequests).toMatchObject([ + { + method: 'POST', + url: '/mcp', + body: JSON.stringify(notification), + headers: { ...sent, 'content-type': 'application/json' } + } + ]); + }); + + test('Streamable HTTP GET stream to another origin is followed', async () => { + const client = await streamableHttpWith(follow); + handle = toOther; + + await client.resumeStream('event-1'); + + expect(otherRequests[0]).toMatchObject({ method: 'GET', url: '/mcp', headers: { ...sent, 'last-event-id': 'event-1' } }); + }); + + test('Streamable HTTP DELETE to another origin is followed', async () => { + const client = await streamableHttpWith(follow); + handle = toOther; + + await client.terminateSession(); + + expect(otherRequests).toMatchObject([{ method: 'DELETE', url: '/mcp', headers: sent }]); + expect(client.sessionId).toBeUndefined(); + }); + + test('SSE stream to another origin is followed', async () => { + handle = toOther; + + await sseWith(follow) + .start() + .catch(() => {}); + + expect(otherRequests[0]).toMatchObject({ method: 'GET', url: '/sse', headers: { 'x-api-key': 'key-1' } }); + }); + + test('SSE stream given its own fetch is followed to another origin', async () => { + handle = toOther; + const eventSourceInit = { fetch: (url: string | URL, init?: RequestInit) => fetch(url, init) }; + + await sseWith({ ...follow, eventSourceInit }) + .start() + .catch(() => {}); + + expect(otherRequests[0]).toMatchObject({ method: 'GET', url: '/sse', headers: { 'x-api-key': 'key-1' } }); + }); + + test('SSE POST to another origin is followed with its headers and body', async () => { + handle = sseEndpoint(toOther); + const client = await sseStarted(follow); + + await client.send(notification); + + expect(otherRequests).toMatchObject([ + { method: 'POST', url: '/messages', body: JSON.stringify(notification), headers: { 'x-api-key': 'key-1' } } + ]); + }); + + describe.each(started)('%s', (_name, create) => { + test('the token request of finishAuth is followed to another origin with its body', async () => { + handle = oauthDiscovery(sseEndpoint(toOther)); + const client = await create({ ...follow, authProvider: authProvider() }); + + await client.finishAuth('code-1').catch(() => {}); + + expect(otherRequests).toMatchObject([{ method: 'POST', url: '/token', headers: { 'x-api-key': 'key-1' } }]); + expect(otherRequests[0]!.body).toContain('code=code-1'); + }); + + test('requestInit.redirect is handed to fetch, and a redirect it returns is reported as any other response', async () => { + handle = sseEndpoint(redirectTo('/next')); + const client = await create({ ...follow, requestInit: { ...requestInit, redirect: 'manual' } }); + + await expect(client.send(request)).rejects.toThrow(/^Error POSTing to endpoint( \(HTTP 307\))?: $/); + expect(endpointRequests.filter(r => r.method === 'POST')).toHaveLength(1); + }); + }); + }); + + describe("'same-origin' is the default", () => { + describe.each(started)('%s', (_name, create) => { + test.each([undefined, 'same-origin', 'other'] as const)( + 'set to %s, a redirect to another origin is not followed', + async value => { + handle = oauthDiscovery(sseEndpoint(toOther)); + const client = await create({ redirectPolicy: value as 'same-origin', authProvider: authProvider() }); + + await expect(client.send(request)).rejects.toThrow(/Redirect to \S+ not followed/); + await client.finishAuth('code-1').catch(() => {}); + expect(endpointRequests.map(r => `${r.method} ${r.url}`)).toContain('POST /token'); + expect(otherRequests).toEqual([]); + } + ); + + test.each(['error', 'manual'] as const)( + 'a requestInit.redirect of %s is handed to fetch, and a redirect within the origin is not followed', + async redirect => { + handle = sseEndpoint(redirectTo('/next')); + const client = await create({ requestInit: { ...requestInit, redirect } }); + + await expect(client.send(request)).rejects.toThrow(); + expect(endpointRequests.filter(r => r.method === 'POST')).toHaveLength(1); + } + ); + }); + + test.each<[string, (client: StreamableHTTPClientTransport) => Promise]>([ + ['GET stream', client => client.resumeStream('event-1')], + ['DELETE', client => client.terminateSession()] + ])("a requestInit.redirect of 'error' is handed to fetch for the Streamable HTTP %s", async (_name, run) => { + handle = redirectTo('/next'); + const client = await streamableHttpWith({ requestInit: { ...requestInit, redirect: 'error' } }); + + await expect(run(client)).rejects.toThrow(); + expect(endpointRequests.map(r => r.url)).toEqual(['/mcp']); + }); + + test.each<[string, () => Promise]>([ + ['Streamable HTTP POST', () => streamableHttpWith({}).then(client => client.send(request))], + ['Streamable HTTP GET stream', () => streamableHttpWith({}).then(client => client.resumeStream('event-1'))], + ['Streamable HTTP DELETE', () => streamableHttpWith({}).then(client => client.terminateSession())], + ['SSE stream', () => sseWith({}).start()], + [ + 'SSE POST', + async () => { + handle = sseEndpoint(toOther); + const client = await sseStarted({}); + return client.send(request); + } + ] + ])('the error of a %s that is not followed names the option', async (_name, run) => { + handle = toOther; + + await expect(run()).rejects.toThrow(/Redirect to \S+ not followed.* \(redirectPolicy: 'same-origin'\)$/); + expect(otherRequests).toEqual([]); + }); + }); + }); +}); diff --git a/packages/core-internal/src/shared/transport.ts b/packages/core-internal/src/shared/transport.ts index 226f6ab0bd..1679995081 100644 --- a/packages/core-internal/src/shared/transport.ts +++ b/packages/core-internal/src/shared/transport.ts @@ -45,6 +45,72 @@ export function createFetchWithInit(baseFetch: FetchLike = fetch, baseInit?: Req }; } +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const MAX_REDIRECTS = 5; + +/** Whether `to` has the scheme, host and port of `from`, or is its https form with both on the default port. */ +export function isWithinOrigin(from: URL, to: URL): boolean { + if (from.protocol === to.protocol && from.host === to.host) return true; + return from.protocol === 'http:' && to.protocol === 'https:' && from.hostname === to.hostname && !from.port && !to.port; +} + +/** The URL that a redirect `response` to a request for `url` points at, if it names one. */ +function redirectTarget(url: string | URL, response: Response): URL | undefined { + const location = REDIRECT_STATUSES.has(response.status) ? response.headers?.get('location') : undefined; + if (!location) return undefined; + try { + return new URL(location, url); + } catch { + return undefined; + } +} + +const leftToFetch = new WeakSet(); + +/** A copy of `baseFetch` that `fetchWithinOrigin` returns as it is, which leaves redirects to `baseFetch`. */ +export function fetchLeavingRedirects(baseFetch: FetchLike): FetchLike { + const copy: FetchLike = (url, init) => baseFetch(url, init); + leftToFetch.add(copy); + return copy; +} + +/** Wraps `baseFetch` to follow a redirect only when it keeps the method and stays within the origin of the request. */ +export function fetchWithinOrigin(baseFetch: FetchLike): FetchLike { + if (leftToFetch.has(baseFetch)) return baseFetch; + return async (url, init) => { + // A request that sets `redirect` to 'error' or 'manual' is handed to the base fetch as it is. + if (init?.redirect === 'error' || init?.redirect === 'manual') return baseFetch(url, init); + const method = (init?.method ?? 'GET').toUpperCase(); + let current = url; + for (let followed = 0; ; followed++) { + // Browsers answer a manual redirect with an opaque response (status 0, no Location); it is returned as it is. + const response = await baseFetch(current, { ...init, redirect: 'manual' }); + const target = redirectTarget(current, response); + if (!target || followed === MAX_REDIRECTS) return response; + const from = new URL(current); + const keepsMethod = method === 'GET' || response.status === 307 || response.status === 308; + const keepsUserinfo = + !(target.username || target.password) || (target.username === from.username && target.password === from.password); + if (!keepsMethod || !keepsUserinfo || !isWithinOrigin(from, target)) return response; + await response.text?.().catch(() => {}); + current = target; + } + }; +} + +/** Error text for a redirect `response` to a request for `url` that was not followed, or `undefined` for any other response. */ +export function unfollowedRedirect(url: string | URL, response: Response): string | undefined { + if (response.type === 'opaqueredirect') return 'Redirect not followed: this runtime does not expose where it points'; + const target = redirectTarget(response.url || url, response); + if (!target) return undefined; + target.username = target.password = target.search = target.hash = ''; + if (target.protocol === 'http:' && new URL(response.url || url).protocol === 'https:') { + target.protocol = 'https:'; + return `Redirect from https to plain http not followed; try ${target.href} as the endpoint`; + } + return `Redirect to ${target.href} not followed; use that URL as the endpoint if it is the intended server`; +} + /** * Options for sending a JSON-RPC message. */ diff --git a/packages/core-internal/test/shared/fetchWithinOrigin.test.ts b/packages/core-internal/test/shared/fetchWithinOrigin.test.ts new file mode 100644 index 0000000000..f08e38636c --- /dev/null +++ b/packages/core-internal/test/shared/fetchWithinOrigin.test.ts @@ -0,0 +1,203 @@ +import type { FetchLike } from '../../src/shared/transport'; +import { fetchLeavingRedirects, fetchWithinOrigin, isWithinOrigin, unfollowedRedirect } from '../../src/shared/transport'; + +const redirect = (location: string, status = 307) => new Response(null, { status, headers: { location } }); +const opaqueRedirect = { type: 'opaqueredirect', status: 0, ok: false, headers: new Headers() } as Response; + +describe('isWithinOrigin', () => { + test.each([ + ['https://example.com/mcp', 'https://example.com/mcp', true], + ['https://example.com/mcp', 'https://example.com/mcp/?a=1', true], + ['http://example.com:8080/mcp', 'http://example.com:8080/other', true], + ['http://example.com/mcp', 'https://example.com/mcp', true], + ['http://example.com:80/mcp', 'https://example.com:443/mcp', true], + ['https://example.com/mcp', 'http://example.com/mcp', false], + ['https://example.com/mcp', 'https://example.com:8443/mcp', false], + ['http://example.com:8080/mcp', 'https://example.com/mcp', false], + ['http://example.com/mcp', 'https://example.com:8443/mcp', false], + ['https://example.com/mcp', 'https://other.example/mcp', false], + ['http://example.com/mcp', 'https://other.example/mcp', false], + ['https://example.com/mcp', 'https://sub.example.com/mcp', false] + ])('from %s to %s is %s', (from, to, expected) => { + expect(isWithinOrigin(new URL(from), new URL(to))).toBe(expected); + }); +}); + +describe('fetchWithinOrigin', () => { + const init = { method: 'POST', headers: { 'x-api-key': 'key-1' }, body: '{"id":1}' }; + + /** A fetch that answers from `responses` by URL, and 200 for any URL not listed. */ + const fetchAnswering = (responses: Record Response>) => + vi.fn(async url => responses[String(url)]?.() ?? new Response('ok')); + const requested = (baseFetch: ReturnType) => baseFetch.mock.calls.map(([url]) => String(url)); + + test('hands the url and init to the base fetch and returns its response', async () => { + const response = new Response('ok'); + const baseFetch = vi.fn(async () => response); + const url = new URL('https://example.com/mcp'); + + await expect(fetchWithinOrigin(baseFetch)(url, init)).resolves.toBe(response); + + expect(baseFetch).toHaveBeenCalledTimes(1); + expect(baseFetch.mock.calls[0]![0]).toBe(url); + expect(baseFetch.mock.calls[0]![1]).toEqual({ ...init, redirect: 'manual' }); + }); + + test.each([307, 308])('follows a %i within the origin with the same method, headers and body', async status => { + const baseFetch = fetchAnswering({ 'https://example.com/mcp': () => redirect('/mcp/', status) }); + + const response = await fetchWithinOrigin(baseFetch)('https://example.com/mcp', init); + + expect(response.status).toBe(200); + expect(requested(baseFetch)).toEqual(['https://example.com/mcp', 'https://example.com/mcp/']); + expect(baseFetch.mock.calls[1]![1]).toEqual({ ...init, redirect: 'manual' }); + }); + + test.each([301, 302, 303, 307, 308])('follows a %i of a GET within the origin', async status => { + const baseFetch = fetchAnswering({ 'https://example.com/mcp': () => redirect('https://example.com/mcp/', status) }); + + const response = await fetchWithinOrigin(baseFetch)('https://example.com/mcp', { headers: init.headers }); + + expect(response.status).toBe(200); + expect(requested(baseFetch)).toEqual(['https://example.com/mcp', 'https://example.com/mcp/']); + }); + + test.each([301, 302, 303])('returns a %i of a POST without following it', async status => { + const baseFetch = fetchAnswering({ 'https://example.com/mcp': () => redirect('/mcp/', status) }); + + const response = await fetchWithinOrigin(baseFetch)('https://example.com/mcp', init); + + expect(response.status).toBe(status); + expect(requested(baseFetch)).toEqual(['https://example.com/mcp']); + }); + + test('follows from http to https on the same host and default ports', async () => { + const baseFetch = fetchAnswering({ 'http://example.com/mcp': () => redirect('https://example.com/mcp') }); + + const response = await fetchWithinOrigin(baseFetch)('http://example.com/mcp', init); + + expect(response.status).toBe(200); + expect(requested(baseFetch)).toEqual(['http://example.com/mcp', 'https://example.com/mcp']); + }); + + test.each([ + 'https://other.example/mcp', + 'https://example.com:8443/mcp', + 'http://example.com/mcp', + '//other.example/mcp', + 'https://user:pass@example.com/mcp/', + 'ftp://example.com/mcp', + 'http://[' + ])('returns a redirect to %s without following it', async location => { + const baseFetch = fetchAnswering({ 'https://example.com/mcp': () => redirect(location) }); + + const response = await fetchWithinOrigin(baseFetch)('https://example.com/mcp', init); + + expect(response.status).toBe(307); + expect(requested(baseFetch)).toEqual(['https://example.com/mcp']); + }); + + test('follows a relative Location that keeps the userinfo of the requested url', async () => { + const baseFetch = fetchAnswering({ 'https://user:pass@example.com/mcp': () => redirect('/mcp/') }); + + const response = await fetchWithinOrigin(baseFetch)('https://user:pass@example.com/mcp', init); + + expect(response.status).toBe(200); + expect(requested(baseFetch)).toEqual(['https://user:pass@example.com/mcp', 'https://user:pass@example.com/mcp/']); + }); + + test('returns a Location with userinfo other than that of the requested url without following it', async () => { + const baseFetch = fetchAnswering({ 'https://user:pass@example.com/mcp': () => redirect('https://other:pass@example.com/mcp/') }); + + const response = await fetchWithinOrigin(baseFetch)('https://user:pass@example.com/mcp', init); + + expect(response.status).toBe(307); + expect(requested(baseFetch)).toEqual(['https://user:pass@example.com/mcp']); + }); + + test('follows five redirects and returns the sixth', async () => { + const baseFetch = vi.fn(async url => redirect(`/hop/${Number(String(url).split('/').pop()) + 1}`)); + + const response = await fetchWithinOrigin(baseFetch)('https://example.com/hop/0', init); + + expect(response.status).toBe(307); + expect(response.headers.get('location')).toBe('/hop/6'); + expect(baseFetch).toHaveBeenCalledTimes(6); + }); + + test.each(['error', 'manual'] as const)('hands a request that sets redirect to %s to the base fetch as it is', async mode => { + const response = redirect('/mcp/'); + const baseFetch = vi.fn(async () => response); + const asked = { ...init, redirect: mode }; + + await expect(fetchWithinOrigin(baseFetch)('https://example.com/mcp', asked)).resolves.toBe(response); + + expect(baseFetch).toHaveBeenCalledTimes(1); + expect(baseFetch.mock.calls[0]![1]).toBe(asked); + }); + + test('returns a redirect response without a readable Location as it is', async () => { + const baseFetch = vi.fn(async () => opaqueRedirect); + + await expect(fetchWithinOrigin(baseFetch)('https://example.com/mcp', init)).resolves.toBe(opaqueRedirect); + expect(baseFetch).toHaveBeenCalledTimes(1); + }); +}); + +describe('unfollowedRedirect', () => { + test('is undefined for a response that is not a redirect', () => { + expect(unfollowedRedirect('https://example.com/mcp', new Response('ok'))).toBeUndefined(); + expect(unfollowedRedirect('https://example.com/mcp', new Response(null, { status: 404 }))).toBeUndefined(); + }); + + test('names the target without userinfo, query or fragment', () => { + const text = unfollowedRedirect('https://example.com/mcp', redirect('https://user:pass@other.example/mcp?a=1#b')); + + expect(text).toBe('Redirect to https://other.example/mcp not followed; use that URL as the endpoint if it is the intended server'); + }); + + test('resolves a relative Location against the requested url', () => { + const text = unfollowedRedirect('https://example.com/mcp', redirect('/mcp/', 302)); + + expect(text).toContain('Redirect to https://example.com/mcp/ not followed'); + }); + + test('names the https form of a plain http target of an https request', () => { + const text = unfollowedRedirect('https://example.com/mcp', redirect('http://example.com/mcp/?a=1')); + + expect(text).toBe('Redirect from https to plain http not followed; try https://example.com/mcp/ as the endpoint'); + }); + + test('describes a redirect response that does not expose its target', () => { + expect(unfollowedRedirect('https://example.com/mcp', opaqueRedirect)).toBe( + 'Redirect not followed: this runtime does not expose where it points' + ); + }); +}); + +describe('fetchLeavingRedirects', () => { + const init = { method: 'POST', headers: { 'x-api-key': 'key-1' }, body: '{"id":1}' }; + + test('is returned by fetchWithinOrigin as it is, and hands the url and init to the base fetch', async () => { + const response = redirect('/mcp/'); + const baseFetch = vi.fn(async () => response); + const leaving = fetchLeavingRedirects(baseFetch); + const url = new URL('https://example.com/mcp'); + + expect(fetchWithinOrigin(leaving)).toBe(leaving); + await expect(leaving(url, init)).resolves.toBe(response); + + expect(baseFetch).toHaveBeenCalledTimes(1); + expect(baseFetch.mock.calls[0]![0]).toBe(url); + expect(baseFetch.mock.calls[0]![1]).toBe(init); + }); + + test('leaves the base fetch as fetchWithinOrigin wraps it', async () => { + const baseFetch = vi.fn(async () => new Response('ok')); + fetchLeavingRedirects(baseFetch); + + await fetchWithinOrigin(baseFetch)('https://example.com/mcp', init); + + expect(baseFetch.mock.calls[0]![1]).toEqual({ ...init, redirect: 'manual' }); + }); +}); From e16d27729ea2fae144a6d957fd0372d9eb0e6c58 Mon Sep 17 00:00:00 2001 From: Max <224885523+maxisbey@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:02:07 +0100 Subject: [PATCH 27/44] docs: close idle sessions and cap the session map in the sessions guide and examples (#2913) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- docs/serving/sessions-state-scaling.md | 39 ++++++++++++++--- examples/elicitation/server.ts | 35 +++++++++++++-- .../sessions-state-scaling.examples.ts | 33 ++++++++++++-- examples/legacy-routing/server.ts | 33 ++++++++++++-- examples/repl/server.ts | 35 ++++++++++++--- examples/sse-polling/server.ts | 33 ++++++++++++-- examples/standalone-get/server.ts | 43 ++++++++++++++++--- 7 files changed, 216 insertions(+), 35 deletions(-) diff --git a/docs/serving/sessions-state-scaling.md b/docs/serving/sessions-state-scaling.md index 0bbffe450a..7f51545648 100644 --- a/docs/serving/sessions-state-scaling.md +++ b/docs/serving/sessions-state-scaling.md @@ -23,19 +23,36 @@ The transport answers `initialize` with the generated id in an `Mcp-Session-Id` One transport instance is one session, so a sessionful deployment keeps a map: build a transport when `initialize` arrives, store it in `onsessioninitialized`, and route every later request to the transport that owns its `Mcp-Session-Id`. This Express route handles all three verbs — `POST`, the `GET` notification stream, and `DELETE` ([Serve with Express](./express.md) covers the app itself). ```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#sessions_routing" -const sessions = new Map(); +const IDLE_MS = 30 * 60_000; +const MAX_SESSIONS = 1000; + +type Session = { transport: NodeStreamableHTTPServerTransport; open: number; lastActive: number }; +const sessions = new Map(); const route = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && sessions.has(sessionId)) { - await sessions.get(sessionId)!.handleRequest(req, res, req.body); + const session = sessionId ? sessions.get(sessionId) : undefined; + if (session) { + // Count open responses so a long-running request or a listening stream is not treated as idle. + if (res.socket && !res.destroyed) { + session.open++; + res.on('close', () => { + session.open--; + session.lastActive = Date.now(); + }); + } + await session.transport.handleRequest(req, res, req.body); return; } if (!sessionId && isInitializeRequest(req.body)) { + if (sessions.size >= MAX_SESSIONS) { + res.status(503).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Too many open sessions' }, id: null }); + return; + } const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: id => { - sessions.set(id, transport); + sessions.set(id, { transport, open: 0, lastActive: Date.now() }); } }); transport.onclose = () => { @@ -57,12 +74,20 @@ const route = async (req: Request, res: Response) => { app.post('/mcp', route); app.get('/mcp', route); app.delete('/mcp', route); + +// Close sessions with nothing open and no activity for IDLE_MS. +setInterval(() => { + const cutoff = Date.now() - IDLE_MS; + for (const { transport, open, lastActive } of sessions.values()) { + if (open === 0 && lastActive < cutoff) transport.close().catch(console.error); + } +}, 60_000).unref(); ``` -The map cleans itself up: `transport.onclose` fires when the session ends, whether the client sent `DELETE` or you called `transport.close()`. A request with an unknown `Mcp-Session-Id` gets the `404` above, which tells the client to start a new session; a request with no session header at all gets the `400`, which tells it to re-send the id it already has instead of re-initializing. +`transport.onclose` removes an entry when the client sends `DELETE`, you call `transport.close()`, or the timer closes a session idle for `IDLE_MS`. At `MAX_SESSIONS`, `initialize` gets a `503`. The limit is shared by all clients and an unused session holds its place for `IDLE_MS`, so pick a `MAX_SESSIONS` that fits in memory, and on a server anyone can reach put authentication or a per-client limit in front of `initialize`. An expired id gets the `404` above, which tells the client to start a new session; a request with no session header at all gets the `400`, which tells it to re-send the id it already has instead of re-initializing. ::: tip -On shutdown, close every stored transport — `for (const [, transport] of sessions) await transport.close()` — before exiting; `close()` ends the session's SSE streams and rejects its pending requests. +On shutdown, close every stored transport — `for (const { transport } of sessions.values()) await transport.close()` — before exiting; `close()` ends the session's SSE streams and rejects its pending requests. ::: ## Resume a dropped stream @@ -102,6 +127,6 @@ Now `handler.notify.resourceUpdated(uri)` on any node publishes through the shar - `createMcpHandler` builds a fresh server per request and holds nothing between requests, so stateless nodes scale behind any load balancer with no session affinity. - Sessions belong to the hand-wired 2025-era transport: `sessionIdGenerator` turns them on, and responses carry `Mcp-Session-Id`. -- A sessionful deployment keeps one transport per session and routes every request to it by that header; unknown ids get a `404`. +- A sessionful deployment keeps one transport per session and routes every request to it by that header; it closes idle sessions, caps how many are open, and answers unknown ids with a `404`. - An `eventStore` makes a dropped SSE stream resumable: the client reconnects with `Last-Event-ID` and the transport replays what it missed. - `subscriptions/listen` scales across nodes by handing every node's `createMcpHandler` the same `ServerEventBus`. diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts index 9157a07a01..db0f99fa31 100644 --- a/examples/elicitation/server.ts +++ b/examples/elicitation/server.ts @@ -252,16 +252,35 @@ if (transport === 'stdio') { // --- legacy (2025): sessionful Streamable HTTP — push-style elicitation // requires the session (client capabilities + bidirectional SSE stream) --- - const sessions = new Map(); + const IDLE_MS = 30 * 60_000; + const MAX_SESSIONS = 1000; + + type Session = { transport: NodeStreamableHTTPServerTransport; open: number; lastActive: number }; + const sessions = new Map(); const handleLegacy = async (req: IncomingMessage, res: ServerResponse, body: unknown): Promise => { const sid = req.headers['mcp-session-id'] as string | undefined; - if (sid && sessions.has(sid)) { - await sessions.get(sid)!.handleRequest(req, res, body); + const session = sid ? sessions.get(sid) : undefined; + if (session) { + // Count open responses so a long-running request or a listening stream is not treated as idle. + if (res.socket && !res.destroyed) { + session.open++; + res.on('close', () => { + session.open--; + session.lastActive = Date.now(); + }); + } + await session.transport.handleRequest(req, res, body); } else if (!sid && isInitializeRequest(body)) { + if (sessions.size >= MAX_SESSIONS) { + res.writeHead(503, { 'content-type': 'application/json' }).end( + JSON.stringify({ jsonrpc: '2.0', error: { code: -32_000, message: 'Too many open sessions' }, id: null }) + ); + return; + } const t = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: id => { - sessions.set(id, t); + sessions.set(id, { transport: t, open: 0, lastActive: Date.now() }); } }); t.onclose = () => t.sessionId && sessions.delete(t.sessionId); @@ -280,6 +299,14 @@ if (transport === 'stdio') { } }; + // Close sessions with nothing open and no activity for IDLE_MS. + setInterval(() => { + const cutoff = Date.now() - IDLE_MS; + for (const session of sessions.values()) { + if (session.open === 0 && session.lastActive < cutoff) session.transport.close().catch(console.error); + } + }, 60_000).unref(); + // Host/Origin guards for the hand-wired `node:http` server — plain // `createServer` has no middleware chain, so compose the boolean-returning // guards from `@modelcontextprotocol/node` in front of both arms, diff --git a/examples/guides/serving/sessions-state-scaling.examples.ts b/examples/guides/serving/sessions-state-scaling.examples.ts index 9d9ef5ba41..47be994e57 100644 --- a/examples/guides/serving/sessions-state-scaling.examples.ts +++ b/examples/guides/serving/sessions-state-scaling.examples.ts @@ -35,19 +35,36 @@ import type { Express, Request, Response } from 'express'; */ function sessions_routing(app: Express, buildServer: () => McpServer) { //#region sessions_routing - const sessions = new Map(); + const IDLE_MS = 30 * 60_000; + const MAX_SESSIONS = 1000; + + type Session = { transport: NodeStreamableHTTPServerTransport; open: number; lastActive: number }; + const sessions = new Map(); const route = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; - if (sessionId && sessions.has(sessionId)) { - await sessions.get(sessionId)!.handleRequest(req, res, req.body); + const session = sessionId ? sessions.get(sessionId) : undefined; + if (session) { + // Count open responses so a long-running request or a listening stream is not treated as idle. + if (res.socket && !res.destroyed) { + session.open++; + res.on('close', () => { + session.open--; + session.lastActive = Date.now(); + }); + } + await session.transport.handleRequest(req, res, req.body); return; } if (!sessionId && isInitializeRequest(req.body)) { + if (sessions.size >= MAX_SESSIONS) { + res.status(503).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Too many open sessions' }, id: null }); + return; + } const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: id => { - sessions.set(id, transport); + sessions.set(id, { transport, open: 0, lastActive: Date.now() }); } }); transport.onclose = () => { @@ -69,6 +86,14 @@ function sessions_routing(app: Express, buildServer: () => McpServer) { app.post('/mcp', route); app.get('/mcp', route); app.delete('/mcp', route); + + // Close sessions with nothing open and no activity for IDLE_MS. + setInterval(() => { + const cutoff = Date.now() - IDLE_MS; + for (const { transport, open, lastActive } of sessions.values()) { + if (open === 0 && lastActive < cutoff) transport.close().catch(console.error); + } + }, 60_000).unref(); //#endregion sessions_routing } void sessions_routing; diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts index 33547b2384..95b52f53d7 100644 --- a/examples/legacy-routing/server.ts +++ b/examples/legacy-routing/server.ts @@ -31,16 +31,33 @@ const buildServer = (era: 'legacy' | 'modern') => { }; // --- the existing sessionful 2025 deployment, unchanged --- -const sessions = new Map(); +const IDLE_MS = 30 * 60_000; +const MAX_SESSIONS = 1000; + +type Session = { transport: NodeStreamableHTTPServerTransport; open: number; lastActive: number }; +const sessions = new Map(); const handleLegacy = async (req: Request, res: Response) => { const sid = req.headers['mcp-session-id'] as string | undefined; - if (sid && sessions.has(sid)) { - await sessions.get(sid)!.handleRequest(req, res, req.body); + const session = sid ? sessions.get(sid) : undefined; + if (session) { + // Count open responses so a long-running request or a listening stream is not treated as idle. + if (res.socket && !res.destroyed) { + session.open++; + res.on('close', () => { + session.open--; + session.lastActive = Date.now(); + }); + } + await session.transport.handleRequest(req, res, req.body); } else if (!sid && isInitializeRequest(req.body)) { + if (sessions.size >= MAX_SESSIONS) { + res.status(503).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Too many open sessions' }, id: null }); + return; + } const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: id => { - sessions.set(id, transport); + sessions.set(id, { transport, open: 0, lastActive: Date.now() }); } }); transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); @@ -54,6 +71,14 @@ const handleLegacy = async (req: Request, res: Response) => { } }; +// Close sessions with nothing open and no activity for IDLE_MS. +setInterval(() => { + const cutoff = Date.now() - IDLE_MS; + for (const { transport, open, lastActive } of sessions.values()) { + if (open === 0 && lastActive < cutoff) transport.close().catch(console.error); + } +}, 60_000).unref(); + // --- the strict modern entry alongside it --- const modern = createMcpHandler((ctx: McpRequestContext) => buildServer(ctx.era), { legacy: 'reject' }); const modernNode = toNodeHandler(modern); diff --git a/examples/repl/server.ts b/examples/repl/server.ts index 33f70bf631..cfc29d38ce 100644 --- a/examples/repl/server.ts +++ b/examples/repl/server.ts @@ -255,20 +255,37 @@ const { port } = parseExampleArgs(); // Sessionful 2025-era hosting with an in-memory event store so the REPL // client's resumability commands work (reconnect with `Last-Event-ID` replays // missed `notifications/message` events). -const sessions = new Map(); +const IDLE_MS = 30 * 60_000; +const MAX_SESSIONS = 1000; + +type Session = { transport: NodeStreamableHTTPServerTransport; open: number; lastActive: number }; +const sessions = new Map(); const eventStore = new InMemoryEventStore(); const app = createMcpExpressApp(); app.all('/mcp', async (req: Request, res: Response) => { const sid = req.headers['mcp-session-id'] as string | undefined; - if (sid && sessions.has(sid)) { - await sessions.get(sid)!.handleRequest(req, res, req.body); + const session = sid ? sessions.get(sid) : undefined; + if (session) { + // Count open responses so a long-running request or a listening stream is not treated as idle. + if (res.socket && !res.destroyed) { + session.open++; + res.on('close', () => { + session.open--; + session.lastActive = Date.now(); + }); + } + await session.transport.handleRequest(req, res, req.body); } else if (!sid && isInitializeRequest(req.body)) { + if (sessions.size >= MAX_SESSIONS) { + res.status(503).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Too many open sessions' }, id: null }); + return; + } const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, // resumability — events are persisted for replay on GET reconnect onsessioninitialized: id => { - sessions.set(id, transport); + sessions.set(id, { transport, open: 0, lastActive: Date.now() }); } }); transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); @@ -281,9 +298,17 @@ app.all('/mcp', async (req: Request, res: Response) => { } }); +// Close sessions with nothing open and no activity for IDLE_MS. +setInterval(() => { + const cutoff = Date.now() - IDLE_MS; + for (const { transport, open, lastActive } of sessions.values()) { + if (open === 0 && lastActive < cutoff) transport.close().catch(console.error); + } +}, 60_000).unref(); + app.listen(port, () => console.error(`[server] REPL playground listening on http://127.0.0.1:${port}/mcp`)); process.on('SIGINT', async () => { - for (const t of sessions.values()) await t.close(); + for (const { transport } of sessions.values()) await transport.close(); process.exit(0); }); diff --git a/examples/sse-polling/server.ts b/examples/sse-polling/server.ts index dbc011a84b..6ddd54e91a 100644 --- a/examples/sse-polling/server.ts +++ b/examples/sse-polling/server.ts @@ -93,23 +93,40 @@ app.use(cors()); // Create event store for resumability const eventStore = new InMemoryEventStore(); +const IDLE_MS = 30 * 60_000; +const MAX_SESSIONS = 1000; + +type Session = { transport: NodeStreamableHTTPServerTransport; open: number; lastActive: number }; // Track transports by session ID for session reuse -const transports = new Map(); +const transports = new Map(); // Handle all MCP requests (standard sessionful routing: known sid → reuse; // no sid + initialize → new session; unknown sid → 404; otherwise → 400). app.all('/mcp', async (req: Request, res: Response) => { const sid = req.headers['mcp-session-id'] as string | undefined; - if (sid && transports.has(sid)) { - await transports.get(sid)!.handleRequest(req, res, req.body); + const session = sid ? transports.get(sid) : undefined; + if (session) { + // Count open responses so a long-running request or a listening stream is not treated as idle. + if (res.socket && !res.destroyed) { + session.open++; + res.on('close', () => { + session.open--; + session.lastActive = Date.now(); + }); + } + await session.transport.handleRequest(req, res, req.body); } else if (!sid && isInitializeRequest(req.body)) { + if (transports.size >= MAX_SESSIONS) { + res.status(503).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Too many open sessions' }, id: null }); + return; + } const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore, retryInterval: 300, // Default retry interval for priming events onsessioninitialized: id => { console.error(`[${id}] Session initialized`); - transports.set(id, transport); + transports.set(id, { transport, open: 0, lastActive: Date.now() }); } }); transport.onclose = () => transport.sessionId && transports.delete(transport.sessionId); @@ -123,6 +140,14 @@ app.all('/mcp', async (req: Request, res: Response) => { } }); +// Close sessions with nothing open and no activity for IDLE_MS. +setInterval(() => { + const cutoff = Date.now() - IDLE_MS; + for (const { transport, open, lastActive } of transports.values()) { + if (open === 0 && lastActive < cutoff) transport.close().catch(console.error); + } +}, 60_000).unref(); + const { port } = parseExampleArgs(); app.listen(port, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); diff --git a/examples/standalone-get/server.ts b/examples/standalone-get/server.ts index 5852cb40e8..40daffa002 100644 --- a/examples/standalone-get/server.ts +++ b/examples/standalone-get/server.ts @@ -55,18 +55,38 @@ function buildServer(): McpServer { return server; } -const sessions = new Map(); +const IDLE_MS = 30 * 60_000; +const MAX_SESSIONS = 1000; + +type Session = { transport: NodeStreamableHTTPServerTransport; open: number; lastActive: number }; +const sessions = new Map(); const app = createMcpExpressApp(); +// Count open responses so a long-running request or a listening stream is not treated as idle. +const trackResponse = (session: Session, res: Response) => { + if (!res.socket || res.destroyed) return; + session.open++; + res.on('close', () => { + session.open--; + session.lastActive = Date.now(); + }); +}; + app.post('/mcp', async (req: Request, res: Response) => { const sid = req.headers['mcp-session-id'] as string | undefined; - if (sid && sessions.has(sid)) { - await sessions.get(sid)!.handleRequest(req, res, req.body); + const session = sid ? sessions.get(sid) : undefined; + if (session) { + trackResponse(session, res); + await session.transport.handleRequest(req, res, req.body); } else if (!sid && isInitializeRequest(req.body)) { + if (sessions.size >= MAX_SESSIONS) { + res.status(503).json({ jsonrpc: '2.0', error: { code: -32_000, message: 'Too many open sessions' }, id: null }); + return; + } const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: id => { - sessions.set(id, transport); + sessions.set(id, { transport, open: 0, lastActive: Date.now() }); } }); transport.onclose = () => transport.sessionId && sessions.delete(transport.sessionId); @@ -83,16 +103,25 @@ app.post('/mcp', async (req: Request, res: Response) => { // session termination per the MCP spec) route to the session's transport. const sessionVerb = async (req: Request, res: Response) => { const sid = req.headers['mcp-session-id'] as string | undefined; - const t = sid ? sessions.get(sid) : undefined; - if (!t) { + const session = sid ? sessions.get(sid) : undefined; + if (!session) { res.status(sid ? 404 : 400).send(sid ? 'Session not found' : 'Missing session ID'); return; } - await t.handleRequest(req, res); + trackResponse(session, res); + await session.transport.handleRequest(req, res); }; app.get('/mcp', sessionVerb); app.delete('/mcp', sessionVerb); +// Close sessions with nothing open and no activity for IDLE_MS. +setInterval(() => { + const cutoff = Date.now() - IDLE_MS; + for (const { transport, open, lastActive } of sessions.values()) { + if (open === 0 && lastActive < cutoff) transport.close().catch(console.error); + } +}, 60_000).unref(); + const { port } = parseExampleArgs(); app.listen(port, () => { console.error(`[server] listening on http://127.0.0.1:${port}/mcp`); From 84804c22e45a662675a198f853b7f00063838a8d Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:03:49 +0100 Subject: [PATCH 28/44] fix(server): refuse a second connect and stateless transport reuse (#2918) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/one-instance-per-request.md | 28 ++++ docs/advanced/low-level-server.md | 5 +- .../advanced/low-level-server.examples.ts | 7 +- packages/middleware/express/README.md | 5 +- packages/middleware/fastify/README.md | 8 +- packages/middleware/hono/README.md | 12 +- packages/middleware/node/README.md | 6 +- .../node/src/streamableHttp.examples.ts | 10 +- .../middleware/node/src/streamableHttp.ts | 8 +- .../node/test/streamableHttp.test.ts | 58 +++++--- .../server/src/server/createMcpHandler.ts | 11 ++ packages/server/src/server/server.ts | 14 +- .../src/server/streamableHttp.examples.ts | 12 +- packages/server/src/server/streamableHttp.ts | 15 +++ .../server/classificationCarrierPin.test.ts | 11 +- .../test/server/createMcpHandler.test.ts | 127 ++++++++++++++++++ packages/server/test/server/server.test.ts | 26 ++++ .../server/test/server/streamableHttp.test.ts | 30 ++++- test/e2e/requirements.ts | 7 +- .../test/client/discoverRoundtrip.test.ts | 38 +++--- .../test/client/versionNegotiation.test.ts | 20 ++- test/integration/test/server/bun.test.ts | 17 ++- .../test/server/cloudflareWorkers.test.ts | 31 +++-- test/integration/test/server/deno.test.ts | 42 +++--- .../stateManagementStreamableHttp.test.ts | 19 ++- 25 files changed, 449 insertions(+), 118 deletions(-) create mode 100644 .changeset/one-instance-per-request.md diff --git a/.changeset/one-instance-per-request.md b/.changeset/one-instance-per-request.md new file mode 100644 index 0000000000..fe83fc4193 --- /dev/null +++ b/.changeset/one-instance-per-request.md @@ -0,0 +1,28 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/node': patch +'@modelcontextprotocol/express': patch +'@modelcontextprotocol/fastify': patch +'@modelcontextprotocol/hono': patch +--- + +A `Server` or `McpServer` now serves one connection at a time, and a Streamable HTTP server transport without sessions (`sessionIdGenerator: undefined`) serves one request. An app that uses one server object, or one stateless transport, for every HTTP request fails on the second request after this upgrade. Build the server and the transport per request instead. + +What keeps working without a change: + +- `createMcpHandler(buildServer)` and `serveStdio(buildServer)`, where `buildServer` returns a new server on every call. +- A handler that builds a new server and a new stateless transport for each request. +- One server and one transport per session (a transport with a `sessionIdGenerator`). +- Connecting a server again after `close()`. +- `Client`. + +What fails now, how it shows, and what to change: + +- One server object with a new stateless transport per request (`const server = new McpServer(...)` outside the handler, `await server.connect(transport)` inside it): the second HTTP request the process receives fails, and so does every later one. `connect()` rejects with an `SdkError` of code `ALREADY_CONNECTED`. If the handler closes the transport when the response ends, requests that arrive one after the other still work and a request that overlaps another one fails. Change: move `new McpServer(...)` and its registrations into the handler. +- One stateless transport for every request (a transport built once with `sessionIdGenerator: undefined`): the second HTTP request fails. `WebStandardStreamableHTTPServerTransport.handleRequest()` rejects with `Stateless transport cannot be reused across requests. Create a new transport per request.`, and `NodeStreamableHTTPServerTransport.handleRequest()` answers `500`. Change: build the server and the transport inside the handler and connect them there. +- `createMcpHandler(() => server)` with a server built once: a request that arrives after the previous response has been read to its end still works. A request that arrives while another one is being served is answered `500` with the JSON-RPC error `-32603` (`Internal server error`); the reason is reported only through the `onerror` option. Change: pass a function that builds the server, as in `createMcpHandler(buildServer)`. +- One server object for every session: the `initialize` request of the second session fails with `ALREADY_CONNECTED`. Change: build a server per session. + +What the caller sees when `connect()` or `handleRequest()` rejects depends on the host. Express 5, Fastify and Hono answer `500`. A plain `node:http` listener without its own error handling gets an unhandled rejection, which ends the process. + +The README examples of `@modelcontextprotocol/express`, `@modelcontextprotocol/fastify`, `@modelcontextprotocol/hono` and `@modelcontextprotocol/node`, and the handler examples in the JSDoc of `WebStandardStreamableHTTPServerTransport` and `NodeStreamableHTTPServerTransport`, now build a server and a transport per request. diff --git a/docs/advanced/low-level-server.md b/docs/advanced/low-level-server.md index 106abe05d3..eb04933fee 100644 --- a/docs/advanced/low-level-server.md +++ b/docs/advanced/low-level-server.md @@ -113,8 +113,9 @@ Keeping the schema you advertise in `tools/list` identical to the one you valida `serveStdio` — from `@modelcontextprotocol/server/stdio` — and `createMcpHandler` each take an `McpServerFactory`, and the factory returns either an `McpServer` or a `Server`. ```ts source="../../examples/guides/advanced/low-level-server.examples.ts#lowLevel_serve" -serveStdio(() => server); -createMcpHandler(() => server); +// `buildServer` wraps the construction above: the factory runs per connection (stdio) or per request (HTTP). +serveStdio(buildServer); +createMcpHandler(buildServer); ``` Every serving recipe — [stdio](../serving/stdio.md), [HTTP](../serving/http.md) — applies to this server unchanged. diff --git a/examples/guides/advanced/low-level-server.examples.ts b/examples/guides/advanced/low-level-server.examples.ts index ba9b8e965c..0d8bd40847 100644 --- a/examples/guides/advanced/low-level-server.examples.ts +++ b/examples/guides/advanced/low-level-server.examples.ts @@ -130,10 +130,13 @@ await server.close(); // take over this process's stdin/stdout, so the harness never calls this. // --------------------------------------------------------------------------- +declare function buildServer(): Server; + function lowLevel_serve() { //#region lowLevel_serve - serveStdio(() => server); - createMcpHandler(() => server); + // `buildServer` wraps the construction above: the factory runs per connection (stdio) or per request (HTTP). + serveStdio(buildServer); + createMcpHandler(buildServer); //#endregion lowLevel_serve } void lowLevel_serve; diff --git a/packages/middleware/express/README.md b/packages/middleware/express/README.md index b837f5b623..949dbe3cfd 100644 --- a/packages/middleware/express/README.md +++ b/packages/middleware/express/README.md @@ -48,11 +48,10 @@ import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; const app = createMcpExpressApp(); -const server = new McpServer({ name: 'my-server', version: '1.0.0' }); app.post('/mcp', async (req, res) => { - // Stateless example: create a transport per request. - // For stateful mode (sessions), keep a transport instance around and reuse it. + // Stateless example: create a server and a transport per request (with sessions: one pair per session). + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res, req.body); diff --git a/packages/middleware/fastify/README.md b/packages/middleware/fastify/README.md index 2355170002..e573ecc1b0 100644 --- a/packages/middleware/fastify/README.md +++ b/packages/middleware/fastify/README.md @@ -42,24 +42,24 @@ import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; const app = createMcpFastifyApp(); -const mcpServer = new McpServer({ name: 'my-server', version: '1.0.0' }); app.post('/mcp', async (request, reply) => { - // Stateless example: create a transport per request. - // For stateful mode (sessions), keep a transport instance around and reuse it. + // Stateless example: create a server and a transport per request (with sessions: one pair per session). + const mcpServer = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await mcpServer.connect(transport); // Clean up when the client closes the connection (e.g. during SSE streaming). reply.raw.on('close', () => { transport.close(); + mcpServer.close(); }); await transport.handleRequest(request.raw, reply.raw, request.body); }); ``` -If you create a new `McpServer` per request in stateless mode, also call `mcpServer.close()` in the `close` handler. To reject non-POST requests with 405 Method Not Allowed, add routes for GET and DELETE that send a JSON-RPC error response. +To reject non-POST requests with 405 Method Not Allowed, add routes for GET and DELETE that send a JSON-RPC error response. ### Host header validation (DNS rebinding protection) diff --git a/packages/middleware/hono/README.md b/packages/middleware/hono/README.md index f591888506..b2827954d6 100644 --- a/packages/middleware/hono/README.md +++ b/packages/middleware/hono/README.md @@ -30,12 +30,14 @@ npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; import { createMcpHonoApp } from '@modelcontextprotocol/hono'; -const server = new McpServer({ name: 'my-server', version: '1.0.0' }); -const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); -await server.connect(transport); - const app = createMcpHonoApp(); -app.all('/mcp', c => transport.handleRequest(c.req.raw, { parsedBody: c.get('parsedBody') })); +app.all('/mcp', async c => { + // Stateless example: create a server and a transport per request. + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); + return transport.handleRequest(c.req.raw, { parsedBody: c.get('parsedBody') }); +}); ``` ### Host header validation (DNS rebinding protection) diff --git a/packages/middleware/node/README.md b/packages/middleware/node/README.md index bc279cae3a..63111e964e 100644 --- a/packages/middleware/node/README.md +++ b/packages/middleware/node/README.md @@ -33,10 +33,11 @@ import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; -const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const app = createMcpExpressApp(); app.post('/mcp', async (req, res) => { + // Stateless example: create a server and a transport per request. + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); @@ -59,13 +60,12 @@ import { createServer } from 'node:http'; import { localhostHostValidation, localhostOriginValidation, NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { McpServer } from '@modelcontextprotocol/server'; -const server = new McpServer({ name: 'my-server', version: '1.0.0' }); - const validateHost = localhostHostValidation(); const validateOrigin = localhostOriginValidation(); createServer(async (req, res) => { if (!validateHost(req, res) || !validateOrigin(req, res)) return; + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); await server.connect(transport); await transport.handleRequest(req, res); diff --git a/packages/middleware/node/src/streamableHttp.examples.ts b/packages/middleware/node/src/streamableHttp.examples.ts index 4ac8778c92..6356059403 100644 --- a/packages/middleware/node/src/streamableHttp.examples.ts +++ b/packages/middleware/node/src/streamableHttp.examples.ts @@ -47,10 +47,14 @@ declare const app: { post(path: string, handler: (req: IncomingMessage & { body? /** * Example: Using with a pre-parsed request body (e.g. Express). */ -function NodeStreamableHTTPServerTransport_express(transport: NodeStreamableHTTPServerTransport) { +function NodeStreamableHTTPServerTransport_express() { //#region NodeStreamableHTTPServerTransport_express - app.post('/mcp', (req, res) => { - transport.handleRequest(req, res, req.body); + app.post('/mcp', async (req, res) => { + // Stateless example: create a server and a transport per request. + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); + await transport.handleRequest(req, res, req.body); }); //#endregion NodeStreamableHTTPServerTransport_express } diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 779e582ac9..3fb96d3081 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -66,8 +66,12 @@ export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServ * * @example Using with a pre-parsed request body (e.g. Express) * ```ts source="./streamableHttp.examples.ts#NodeStreamableHTTPServerTransport_express" - * app.post('/mcp', (req, res) => { - * transport.handleRequest(req, res, req.body); + * app.post('/mcp', async (req, res) => { + * // Stateless example: create a server and a transport per request. + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + * await server.connect(transport); + * await transport.handleRequest(req, res, req.body); * }); * ``` */ diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index 140717c6bb..901b78446f 100644 --- a/packages/middleware/node/test/streamableHttp.test.ts +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -150,15 +150,11 @@ function expectErrorResponse( } describe('Zod v4', () => { /** - * Helper to create and start test HTTP server with MCP setup + * Helper to connect a new MCP server to a new transport */ - async function createTestServer(config?: TestServerConfig): Promise<{ - server: Server; - transport: NodeStreamableHTTPServerTransport; - mcpServer: McpServer; - baseUrl: URL; - }> { - config ??= { sessionIdGenerator: () => randomUUID() }; + async function createTestPair( + config: TestServerConfig + ): Promise<{ transport: NodeStreamableHTTPServerTransport; mcpServer: McpServer }> { const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); mcpServer.registerTool( @@ -183,6 +179,21 @@ describe('Zod v4', () => { await mcpServer.connect(transport); + return { transport, mcpServer }; + } + + /** + * Helper to create and start test HTTP server with MCP setup + */ + async function createTestServer(config?: TestServerConfig): Promise<{ + server: Server; + transport: NodeStreamableHTTPServerTransport; + mcpServer: McpServer; + baseUrl: URL; + }> { + config ??= { sessionIdGenerator: () => randomUUID() }; + const { transport, mcpServer } = await createTestPair(config); + const server = createServer(async (req, res) => { try { await (config.customRequestHandler ? config.customRequestHandler(req, res) : transport.handleRequest(req, res)); @@ -1603,7 +1614,15 @@ describe('Zod v4', () => { let baseUrl: URL; beforeEach(async () => { - const result = await createTestServer({ sessionIdGenerator: undefined }); + const result = await createTestServer({ + sessionIdGenerator: undefined, + // A stateless transport serves one request: connect a new pair for each. + customRequestHandler: async (req, res) => { + const pair = await createTestPair({ sessionIdGenerator: undefined }); + res.on('close', () => void pair.transport.close()); + await pair.transport.handleRequest(req, res); + } + }); server = result.server; transport = result.transport; baseUrl = result.baseUrl; @@ -1655,9 +1674,8 @@ describe('Zod v4', () => { expect(response2.status).toBe(200); }); - it('should reject second SSE stream even in stateless mode', async () => { - // Despite no session ID requirement, the transport still only allows - // one standalone SSE stream at a time + it('should open an SSE stream per request in stateless mode', async () => { + // Each request has its own transport, so the one-stream limit of a transport is never reached // Initialize the server first await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); @@ -1672,7 +1690,7 @@ describe('Zod v4', () => { }); expect(stream1.status).toBe(200); - // Open second SSE stream - should still be rejected, stateless mode still only allows one + // Open second SSE stream - served by its own transport const stream2 = await fetch(baseUrl, { method: 'GET', headers: { @@ -1680,7 +1698,17 @@ describe('Zod v4', () => { 'mcp-protocol-version': '2025-11-25' } }); - expect(stream2.status).toBe(409); // Conflict - only one stream allowed + expect(stream2.status).toBe(200); + }); + + it('should answer 500 to a second request on the same transport', async () => { + const shared = await createTestServer({ sessionIdGenerator: undefined }); + try { + expect((await sendPostRequest(shared.baseUrl, TEST_MESSAGES.initialize)).status).toBe(200); + expect((await sendPostRequest(shared.baseUrl, TEST_MESSAGES.toolsList)).status).toBe(500); + } finally { + await stopTestServer(shared); + } }); }); @@ -2942,7 +2970,7 @@ describe('Zod v4', () => { describe('Combined validations', () => { it('should validate both host and origin when both are configured', async () => { const result = await createTestServerWithDnsProtection({ - sessionIdGenerator: undefined, + sessionIdGenerator: () => randomUUID(), allowedHosts: ['localhost'], allowedOrigins: ['http://localhost:3001'], enableDnsRebindingProtection: true diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index 52ac41fd20..b7d27662c9 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -297,6 +297,13 @@ function internalServerErrorResponse(id: RequestId | null = null): Response { return jsonRpcErrorResponse(500, -32_603, 'Internal server error', undefined, id); } +/** Throws when the factory returned an instance that is still connected to another exchange. */ +function assertNotServing(server: Server): void { + if (server.transport !== undefined) { + throw new Error('The factory returned an instance that is still serving another request. Return a new instance for each request.'); + } +} + /* ------------------------------------------------------------------------ * * The default legacy fallback * ------------------------------------------------------------------------ */ @@ -786,6 +793,7 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa requestInfo: request }); const server = product instanceof McpServer ? product.server : product; + assertNotServing(server); // Entry-handled `subscriptions/listen`: the router owns ack-first / // per-stream filtering / subscription-id stamping / keepalive / @@ -852,6 +860,9 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } } + // Checked again after the awaits above, before anything is written to the instance. + assertNotServing(server); + // Era-write at instance binding, then modern-only handler installation — // both before the instance is connected to the per-request transport. setNegotiatedProtocolVersion(server, claimedRevision); diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index 2eee0b9ff4..032fdc9c7f 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -35,7 +35,8 @@ import type { ServerCapabilities, ServerContext, ToolResultContent, - ToolUseContent + ToolUseContent, + Transport } from '@modelcontextprotocol/core-internal'; import { assertValidCacheHint, @@ -351,6 +352,17 @@ export class Server extends Protocol { } } + /** Attaches to the given transport; rejects while this instance is connected to another one. */ + override async connect(transport: Transport): Promise { + if (this.transport !== undefined) { + throw new SdkError( + SdkErrorCode.AlreadyConnected, + 'Use a separate Server instance per connection: this instance is already connected to a transport. To connect it to a new transport, call close() first.' + ); + } + await super.connect(transport); + } + /** * Registers the built-in `logging/setLevel` request handler. * diff --git a/packages/server/src/server/streamableHttp.examples.ts b/packages/server/src/server/streamableHttp.examples.ts index 678c43c928..cdfad542f1 100644 --- a/packages/server/src/server/streamableHttp.examples.ts +++ b/packages/server/src/server/streamableHttp.examples.ts @@ -43,9 +43,13 @@ declare const app: { all(path: string, handler: (c: { req: { raw: Request } }) = /** * Example: Using with Hono.js. */ -function WebStandardStreamableHTTPServerTransport_hono(transport: WebStandardStreamableHTTPServerTransport) { +function WebStandardStreamableHTTPServerTransport_hono() { //#region WebStandardStreamableHTTPServerTransport_hono app.all('/mcp', async c => { + // Stateless example: create a server and a transport per request. + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); return transport.handleRequest(c.req.raw); }); //#endregion WebStandardStreamableHTTPServerTransport_hono @@ -54,10 +58,14 @@ function WebStandardStreamableHTTPServerTransport_hono(transport: WebStandardStr /** * Example: Using with Cloudflare Workers. */ -function WebStandardStreamableHTTPServerTransport_workers(transport: WebStandardStreamableHTTPServerTransport) { +function WebStandardStreamableHTTPServerTransport_workers() { //#region WebStandardStreamableHTTPServerTransport_workers const worker = { async fetch(request: Request): Promise { + // Stateless example: create a server and a transport per request. + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await server.connect(transport); return transport.handleRequest(request); } }; diff --git a/packages/server/src/server/streamableHttp.ts b/packages/server/src/server/streamableHttp.ts index e57c6e2e82..2484ae19b0 100644 --- a/packages/server/src/server/streamableHttp.ts +++ b/packages/server/src/server/streamableHttp.ts @@ -235,6 +235,10 @@ export interface HandleRequestOptions { * @example Hono.js * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_hono" * app.all('/mcp', async c => { + * // Stateless example: create a server and a transport per request. + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + * await server.connect(transport); * return transport.handleRequest(c.req.raw); * }); * ``` @@ -243,6 +247,10 @@ export interface HandleRequestOptions { * ```ts source="./streamableHttp.examples.ts#WebStandardStreamableHTTPServerTransport_workers" * const worker = { * async fetch(request: Request): Promise { + * // Stateless example: create a server and a transport per request. + * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + * const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + * await server.connect(transport); * return transport.handleRequest(request); * } * }; @@ -252,6 +260,7 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { // when sessionId is not set (undefined), it means the transport is in stateless mode private sessionIdGenerator: (() => string) | undefined; private _started: boolean = false; + private _hasHandledRequest: boolean = false; private _closed: boolean = false; private _streamMapping: Map = new Map(); private _requestToStreamMapping: Map = new Map(); @@ -415,6 +424,12 @@ export class WebStandardStreamableHTTPServerTransport implements Transport { return this.createJsonErrorResponse(404, -32_001, 'Session not found'); } + // A stateless transport (no sessionIdGenerator) serves exactly one request. + if (!this.sessionIdGenerator && this._hasHandledRequest) { + throw new Error('Stateless transport cannot be reused across requests. Create a new transport per request.'); + } + this._hasHandledRequest = true; + // Validate request headers for DNS rebinding protection const validationError = this.validateRequestHeaders(req); if (validationError) { diff --git a/packages/server/test/server/classificationCarrierPin.test.ts b/packages/server/test/server/classificationCarrierPin.test.ts index 4694f65abb..3000b6137d 100644 --- a/packages/server/test/server/classificationCarrierPin.test.ts +++ b/packages/server/test/server/classificationCarrierPin.test.ts @@ -73,18 +73,21 @@ const modernEnvelope = { describe('B-2: hand-wired legacy-transport traffic is never Protocol-classified', () => { it('extra.request is set and extra.classification stays unset for every delivered message', async () => { - const { server, seen, post } = await setupHandWired(); + const plain = await setupHandWired(); + const stamped = await setupHandWired(); - await post(toolsCall()); - await post(toolsCall(modernEnvelope)); + await plain.post(toolsCall()); + await stamped.post(toolsCall(modernEnvelope)); + const seen = [...plain.seen, ...stamped.seen]; expect(seen.length).toBeGreaterThanOrEqual(2); for (const { extra } of seen) { expect(extra?.request).toBeInstanceOf(Request); expect(extra?.classification).toBeUndefined(); } - await server.close(); + await plain.server.close(); + await stamped.server.close(); }); it('a modern-stamped body through the legacy transport gets today’s exact legacy semantics, byte-identical', async () => { diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index 8de6672f49..a1b9ce7986 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -960,3 +960,130 @@ describe('createMcpHandler — keepAliveMs', () => { } }); }); + +describe('createMcpHandler — a factory that returns the same instance for every request', () => { + /** One instance whose `report` tool holds the call named `first` until released, then notifies and answers with its own text. */ + function heldInstance(): { instance: McpServer; started: Promise; release: () => void } { + let release!: () => void; + const gate = new Promise(resolve => { + release = resolve; + }); + let markStarted!: () => void; + const started = new Promise(resolve => { + markStarted = resolve; + }); + const instance = new McpServer({ name: 'entry-test-server', version: '1.0.0' }); + instance.registerTool('report', { inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx) => { + if (text === 'first') { + markStarted(); + await gate; + } + await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'tok', progress: 1, message: text } }); + return { content: [{ type: 'text', text: `result for ${text}` }] }; + }); + return { instance, started, release }; + } + + const modernCall = (text: string, clientName = 'entry-test-client'): Request => + postRequest( + modernToolsCall( + 'report', + { text }, + { ...ENVELOPE, [CLIENT_INFO_META_KEY]: { name: clientName, version: '1.0.0' }, progressToken: 'tok' } + ) + ); + const legacyCall = (text: string): Request => + postRequest({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'report', arguments: { text }, _meta: { progressToken: 'tok' } } + }); + const legs = [ + { leg: 'modern', call: modernCall, reported: 'still serving another request' }, + { leg: 'legacy', call: legacyCall, reported: 'already connected to a transport' } + ]; + + it.each(legs)('answers 500 to an overlapping request and completes the one in progress ($leg)', async ({ call, reported }) => { + const { instance, started, release } = heldInstance(); + const onerror = vi.fn(); + const handler = createMcpHandler(() => instance, { onerror }); + + const inProgress = handler.fetch(call('first')); + await started; + + const overlapping = await handler.fetch(call('second')); + expect(overlapping.status).toBe(500); + expect(((await overlapping.json()) as JSONRPCErrorBody).error).toEqual({ code: -32_603, message: 'Internal server error' }); + expect(onerror).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining(reported) })); + + release(); + const response = await inProgress; + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain('"message":"first"'); + expect(text).toContain('result for first'); + expect(text).not.toContain('second'); + }); + + it.each(legs)('serves one of two requests that reach an async factory together ($leg)', async ({ call }) => { + const { instance } = heldInstance(); + let ready: Promise | undefined; + const handler = createMcpHandler(async () => { + ready ??= new Promise(resolve => setTimeout(resolve, 10)); + await ready; + return instance; + }); + + const responses = await Promise.all([handler.fetch(call('one')), handler.fetch(call('two'))]); + expect(responses.filter(response => response.status === 200)).toHaveLength(1); + expect(responses.filter(response => response.status === 500)).toHaveLength(1); + + const [own, other] = responses[0]!.status === 200 ? ['one', 'two'] : ['two', 'one']; + const text = await responses.find(response => response.status === 200)!.text(); + expect(text).toContain(`result for ${own}`); + expect(text).not.toContain(other); + }); + + it('keeps the client identity of the request in progress when an overlapping request is answered 500', async () => { + const { instance, started, release } = heldInstance(); + const handler = createMcpHandler(() => instance); + + const inProgress = handler.fetch(modernCall('first', 'client-of-first')); + await started; + const overlapping = await handler.fetch(modernCall('second', 'client-of-second')); + expect(overlapping.status).toBe(500); + expect(instance.server.getClientVersion()?.name).toBe('client-of-first'); + + release(); + expect((await inProgress).status).toBe(200); + }); + + it('answers 500 to an overlapping subscriptions/listen and completes the request in progress', async () => { + const { instance, started, release } = heldInstance(); + const handler = createMcpHandler(() => instance); + + const inProgress = handler.fetch(modernCall('first')); + await started; + const listen = { jsonrpc: '2.0', id: 1, method: 'subscriptions/listen', params: { _meta: ENVELOPE, notifications: {} } }; + expect((await handler.fetch(postRequest(listen))).status).toBe(500); + + release(); + const response = await inProgress; + expect(response.status).toBe(200); + expect(await response.text()).toContain('result for first'); + }); + + it.each(legs)('serves requests that arrive one after the other ($leg)', async ({ call }) => { + const { instance } = heldInstance(); + const onerror = vi.fn(); + const handler = createMcpHandler(() => instance, { onerror }); + + for (const text of ['one', 'two', 'three']) { + const response = await handler.fetch(call(text)); + expect(response.status).toBe(200); + expect(await response.text()).toContain(`result for ${text}`); + } + expect(onerror).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/test/server/server.test.ts b/packages/server/test/server/server.test.ts index 0a96a0bb66..ff60a1fe75 100644 --- a/packages/server/test/server/server.test.ts +++ b/packages/server/test/server/server.test.ts @@ -242,4 +242,30 @@ describe('Server', () => { expect(result.structuredContent).toEqual({ ok: true }); }); }); + + describe('connect', () => { + it('rejects while the instance is connected to a transport and leaves that connection in place', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + const [, first] = InMemoryTransport.createLinkedPair(); + const [, second] = InMemoryTransport.createLinkedPair(); + await server.connect(first); + + await expect(server.connect(second)).rejects.toMatchObject({ + code: 'ALREADY_CONNECTED', + message: + 'Use a separate Server instance per connection: this instance is already connected to a transport. To connect it to a new transport, call close() first.' + }); + + expect(server.transport).toBe(first); + expect(second.onmessage).toBeUndefined(); + }); + + it('connects again after close()', async () => { + const server = new Server({ name: 'test', version: '1.0.0' }, { capabilities: {} }); + for (const requestedVersion of [LATEST_PROTOCOL_VERSION, OLDER_SUPPORTED_VERSION]) { + expect(await initializeServer(server, requestedVersion)).toBe(requestedVersion); + await server.close(); + } + }); + }); }); diff --git a/packages/server/test/server/streamableHttp.test.ts b/packages/server/test/server/streamableHttp.test.ts index a0fa60efc6..937316ebed 100644 --- a/packages/server/test/server/streamableHttp.test.ts +++ b/packages/server/test/server/streamableHttp.test.ts @@ -431,7 +431,7 @@ describe('Zod v4', () => { let transport: WebStandardStreamableHTTPServerTransport; let mcpServer: McpServer; - beforeEach(async () => { + async function connectStatelessPair(): Promise { mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }, { capabilities: { logging: {} } }); mcpServer.registerTool( @@ -447,7 +447,9 @@ describe('Zod v4', () => { }); await mcpServer.connect(transport); - }); + } + + beforeEach(connectStatelessPair); afterEach(async () => { await transport.close(); @@ -465,13 +467,24 @@ describe('Zod v4', () => { // Initialize const initRequest = createRequest('POST', TEST_MESSAGES.initialize); await transport.handleRequest(initRequest); + await transport.close(); - // Subsequent request without session ID should work + // Subsequent request without session ID should work on its own transport + await connectStatelessPair(); const request = createRequest('POST', TEST_MESSAGES.toolsList); const response = await transport.handleRequest(request); expect(response.status).toBe(200); }); + + it('should reject a second request on the same transport', async () => { + const first = await transport.handleRequest(createRequest('POST', TEST_MESSAGES.initialize)); + expect(first.status).toBe(200); + + await expect(transport.handleRequest(createRequest('POST', TEST_MESSAGES.toolsList))).rejects.toThrow( + 'Stateless transport cannot be reused across requests. Create a new transport per request.' + ); + }); }); describe('HTTPServerTransport - JSON Response Mode', () => { @@ -1595,12 +1608,13 @@ describe('WebStandardStreamableHTTPServerTransport request body limits', () => { }); it('maxRequestBodySize sets the bound on both read paths and is validated at construction', async () => { - const strict = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, maxRequestBodySize: 1024 }); - const declared = await strict.handleRequest(streamedPost(1, { 'Content-Length': '1025' }).request); + const strict = (): WebStandardStreamableHTTPServerTransport => + new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, maxRequestBodySize: 1024 }); + const declared = await strict().handleRequest(streamedPost(1, { 'Content-Length': '1025' }).request); expect(declared.status).toBe(413); expectErrorResponse(await declared.json(), -32_000, /must not exceed 1024 bytes/); const streamed = streamedPost(2); - const overLimit = await strict.handleRequest(streamed.request); + const overLimit = await strict().handleRequest(streamed.request); expect(overLimit.status).toBe(413); expect(streamed.pulls()).toBe(1); @@ -1627,8 +1641,10 @@ describe('WebStandardStreamableHTTPServerTransport request body limits', () => { const batch = Array.from({ length: 101 }, (_, i): JSONRPCMessage => ({ jsonrpc: '2.0', method: 'ping', id: i })); const onmessage = vi.fn(); transport.onmessage = onmessage; + const second = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + second.onmessage = onmessage; const read = await transport.handleRequest(createRequest('POST', batch)); - const preParsed = await transport.handleRequest(createRequest('POST', batch), { parsedBody: batch }); + const preParsed = await second.handleRequest(createRequest('POST', batch), { parsedBody: batch }); for (const response of [read, preParsed]) { expect(response.status).toBe(400); expectErrorResponse(await response.json(), -32_600, /Batch must not exceed 100 messages/); diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 480a13f769..e686b04084 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1519,12 +1519,7 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'A stateless per-request transport cannot be reused for a second request.', transports: ['streamableHttp'], - note: 'This exercises the HTTP hosting layer and stateless mode; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.', - knownFailures: [ - { - note: 'changed in v2: the stateless reuse guard was removed, so a second request on the same per-request transport is processed instead of rejected.' - } - ] + note: 'This exercises the HTTP hosting layer and stateless mode; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'hosting:stateless:no-session-id': { source: 'sdk', diff --git a/test/integration/test/client/discoverRoundtrip.test.ts b/test/integration/test/client/discoverRoundtrip.test.ts index 13bddc46fb..09971d67e0 100644 --- a/test/integration/test/client/discoverRoundtrip.test.ts +++ b/test/integration/test/client/discoverRoundtrip.test.ts @@ -39,8 +39,7 @@ describe('server/discover round-trip against a modern server', () => { while (cleanups.length > 0) await cleanups.pop()!(); }); - async function startServer(options: { modernEraInstance: boolean }) { - const httpServer: HttpServer = createServer(); + async function connectPair(options: { modernEraInstance: boolean }) { const mcpServer = new McpServer( { name: 'dual-era-server', version: '2.0.0' }, { @@ -59,13 +58,19 @@ describe('server/discover round-trip against a modern server', () => { // instance as serving the modern era so it can answer the probe. setNegotiatedProtocolVersion(mcpServer.server, MODERN); } - httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); - const baseUrl = await listenOnRandomPort(httpServer); - cleanups.push(async () => { - await mcpServer.close().catch(() => {}); - await serverTransport.close().catch(() => {}); - httpServer.close(); + return { mcpServer, serverTransport }; + } + + async function startServer(options: { modernEraInstance: boolean }) { + const httpServer: HttpServer = createServer(); + // A stateless transport serves one request: connect a new pair for each. + httpServer.on('request', async (req, res) => { + const { mcpServer, serverTransport } = await connectPair(options); + res.on('close', () => void mcpServer.close()); + await serverTransport.handleRequest(req, res); }); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(() => void httpServer.close()); return baseUrl; } @@ -149,16 +154,15 @@ describe('server/discover round-trip against a modern server', () => { // round-trip over HTTP completes once every modern request carries the // per-request _meta envelope.) const httpServer: HttpServer = createServer(); - const mcpServer = new McpServer({ name: 'legacy-only', version: '1.0.0' }, { capabilities: { tools: {} } }); - const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); - await mcpServer.connect(serverTransport); - httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); - const baseUrl = await listenOnRandomPort(httpServer); - cleanups.push(async () => { - await mcpServer.close().catch(() => {}); - await serverTransport.close().catch(() => {}); - httpServer.close(); + httpServer.on('request', async (req, res) => { + const mcpServer = new McpServer({ name: 'legacy-only', version: '1.0.0' }, { capabilities: { tools: {} } }); + const serverTransport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined }); + await mcpServer.connect(serverTransport); + res.on('close', () => void mcpServer.close()); + await serverTransport.handleRequest(req, res); }); + const baseUrl = await listenOnRandomPort(httpServer); + cleanups.push(() => void httpServer.close()); const client = new Client({ name: 'legacy-client', version: '1.0.0' }); await client.connect(new StreamableHTTPClientTransport(baseUrl)); diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts index c6e70446cd..bd74aaa951 100644 --- a/test/integration/test/client/versionNegotiation.test.ts +++ b/test/integration/test/client/versionNegotiation.test.ts @@ -62,8 +62,7 @@ function recordingFetch() { const NEGOTIATION_HEADERS = ['mcp-protocol-version', 'mcp-method', 'mcp-name'] as const; -async function setupLegacyServer(stateful: boolean) { - const httpServer: Server = createServer(); +async function connectLegacyPair(stateful: boolean) { const mcpServer = new McpServer({ name: 'deployed-2025-server', version: '1.0.0' }, { capabilities: { tools: {} } }); mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => ({ content: [{ type: 'text', text }] @@ -72,7 +71,22 @@ async function setupLegacyServer(stateful: boolean) { sessionIdGenerator: stateful ? () => randomUUID() : undefined }); await mcpServer.connect(serverTransport); - httpServer.on('request', (req, res) => void serverTransport.handleRequest(req, res)); + return { mcpServer, serverTransport }; +} + +async function setupLegacyServer(stateful: boolean) { + const httpServer: Server = createServer(); + const { mcpServer, serverTransport } = await connectLegacyPair(stateful); + httpServer.on('request', async (req, res) => { + if (stateful) { + await serverTransport.handleRequest(req, res); + return; + } + // A stateless transport serves one request: connect a new pair for each. + const pair = await connectLegacyPair(false); + res.on('close', () => void pair.mcpServer.close()); + await pair.serverTransport.handleRequest(req, res); + }); const baseUrl = await listenOnRandomPort(httpServer); return { httpServer, mcpServer, serverTransport, baseUrl }; } diff --git a/test/integration/test/server/bun.test.ts b/test/integration/test/server/bun.test.ts index 229145aa86..43c4aaa80b 100644 --- a/test/integration/test/server/bun.test.ts +++ b/test/integration/test/server/bun.test.ts @@ -13,9 +13,9 @@ import * as z from 'zod/v4'; describe('MCP on Bun', () => { let httpServer: ReturnType; - let transport: WebStandardStreamableHTTPServerTransport; - beforeAll(async () => { + // A stateless transport serves one request: connect a new pair for each. + async function connectPair(): Promise { const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); mcpServer.registerTool( @@ -29,17 +29,22 @@ describe('MCP on Bun', () => { }) ); - transport = new WebStandardStreamableHTTPServerTransport(); + const transport = new WebStandardStreamableHTTPServerTransport(); await mcpServer.connect(transport); + return transport; + } + beforeAll(() => { httpServer = Bun.serve({ port: 0, - fetch: req => transport.handleRequest(req) + fetch: async req => { + const transport = await connectPair(); + return transport.handleRequest(req); + } }); }); - afterAll(async () => { - await transport?.close(); + afterAll(() => { httpServer?.stop(); }); diff --git a/test/integration/test/server/cloudflareWorkers.test.ts b/test/integration/test/server/cloudflareWorkers.test.ts index 45d0117510..150a938ccc 100644 --- a/test/integration/test/server/cloudflareWorkers.test.ts +++ b/test/integration/test/server/cloudflareWorkers.test.ts @@ -302,20 +302,27 @@ describe('Cloudflare Workers compatibility (no nodejs_compat)', () => { import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; import { z } from 'zod'; -const server = new McpServer({ name: "test-server", version: "${SERVER_VERSION_NONCE}" }); - -server.registerTool("greet", { - description: "Greet someone", - inputSchema: z.object({ name: z.string() }) -}, async ({ name }) => ({ - content: [{ type: "text", text: "Hello, " + name + "!" }] -})); - -const transport = new WebStandardStreamableHTTPServerTransport(); -await server.connect(transport); +// A stateless transport serves one request: connect a new pair for each. +async function connectPair() { + const server = new McpServer({ name: "test-server", version: "${SERVER_VERSION_NONCE}" }); + + server.registerTool("greet", { + description: "Greet someone", + inputSchema: z.object({ name: z.string() }) + }, async ({ name }) => ({ + content: [{ type: "text", text: "Hello, " + name + "!" }] + })); + + const transport = new WebStandardStreamableHTTPServerTransport(); + await server.connect(transport); + return transport; +} export default { - fetch: (request) => transport.handleRequest(request) + fetch: async (request) => { + const transport = await connectPair(); + return transport.handleRequest(request); + } }; `; fs.writeFileSync(path.join(tempDir, 'server.ts'), serverSource); diff --git a/test/integration/test/server/deno.test.ts b/test/integration/test/server/deno.test.ts index 7d45f815cf..8eb9ba4010 100644 --- a/test/integration/test/server/deno.test.ts +++ b/test/integration/test/server/deno.test.ts @@ -16,23 +16,30 @@ Deno.test({ sanitizeOps: false, sanitizeResources: false, async fn() { - const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); - - mcpServer.registerTool( - 'greet', - { - description: 'Greet someone', - inputSchema: z.object({ name: z.string() }) - }, - async ({ name }) => ({ - content: [{ type: 'text' as const, text: `Hello, ${name}!` }] - }) - ); - - const transport = new WebStandardStreamableHTTPServerTransport(); - await mcpServer.connect(transport); - - const httpServer = Deno.serve({ port: 0 }, req => transport.handleRequest(req)); + // A stateless transport serves one request: connect a new pair for each. + const connectPair = async (): Promise => { + const mcpServer = new McpServer({ name: 'test-server', version: '1.0.0' }); + + mcpServer.registerTool( + 'greet', + { + description: 'Greet someone', + inputSchema: z.object({ name: z.string() }) + }, + async ({ name }) => ({ + content: [{ type: 'text' as const, text: `Hello, ${name}!` }] + }) + ); + + const transport = new WebStandardStreamableHTTPServerTransport(); + await mcpServer.connect(transport); + return transport; + }; + + const httpServer = Deno.serve({ port: 0 }, async req => { + const transport = await connectPair(); + return transport.handleRequest(req); + }); const port = httpServer.addr.port; try { @@ -46,7 +53,6 @@ Deno.test({ await client.close(); } finally { - await transport.close(); await httpServer.shutdown(); } } diff --git a/test/integration/test/stateManagementStreamableHttp.test.ts b/test/integration/test/stateManagementStreamableHttp.test.ts index 3f32c64a34..84ba9f65f3 100644 --- a/test/integration/test/stateManagementStreamableHttp.test.ts +++ b/test/integration/test/stateManagementStreamableHttp.test.ts @@ -8,8 +8,7 @@ import { LATEST_PROTOCOL_VERSION, McpServer } from '@modelcontextprotocol/server import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import * as z from 'zod/v4'; -async function setupServer(withSessionManagement: boolean) { - const server: Server = createServer(); +async function connectPair(withSessionManagement: boolean) { const mcpServer = new McpServer( { name: 'test-server', version: '1.0.0' }, { @@ -68,8 +67,22 @@ async function setupServer(withSessionManagement: boolean) { await mcpServer.connect(serverTransport); + return { mcpServer, serverTransport }; +} + +async function setupServer(withSessionManagement: boolean) { + const server: Server = createServer(); + const { mcpServer, serverTransport } = await connectPair(withSessionManagement); + server.on('request', async (req, res) => { - await serverTransport.handleRequest(req, res); + if (withSessionManagement) { + await serverTransport.handleRequest(req, res); + return; + } + // A stateless transport serves one request: connect a new pair for each. + const pair = await connectPair(false); + res.on('close', () => void pair.mcpServer.close()); + await pair.serverTransport.handleRequest(req, res); }); // Start the server on a random port From 63c0fcab49dc13f22dd7d016d6320c62d05e3ca8 Mon Sep 17 00:00:00 2001 From: Sthreal <2066885216@qq.com> Date: Fri, 2 Oct 2026 22:43:48 +0800 Subject: [PATCH 29/44] fix(client): update eventsource-parser for large SSE responses (#2846) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/eventsource-parser-large-sse-memory.md | 5 +++++ pnpm-lock.yaml | 14 ++++---------- pnpm-workspace.yaml | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) create mode 100644 .changeset/eventsource-parser-large-sse-memory.md diff --git a/.changeset/eventsource-parser-large-sse-memory.md b/.changeset/eventsource-parser-large-sse-memory.md new file mode 100644 index 0000000000..168eab8bc4 --- /dev/null +++ b/.changeset/eventsource-parser-large-sse-memory.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Receiving a large message as a single SSE event over Streamable HTTP, such as a tool result of tens of megabytes, is now fast: a 50 MB result that took about 13 seconds arrives in under a second. The client now requires `eventsource-parser` 3.0.8 or later. `SSEClientTransport` reads through the `eventsource` package and gets the same speed-up once that also resolves `eventsource-parser` 3.0.8 or later. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb32c9289c..7c3cf72692 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,8 +89,8 @@ catalogs: specifier: ^3.0.2 version: 3.0.7 eventsource-parser: - specifier: ^3.0.0 - version: 3.0.6 + specifier: ^3.0.8 + version: 3.0.8 jose: specifier: ^6.1.3 version: 6.2.2 @@ -1304,7 +1304,7 @@ importers: version: 3.0.7 eventsource-parser: specifier: catalog:runtimeClientOnly - version: 3.0.6 + version: 3.0.8 jose: specifier: catalog:runtimeClientOnly version: 6.2.2 @@ -4995,10 +4995,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - eventsource-parser@3.0.8: resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} engines: {node: '>=18.0.0'} @@ -9663,13 +9659,11 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.0.6: {} - eventsource-parser@3.0.8: {} eventsource@3.0.7: dependencies: - eventsource-parser: 3.0.6 + eventsource-parser: 3.0.8 expand-template@2.0.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3303ad0607..73c94daf91 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,7 +37,7 @@ catalogs: runtimeClientOnly: cross-spawn: ^7.0.5 eventsource: ^3.0.2 - eventsource-parser: ^3.0.0 + eventsource-parser: ^3.0.8 jose: ^6.1.3 runtimeServerOnly: '@hono/node-server': ^1.19.9 From 2fc49eaf17a6b9ac0810c875cc96d3b949f9522c Mon Sep 17 00:00:00 2001 From: Pragnyan Ramtha Date: Fri, 2 Oct 2026 20:19:38 +0530 Subject: [PATCH 30/44] fix(server): accept prompts/get requests that omit arguments (#2107) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/fix-omitted-prompt-arguments.md | 5 ++ packages/server/src/server/mcp.ts | 2 +- test/integration/test/server/mcp.test.ts | 85 ++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-omitted-prompt-arguments.md diff --git a/.changeset/fix-omitted-prompt-arguments.md b/.changeset/fix-omitted-prompt-arguments.md new file mode 100644 index 0000000000..95b0b17a4b --- /dev/null +++ b/.changeset/fix-omitted-prompt-arguments.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`prompts/get` without `arguments` no longer fails with "Invalid arguments" when every argument of the prompt is optional. A missing `arguments` is now validated as `{}`, as it already is for `tools/call`, so a top-level `.optional()` or `.default(...)` on `argsSchema` no longer sees `undefined`. diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 2aeb657e7e..78d4d4aa20 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -1553,7 +1553,7 @@ function createPromptHandler( ) => GetPromptResult | InputRequiredResult | Promise; return async (args, ctx) => { - const parseResult = await validateStandardSchema(argsSchema, args); + const parseResult = await validateStandardSchema(argsSchema, args ?? {}); if (!parseResult.success) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Invalid arguments for prompt ${name}: ${parseResult.error}`); } diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 8bd40a8459..964499b9c3 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -4174,6 +4174,91 @@ describe('Zod v4', () => { ]); }); + test('should accept omitted prompt arguments when all schema fields are optional', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt( + 'echo', + { + argsSchema: z.object({ + context: z.string().optional() + }) + }, + ({ context }) => ({ + messages: [ + { + role: 'user', + content: { + type: 'text', + text: `context: ${context ?? 'none'}` + } + } + ] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const result = await client.request({ + method: 'prompts/get', + params: { + name: 'echo' + } + }); + + expect(result.messages).toEqual([ + { + role: 'user', + content: { + type: 'text', + text: 'context: none' + } + } + ]); + }); + + test('should reject omitted prompt arguments when a schema field is required', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerPrompt('echo', { argsSchema: z.object({ context: z.string() }) }, ({ context }) => ({ + messages: [{ role: 'user', content: { type: 'text', text: `context: ${context}` } }] + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + await expect( + client.request({ + method: 'prompts/get', + params: { + name: 'echo' + } + }) + ).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('context') + }); + }); + /*** * Test: Prompt Registration with _meta field */ From 6d8dbc6cb590edf766fcb66b636ca6b654e99c37 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:51:43 +0100 Subject: [PATCH 31/44] feat(server): add maxToolInputElements option to limit the number of elements in tool-call arguments (#2926) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/bound-tool-input-elements.md | 5 + docs/servers/tools.md | 11 ++ examples/guides/servers/tools.examples.ts | 9 ++ packages/server/src/index.ts | 1 + packages/server/src/server/mcp.ts | 59 +++++++- .../server/toolInputElementBudget.test.ts | 137 ++++++++++++++++++ 6 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 .changeset/bound-tool-input-elements.md create mode 100644 packages/server/test/server/toolInputElementBudget.test.ts diff --git a/.changeset/bound-tool-input-elements.md b/.changeset/bound-tool-input-elements.md new file mode 100644 index 0000000000..6b29329405 --- /dev/null +++ b/.changeset/bound-tool-input-elements.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +`McpServer` now accepts a `maxToolInputElements` option that limits the number of elements in tool-call arguments: the largest combined number of array elements and object members a single `tools/call` `arguments` payload may contain. It is off by default, so behavior is unchanged unless you set it. When it is set and a call exceeds it, that call is answered with an `isError: true` tool result that names the limit, before the input schema runs, and the server keeps serving. Set it above the largest arguments your tools legitimately accept; `maxRequestBodySize` remains the primary limit on request size. The value must be a number of at least 1, or `Infinity` for no limit; any other value is rejected at construction. The options type is exported as `McpServerOptions`. diff --git a/docs/servers/tools.md b/docs/servers/tools.md index aff0fc430f..ac158d7301 100644 --- a/docs/servers/tools.md +++ b/docs/servers/tools.md @@ -213,6 +213,16 @@ server.registerTool( A tool that takes no arguments omits `inputSchema`. Annotations never change how the SDK runs the tool — clients use them to decide what to put in front of the end user: a host can auto-approve a read-only tool and require confirmation before a destructive one. +## Limit the number of elements in tool arguments + +A `tools/call` request can carry very large arrays and objects in its `arguments`. `McpServer` can limit the number of elements it accepts. Pass `maxToolInputElements` to the constructor: the largest number of array elements and object members, combined, that a single call's `arguments` may contain. It must be a number of at least 1, or `Infinity` for no limit. + +```ts source="../../examples/guides/servers/tools.examples.ts#McpServer_maxToolInputElements" +const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { maxToolInputElements: 10_000 }); +``` + +It is off by default — leave it unset and behavior is unchanged. When it is set and a call exceeds it, that call comes back as an `isError: true` tool result naming the limit, before the schema runs, and the server keeps handling other calls. Choose a value above the largest arguments your tools legitimately accept. The transport's `maxRequestBodySize` (4 MiB by default) is the primary limit on request size; `maxToolInputElements` is a finer limit on the number of elements for servers that may receive large payloads. + ## Recap - `registerTool(name, config, handler)` registers a tool; `inputSchema` is a Zod object schema. @@ -221,3 +231,4 @@ A tool that takes no arguments omits `inputSchema`. Annotations never change how - `outputSchema` plus `structuredContent` add machine-readable results, validated before they leave the server. - `content` blocks are `text`, `image`, `audio`, `resource_link`, or an embedded `resource`; one result can mix them. - `title` and `annotations` describe the tool to clients and never change execution. +- `maxToolInputElements` (off by default) limits the number of elements (array elements and object members, combined) one call's `arguments` may contain. diff --git a/examples/guides/servers/tools.examples.ts b/examples/guides/servers/tools.examples.ts index 0719c9c22c..d613f9d1c9 100644 --- a/examples/guides/servers/tools.examples.ts +++ b/examples/guides/servers/tools.examples.ts @@ -108,6 +108,15 @@ server.registerTool( ); //#endregion registerTool_contentTypes +// "Limit the number of elements in tool arguments" — typecheck-only wrapper. +function McpServer_maxToolInputElements() { + //#region McpServer_maxToolInputElements + const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { maxToolInputElements: 10_000 }); + //#endregion McpServer_maxToolInputElements + return server; +} +void McpServer_maxToolInputElements; + // --------------------------------------------------------------------------- // Harness (not shown on the page). An in-memory client drives the calls whose // output servers/tools.md quotes verbatim. Any MCP client behaves the same. diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 9b3196a80b..7d85654fb2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -24,6 +24,7 @@ export type { BaseToolCallback, CompleteResourceTemplateCallback, ListResourcesCallback, + McpServerOptions, PromptCallback, ReadResourceCallback, ReadResourceTemplateCallback, diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 78d4d4aa20..57b7c132eb 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -52,6 +52,50 @@ import { supportsScopeChallengeResolver } from './scopeChallenge'; import type { ServerOptions } from './server'; import { Server } from './server'; +// Counts array elements and object members in `value`, stopping once the running total passes `max`. +function toolInputElementCount(value: unknown, max: number): number { + let count = 0; + const stack: unknown[] = [value]; + while (stack.length > 0) { + const node = stack.pop(); + if (node === null || typeof node !== 'object') continue; + if (Array.isArray(node)) { + for (const child of node) { + if (++count > max) return count; + if (child !== null && typeof child === 'object') stack.push(child); + } + } else { + for (const key in node) { + if (!Object.prototype.hasOwnProperty.call(node, key)) continue; + if (++count > max) return count; + const child = (node as Record)[key]; + if (child !== null && typeof child === 'object') stack.push(child); + } + } + } + return count; +} + +// Resolves the configured element ceiling: no limit when unset or Infinity, else a number of at least 1. +function resolveMaxToolInputElements(value: number | undefined): number | undefined { + if (value === undefined || value === Infinity) return undefined; + if (typeof value !== 'number' || Number.isNaN(value) || value < 1) { + throw new RangeError(`maxToolInputElements must be a number of at least 1, or Infinity, got ${String(value)}`); + } + return value; +} + +/** + * Options for {@linkcode McpServer}: everything {@linkcode ServerOptions} accepts, plus `maxToolInputElements`. + */ +export type McpServerOptions = ServerOptions & { + /** + * Largest combined number of array elements and object members a single `tools/call` `arguments` payload may contain. + * A number of at least 1; unset or `Infinity` means no limit. + */ + maxToolInputElements?: number; +}; + /** * High-level MCP server that provides a simpler API for working with resources, tools, and prompts. * For advanced usage (like sending notifications or setting custom request handlers), use the underlying @@ -71,6 +115,8 @@ export class McpServer { */ public readonly server: Server; + private readonly _maxToolInputElements: number | undefined; + private _registeredResources: { [uri: string]: RegisteredResource } = {}; private _registeredResourceTemplates: { [name: string]: RegisteredResourceTemplate; @@ -103,8 +149,9 @@ export class McpServer { } } - constructor(serverInfo: Implementation, options?: ServerOptions) { + constructor(serverInfo: Implementation, options?: McpServerOptions) { this.server = new Server(serverInfo, options); + this._maxToolInputElements = resolveMaxToolInputElements(options?.maxToolInputElements); // Per the MCP spec, a server that declares a primitive capability MUST respond to its // list method (potentially with an empty result) rather than "Method not found" — even @@ -306,6 +353,16 @@ export class McpServer { : undefined : undefined >(tool: ToolType, args: Args, toolName: string): Promise { + if ( + this._maxToolInputElements !== undefined && + toolInputElementCount(args, this._maxToolInputElements) > this._maxToolInputElements + ) { + throw new ProtocolError( + ProtocolErrorCode.InvalidParams, + `Invalid arguments for tool ${toolName}: arguments contain more than the maximum of ${this._maxToolInputElements} elements` + ); + } + if (!tool.inputSchema) { return undefined as Args; } diff --git a/packages/server/test/server/toolInputElementBudget.test.ts b/packages/server/test/server/toolInputElementBudget.test.ts new file mode 100644 index 0000000000..7055842053 --- /dev/null +++ b/packages/server/test/server/toolInputElementBudget.test.ts @@ -0,0 +1,137 @@ +import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { InMemoryTransport, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core-internal'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; +import type { McpServerOptions } from '../../src/index'; +import { McpServer } from '../../src/index'; + +type ToolResult = { content?: Array<{ text?: string }>; isError?: boolean }; + +const ok = () => ({ content: [{ type: 'text' as const, text: 'ok' }] }); + +// Connects a peer to a server with three tools; `call` sends a tools/call and resolves with the result of its response. +async function connect(options?: McpServerOptions) { + const server = new McpServer({ name: 'test', version: '1.0.0' }, options); + const handlers = { t: vi.fn(ok), objects: vi.fn(ok), plain: vi.fn(ok) }; + server.registerTool('t', { inputSchema: { items: z.array(z.string()) } }, handlers.t); + server.registerTool( + 'objects', + { inputSchema: { items: z.array(z.object({ a: z.number(), b: z.number().optional() })) } }, + handlers.objects + ); + server.registerTool('plain', {}, handlers.plain); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const waiters = new Map void>(); + clientTransport.onmessage = message => waiters.get((message as { id?: unknown }).id)?.(message); + await server.connect(serverTransport); + await clientTransport.start(); + + let id = 0; + const request = (method: string, params: Record) => + new Promise(resolve => { + waiters.set(++id, resolve); + void clientTransport.send({ jsonrpc: '2.0', id, method, params }); + }); + await request('initialize', { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + }); + await clientTransport.send({ jsonrpc: '2.0', method: 'notifications/initialized' }); + + const call = async (name: string, args: Record): Promise => + ((await request('tools/call', { name, arguments: args })) as { result?: ToolResult }).result; + return { call, handlers }; +} + +async function callArrayTool(items: unknown, options?: McpServerOptions): Promise { + const { call } = await connect(options); + return call('t', { items }); +} + +describe('maxToolInputElements', () => { + it('accepts a large array when no limit is set', async () => { + const result = await callArrayTool(Array(60_000).fill('x')); + expect(result?.isError).toBeFalsy(); + expect(result?.content?.[0]?.text).toBe('ok'); + }); + + it('refuses arguments past a configured limit', async () => { + const result = await callArrayTool(Array(60_000).fill('x'), { maxToolInputElements: 1_000 }); + expect(result?.isError).toBe(true); + expect(result?.content?.[0]?.text).toContain('more than the maximum'); + }); + + it('accepts arguments within a configured limit', async () => { + const result = await callArrayTool(Array(1_000).fill('x'), { maxToolInputElements: 2_000 }); + expect(result?.isError).toBeFalsy(); + expect(result?.content?.[0]?.text).toBe('ok'); + }); + + it('treats Infinity as no limit', async () => { + const result = await callArrayTool(Array(60_000).fill('x'), { maxToolInputElements: Infinity }); + expect(result?.isError).toBeFalsy(); + expect(result?.content?.[0]?.text).toBe('ok'); + }); + + it('rejects a limit that is not a number of at least 1 at construction', () => { + expect(() => new McpServer({ name: 't', version: '1.0.0' }, { maxToolInputElements: Number.NaN })).toThrow(); + expect(() => new McpServer({ name: 't', version: '1.0.0' }, { maxToolInputElements: 0 })).toThrow(); + expect(() => new McpServer({ name: 't', version: '1.0.0' }, { maxToolInputElements: 0.5 })).toThrow(); + expect(() => new McpServer({ name: 't', version: '1.0.0' }, { maxToolInputElements: -1 })).toThrow(); + }); + + it('counts members of nested objects together with array elements', async () => { + const { call } = await connect({ maxToolInputElements: 12 }); + const full = { a: 1, b: 2 }; + + // 1 top-level member + 4 array elements + 7 object members = 12 + const atLimit = await call('objects', { items: [full, full, full, { a: 1 }] }); + expect(atLimit?.isError).toBeFalsy(); + expect(atLimit?.content?.[0]?.text).toBe('ok'); + + // 1 top-level member + 4 array elements + 8 object members = 13 + const pastLimit = await call('objects', { items: [full, full, full, full] }); + expect(pastLimit?.isError).toBe(true); + expect(pastLimit?.content?.[0]?.text).toContain('more than the maximum'); + }); + + it('answers an ordinary call after a refused call on the same server', async () => { + const { call } = await connect({ maxToolInputElements: 1_000 }); + + const refused = await call('t', { items: Array(60_000).fill('x') }); + expect(refused?.isError).toBe(true); + + const answered = await call('t', { items: ['x'] }); + expect(answered?.isError).toBeFalsy(); + expect(answered?.content?.[0]?.text).toBe('ok'); + }); + + it('does not invoke the handler for a refused call', async () => { + const { call, handlers } = await connect({ maxToolInputElements: 1_000 }); + + await call('t', { items: ['x'] }); + expect(handlers.t).toHaveBeenCalledTimes(1); + + const refused = await call('t', { items: Array(60_000).fill('x') }); + expect(refused?.isError).toBe(true); + expect(handlers.t).toHaveBeenCalledTimes(1); + }); + + it('applies the limit to a tool registered without an input schema', async () => { + const items = Array(20).fill('x'); + + const limited = await connect({ maxToolInputElements: 10 }); + const refused = await limited.call('plain', { items }); + expect(refused?.isError).toBe(true); + expect(refused?.content?.[0]?.text).toContain('more than the maximum'); + expect(limited.handlers.plain).not.toHaveBeenCalled(); + + const unlimited = await connect(); + const answered = await unlimited.call('plain', { items }); + expect(answered?.isError).toBeFalsy(); + expect(answered?.content?.[0]?.text).toBe('ok'); + expect(unlimited.handlers.plain).toHaveBeenCalledTimes(1); + }); +}); From 40f8f4e229d963cd7fd2890bda2aeebe7005299a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:28:25 +0100 Subject: [PATCH 32/44] feat(server): add expectedResource to the bearer-token check (#2929) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/bearer-auth-expected-resource.md | 6 ++ docs/serving/authorization.md | 14 ++- examples/bearer-auth/server.ts | 7 +- .../guides/serving/authorization.examples.ts | 10 +- .../serving/authorization.web.examples.ts | 10 +- .../middleware/express/src/auth/bearerAuth.ts | 8 +- .../express/test/auth/resourceServer.test.ts | 27 ++++++ packages/server/src/index.ts | 2 +- .../src/server/middleware/bearerAuth.ts | 56 +++++++++-- .../server/test/server/bearerAuth.test.ts | 95 +++++++++++++++++++ test/e2e/requirements.ts | 7 +- test/e2e/scenarios/hosting-express.test.ts | 2 +- 12 files changed, 215 insertions(+), 29 deletions(-) create mode 100644 .changeset/bearer-auth-expected-resource.md diff --git a/.changeset/bearer-auth-expected-resource.md b/.changeset/bearer-auth-expected-resource.md new file mode 100644 index 0000000000..4effba0cac --- /dev/null +++ b/.changeset/bearer-auth-expected-resource.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/server': minor +'@modelcontextprotocol/express': patch +--- + +`requireBearerAuth` and `verifyBearerToken` take a new optional `expectedResource`, which makes them accept only tokens issued for this resource (the token's audience). Set it to the value your authorization server puts into tokens meant for this server, usually the server's URL. When it is set, a token is accepted only if the verifier reports that value in `AuthInfo.resource`; the two are compared as strings, ignoring a fragment and one trailing slash. A token reported for another value, or for none, is answered `401 invalid_token` with the usual `WWW-Authenticate` challenge. When it is not set, nothing changes. To use it, pass `expectedResource` and have `verifyAccessToken` fill `AuthInfo.resource`, for example from the `aud` claim. The option is declared on a new exported type, `VerifyBearerTokenOptions`, which extends `BearerAuthOptions`; `BearerAuthOptions` itself is unchanged. The Express `requireBearerAuth` passes the option through. With Express, `@modelcontextprotocol/express` has to be upgraded to this release as well: 2.0.1 does not pass the option on, so nothing is compared. Its options type does not have the option, so TypeScript reports an `expectedResource` written in a call to the 2.0.1 `requireBearerAuth` as an error. diff --git a/docs/serving/authorization.md b/docs/serving/authorization.md index 5306ff1ada..c9d412599d 100644 --- a/docs/serving/authorization.md +++ b/docs/serving/authorization.md @@ -30,7 +30,8 @@ const verifier: OAuthTokenVerifier = { verifyAccessToken }; const auth = requireBearerAuth({ verifier, requiredScopes: ['mcp'], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), + expectedResource: mcpServerUrl }); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); @@ -49,7 +50,7 @@ The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, On hosts whose HTTP surface is a `fetch(request)` handler — Cloudflare Workers, Deno, Bun, Hono — the gate is `requireBearerAuth` from `@modelcontextprotocol/server`: no framework, only web-standard `Request` and `Response`. ```ts source="../../examples/guides/serving/authorization.web.examples.ts#requireBearerAuth_webStandard" -const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] }); +const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'], expectedResource: mcpServerUrl }); const handler = createMcpHandler(buildServer); export default { @@ -70,7 +71,10 @@ The gate resolves to the verified `AuthInfo` — pass it to the handler as `{ au ```ts source="../../examples/guides/serving/authorization.examples.ts#tokenVerifier_basic" async function verifyAccessToken(token: string): Promise { const payload = await verifyJwt(token); - return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; + // `aud` is one value, a list, or absent: report the entry on this server's origin, if any, and let `expectedResource` compare it. + const audience = [payload.aud ?? []].flat().find(aud => URL.canParse(aud) && new URL(aud).origin === mcpServerUrl.origin); + const resource = audience ? new URL(audience) : undefined; + return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp, resource }; } ``` @@ -80,6 +84,10 @@ Throw an `OAuthError` with `OAuthErrorCode.InvalidToken` (both from `@modelconte `requireBearerAuth` also answers `401 invalid_token` for a token whose `expiresAt` is unset. Always populate it — from the JWT `exp` claim or the introspection response's `exp` field. ::: +`expectedResource` makes `requireBearerAuth` accept only tokens issued for this resource (the token's audience). Set it to the value your authorization server puts into tokens meant for this server, usually the server's URL. When it is set, `requireBearerAuth` accepts a token only if your verifier reports that value in `AuthInfo.resource` (one trailing slash aside) and answers `401 invalid_token` for a token reported for another value or for none, so populate `resource` from the JWT `aud` claim or the introspection response's `aud` field. `aud` can be a list or absent: report this server's entry, and leave `resource` unset when there is none. When `expectedResource` is not set, `AuthInfo.resource` is not compared with anything. + +With Express, `@modelcontextprotocol/express` has to be upgraded together with `@modelcontextprotocol/server`: `@modelcontextprotocol/express` 2.0.1 does not pass `expectedResource` on, so nothing is compared. Its options type does not have the option, so TypeScript reports an `expectedResource` written in a call to its `requireBearerAuth` as an error. + ## Publish protected resource metadata `mcpAuthMetadataRouter` serves the [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) protected resource metadata document that the `401` challenge points at. `oauthMetadata` is your authorization server's own RFC 8414 metadata document. diff --git a/examples/bearer-auth/server.ts b/examples/bearer-auth/server.ts index 189553869b..20dac45487 100644 --- a/examples/bearer-auth/server.ts +++ b/examples/bearer-auth/server.ts @@ -23,6 +23,7 @@ const buildServer: McpServerFactory = ctx => { }; const { port } = parseExampleArgs(); +const mcpServerUrl = new URL(`http://127.0.0.1:${port}/mcp`); // Replace with JWT verification, RFC 7662 introspection, etc. const staticTokenVerifier: OAuthTokenVerifier = { @@ -30,7 +31,8 @@ const staticTokenVerifier: OAuthTokenVerifier = { if (token !== 'demo-token') { throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); } - return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; + // `resource` says which server the token was issued for: the token's `aud` in a real verifier. + return { token, clientId: 'demo-client', scopes: ['mcp'], expiresAt: Math.floor(Date.now() / 1000) + 3600, resource: mcpServerUrl }; } }; @@ -40,7 +42,8 @@ const staticTokenVerifier: OAuthTokenVerifier = { const handler = createMcpHandler(buildServer); const app = createMcpExpressApp(); -const auth = requireBearerAuth({ verifier: staticTokenVerifier, requiredScopes: ['mcp'] }); +// `expectedResource`: only tokens the verifier reports as issued for this server are accepted. +const auth = requireBearerAuth({ verifier: staticTokenVerifier, requiredScopes: ['mcp'], expectedResource: mcpServerUrl }); // `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it // to the factory as `ctx.authInfo`. const node = toNodeHandler(handler); diff --git a/examples/guides/serving/authorization.examples.ts b/examples/guides/serving/authorization.examples.ts index 4cffc022a0..4ebd6c0127 100644 --- a/examples/guides/serving/authorization.examples.ts +++ b/examples/guides/serving/authorization.examples.ts @@ -31,7 +31,8 @@ const verifier: OAuthTokenVerifier = { verifyAccessToken }; const auth = requireBearerAuth({ verifier, requiredScopes: ['mcp'], - resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl), + expectedResource: mcpServerUrl }); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); @@ -42,12 +43,15 @@ app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); //#region tokenVerifier_basic async function verifyAccessToken(token: string): Promise { const payload = await verifyJwt(token); - return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; + // `aud` is one value, a list, or absent: report the entry on this server's origin, if any, and let `expectedResource` compare it. + const audience = [payload.aud ?? []].flat().find(aud => URL.canParse(aud) && new URL(aud).origin === mcpServerUrl.origin); + const resource = audience ? new URL(audience) : undefined; + return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp, resource }; } //#endregion tokenVerifier_basic // Stand-in for your JWT library or RFC 7662 introspection call. -declare function verifyJwt(token: string): Promise<{ sub: string; scopes: string[]; exp: number }>; +declare function verifyJwt(token: string): Promise<{ sub: string; scopes: string[]; exp: number; aud?: string | string[] }>; // Your authorization server's RFC 8414 metadata document — fetch it from the AS // at startup or embed it. diff --git a/examples/guides/serving/authorization.web.examples.ts b/examples/guides/serving/authorization.web.examples.ts index 147099a4db..752ed85299 100644 --- a/examples/guides/serving/authorization.web.examples.ts +++ b/examples/guides/serving/authorization.web.examples.ts @@ -15,14 +15,18 @@ import type { AuthInfo, OAuthTokenVerifier } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode, requireBearerAuth } from '@modelcontextprotocol/server'; -declare function verifyJwt(token: string): Promise<{ sub: string; scopes: string[]; exp: number }>; +declare function verifyJwt(token: string): Promise<{ sub: string; scopes: string[]; exp: number; aud?: string | string[] }>; + +const mcpServerUrl = new URL('https://api.example.com/mcp'); const verifier: OAuthTokenVerifier = { async verifyAccessToken(token): Promise { const payload = await verifyJwt(token).catch(() => { throw new OAuthError(OAuthErrorCode.InvalidToken, 'unknown token'); }); - return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; + const audience = [payload.aud ?? []].flat().find(aud => URL.canParse(aud) && new URL(aud).origin === mcpServerUrl.origin); + const resource = audience ? new URL(audience) : undefined; + return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp, resource }; } }; @@ -31,7 +35,7 @@ function buildServer(): McpServer { } //#region requireBearerAuth_webStandard -const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] }); +const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'], expectedResource: mcpServerUrl }); const handler = createMcpHandler(buildServer); export default { diff --git a/packages/middleware/express/src/auth/bearerAuth.ts b/packages/middleware/express/src/auth/bearerAuth.ts index 61a0b29829..d5dddee2be 100644 --- a/packages/middleware/express/src/auth/bearerAuth.ts +++ b/packages/middleware/express/src/auth/bearerAuth.ts @@ -1,11 +1,11 @@ -import type { BearerAuthOptions } from '@modelcontextprotocol/server'; +import type { VerifyBearerTokenOptions } from '@modelcontextprotocol/server'; import { bearerAuthChallengeResponse, OAuthError, OAuthErrorCode, verifyBearerToken } from '@modelcontextprotocol/server'; import type { RequestHandler } from 'express'; /** * Options for {@link requireBearerAuth}. */ -export type BearerAuthMiddlewareOptions = BearerAuthOptions; +export type BearerAuthMiddlewareOptions = VerifyBearerTokenOptions; /** * Express middleware that requires a valid Bearer token in the `Authorization` @@ -26,8 +26,8 @@ export type BearerAuthMiddlewareOptions = BearerAuthOptions; export function requireBearerAuth(options: BearerAuthMiddlewareOptions): RequestHandler { // Destructure at creation so a plain-JS caller passing undefined or // malformed options crashes at startup, not on the first request. - const { verifier, requiredScopes = [], resourceMetadataUrl } = options; - const resolved = { verifier, requiredScopes, resourceMetadataUrl }; + const { verifier, requiredScopes = [], resourceMetadataUrl, expectedResource } = options; + const resolved = { verifier, requiredScopes, resourceMetadataUrl, expectedResource }; return async (req, res, next) => { try { req.auth = await verifyBearerToken(req.headers.authorization, resolved); diff --git a/packages/middleware/express/test/auth/resourceServer.test.ts b/packages/middleware/express/test/auth/resourceServer.test.ts index 3726fa46ce..14ff6d653b 100644 --- a/packages/middleware/express/test/auth/resourceServer.test.ts +++ b/packages/middleware/express/test/auth/resourceServer.test.ts @@ -118,6 +118,33 @@ describe('requireBearerAuth middleware', () => { expect(next).not.toHaveBeenCalled(); }); + it('passes expectedResource through to the token check', async () => { + const expectedResource = new URL('https://api.example.com/mcp'); + const authInfo = { token: 'valid', clientId: 'client-123', scopes: [], expiresAt: Math.floor(Date.now() / 1000) + 3600 }; + const middleware = requireBearerAuth({ verifier: mockVerifier, expectedResource }); + + const sameResource: AuthInfo = { ...authInfo, resource: new URL('https://api.example.com/mcp/') }; + mockVerifyAccessToken.mockResolvedValue(sameResource); + const accepted = createMockReqResNext('Bearer valid'); + await middleware(accepted.req, accepted.res, accepted.next); + expect(accepted.req.auth).toEqual(sameResource); + expect(accepted.next).toHaveBeenCalled(); + + for (const reported of [{ ...authInfo, resource: new URL('https://other.example.com/mcp') }, authInfo] satisfies AuthInfo[]) { + mockVerifyAccessToken.mockResolvedValue(reported); + const { req, res, next } = createMockReqResNext('Bearer valid'); + await middleware(req, res, next); + expect(res.status).toHaveBeenCalledWith(401); + expect(res.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + 'Bearer error="invalid_token", error_description="Token was not issued for this resource"' + ); + expect(res.json).toHaveBeenCalledWith({ error: 'invalid_token', error_description: 'Token was not issued for this resource' }); + expect(req.auth).toBeUndefined(); + expect(next).not.toHaveBeenCalled(); + } + }); + it('responds 500 when the verifier throws a non-OAuth error', async () => { mockVerifyAccessToken.mockRejectedValue(new Error('boom')); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 7d85654fb2..32605dd7e7 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -38,7 +38,7 @@ export type { export { McpServer, ResourceTemplate } from './server/mcp'; // Runtime-neutral Bearer authentication for web-standard hosts; the Express // middleware in @modelcontextprotocol/express adapts the same core. -export type { BearerAuthOptions, OAuthTokenVerifier } from './server/middleware/bearerAuth'; +export type { BearerAuthOptions, OAuthTokenVerifier, VerifyBearerTokenOptions } from './server/middleware/bearerAuth'; export { bearerAuthChallengeResponse, requireBearerAuth, verifyBearerToken } from './server/middleware/bearerAuth'; export type { HostHeaderValidationResult } from './server/middleware/hostHeaderValidation'; export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation'; diff --git a/packages/server/src/server/middleware/bearerAuth.ts b/packages/server/src/server/middleware/bearerAuth.ts index 80c8698a41..1c455d11e2 100644 --- a/packages/server/src/server/middleware/bearerAuth.ts +++ b/packages/server/src/server/middleware/bearerAuth.ts @@ -60,6 +60,41 @@ export interface BearerAuthOptions { resourceMetadataUrl?: string; } +/** + * Options for {@link verifyBearerToken} and {@link requireBearerAuth}: + * {@link BearerAuthOptions} plus `expectedResource`. + */ +export interface VerifyBearerTokenOptions extends BearerAuthOptions { + /** + * Accept only tokens issued for this resource (the token's audience): + * the value your authorization server puts into tokens meant for this + * server, usually the server's URL (its + * {@link https://datatracker.ietf.org/doc/html/rfc8707 | RFC 8707} + * resource identifier). + * + * When set, a token is accepted only if the verifier reports that value + * in `AuthInfo.resource`. The two are compared as strings, ignoring a + * fragment and one trailing slash. A token reported for another value, or + * for none, is refused with `401 invalid_token`, so the verifier has to + * fill `AuthInfo.resource`, for example from the token's `aud` claim. + * When unset, `AuthInfo.resource` is not compared with anything. + */ + expectedResource?: URL; +} + +// The serialized value without its fragment and without one trailing slash. +function comparableResource(value: URL): string { + const text = String(value); + const hash = text.indexOf('#'); + return (hash === -1 ? text : text.slice(0, hash)).replace(/\/$/, ''); +} + +// A reported resource matches when it serializes to the same string as the expected one, fragment and one trailing slash aside. +function sameResource(reported: URL | undefined, expected: URL): boolean { + if (!reported) return false; + return comparableResource(reported) === comparableResource(expected); +} + function headerQuotedValue(value: string): string { // HTTP quoted-string per RFC 7235: escape backslash and double quote, and // replace characters a header cannot carry (controls, anything beyond @@ -98,7 +133,8 @@ export function buildWwwAuthenticateHeader( * the verified {@link AuthInfo}. * * The runtime-neutral core of Bearer authentication: it parses the header, - * runs the verifier, enforces `requiredScopes`, and rejects tokens without an + * runs the verifier, compares the token's resource with `expectedResource`, + * enforces `requiredScopes`, and rejects tokens without an * expiration or past it. On any failure it throws an {@link OAuthError} — * pass that to {@link bearerAuthChallengeResponse} for the matching HTTP * answer, or use {@link requireBearerAuth} to get both steps as one call. @@ -106,8 +142,11 @@ export function buildWwwAuthenticateHeader( * Framework adapters build on this: `requireBearerAuth` from * `@modelcontextprotocol/express` feeds it `req.headers.authorization`. */ -export async function verifyBearerToken(authorizationHeader: string | null | undefined, options: BearerAuthOptions): Promise { - const { verifier, requiredScopes = [] } = options; +export async function verifyBearerToken( + authorizationHeader: string | null | undefined, + options: VerifyBearerTokenOptions +): Promise { + const { verifier, requiredScopes = [], expectedResource } = options; if (!authorizationHeader) { throw new OAuthError(OAuthErrorCode.InvalidToken, 'Missing Authorization header'); @@ -120,6 +159,11 @@ export async function verifyBearerToken(authorizationHeader: string | null | und const authInfo = await verifier.verifyAccessToken(token); + // Check if the token was issued for this server (if configured) + if (expectedResource !== undefined && !sameResource(authInfo.resource, expectedResource)) { + throw new OAuthError(OAuthErrorCode.InvalidToken, 'Token was not issued for this resource'); + } + // Check if token has the required scopes (if any) if (requiredScopes.length > 0) { const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope)); @@ -205,11 +249,11 @@ export function bearerAuthChallengeResponse( * } * ``` */ -export function requireBearerAuth(options: BearerAuthOptions): (request: Request) => Promise { +export function requireBearerAuth(options: VerifyBearerTokenOptions): (request: Request) => Promise { // Destructure at creation so a plain-JS caller passing undefined or // malformed options crashes at startup, not on the first request. - const { verifier, requiredScopes = [], resourceMetadataUrl } = options; - const resolved = { verifier, requiredScopes, resourceMetadataUrl }; + const { verifier, requiredScopes = [], resourceMetadataUrl, expectedResource } = options; + const resolved = { verifier, requiredScopes, resourceMetadataUrl, expectedResource }; return async request => { // Outside the try: a wrong-framework misuse (no web-standard Request) // should throw loudly, not surface as a 500 challenge. Fetch's diff --git a/packages/server/test/server/bearerAuth.test.ts b/packages/server/test/server/bearerAuth.test.ts index 24aabcb965..ee6e4e7a8e 100644 --- a/packages/server/test/server/bearerAuth.test.ts +++ b/packages/server/test/server/bearerAuth.test.ts @@ -94,6 +94,83 @@ describe('verifyBearerToken', () => { }); }); +describe('verifyBearerToken with expectedResource', () => { + const expectedResource = new URL('https://api.example.com/mcp'); + const notIssuedForThisResource = { code: OAuthErrorCode.InvalidToken, message: 'Token was not issued for this resource' }; + + function verifierReporting(resource: string | undefined): OAuthTokenVerifier { + return verifierReturning(resource === undefined ? validAuthInfo : { ...validAuthInfo, resource: new URL(resource) }); + } + + it('compares AuthInfo.resource only when expectedResource is set', async () => { + const verifier = verifierReporting('https://other.example.com/mcp'); + await expect(verifyBearerToken('Bearer t', { verifier })).resolves.toMatchObject({ token: 'valid-token' }); + await expect(verifyBearerToken('Bearer t', { verifier, expectedResource })).rejects.toMatchObject(notIssuedForThisResource); + }); + + it('rejects a token without a reported resource only when expectedResource is set', async () => { + const verifier = verifierReporting(undefined); + await expect(verifyBearerToken('Bearer t', { verifier })).resolves.toEqual(validAuthInfo); + await expect(verifyBearerToken('Bearer t', { verifier, expectedResource })).rejects.toMatchObject(notIssuedForThisResource); + }); + + it('accepts a token reported for expectedResource, one trailing slash aside on either value', async () => { + for (const [reported, expected] of [ + ['https://api.example.com/mcp', 'https://api.example.com/mcp'], + ['https://api.example.com/mcp/', 'https://api.example.com/mcp'], + ['https://api.example.com/mcp', 'https://api.example.com/mcp/'], + ['HTTPS://API.example.com:443/mcp', 'https://api.example.com/mcp'] + ] as const) { + const options = { verifier: verifierReporting(reported), expectedResource: new URL(expected) }; + await expect(verifyBearerToken('Bearer t', options)).resolves.toMatchObject({ token: 'valid-token' }); + } + const twoSlashes = { verifier: verifierReporting('https://api.example.com/mcp//'), expectedResource }; + await expect(verifyBearerToken('Bearer t', twoSlashes)).rejects.toMatchObject(notIssuedForThisResource); + }); + + it('ignores a fragment on either value', async () => { + for (const [reported, expected] of [ + ['https://api.example.com/mcp#section', 'https://api.example.com/mcp'], + ['https://api.example.com/mcp', 'https://api.example.com/mcp#section'], + ['https://api.example.com/mcp/#section', 'https://api.example.com/mcp'] + ] as const) { + const options = { verifier: verifierReporting(reported), expectedResource: new URL(expected) }; + await expect(verifyBearerToken('Bearer t', options)).resolves.toMatchObject({ token: 'valid-token' }); + } + const otherPath = { verifier: verifierReporting('https://api.example.com/other#section'), expectedResource }; + await expect(verifyBearerToken('Bearer t', otherPath)).rejects.toMatchObject(notIssuedForThisResource); + }); + + it.each([ + 'https://other.example.com/mcp', + 'http://api.example.com/mcp', + 'https://api.example.com:8443/mcp', + 'https://api.example.com/', + 'https://api.example.com/mcp/tools', + 'https://api.example.com/mcp?tenant=a' + ])('rejects a token reported for %s', async resource => { + const verifier = verifierReporting(resource); + await expect(verifyBearerToken('Bearer t', { verifier, expectedResource })).rejects.toMatchObject(notIssuedForThisResource); + }); + + it('rejects a token reported for another resource before checking scopes', async () => { + const verifier = verifierReturning({ ...validAuthInfo, scopes: [], resource: new URL('https://other.example.com/mcp') }); + await expect(verifyBearerToken('Bearer t', { verifier, requiredScopes: ['read'], expectedResource })).rejects.toMatchObject( + notIssuedForThisResource + ); + }); + + it('compares an identifier that is not an http URL the same way', async () => { + const identifier = new URL('api://0b5e1c2a-notes'); + await expect( + verifyBearerToken('Bearer t', { verifier: verifierReporting('api://0b5e1c2a-notes'), expectedResource: identifier }) + ).resolves.toMatchObject({ token: 'valid-token' }); + await expect( + verifyBearerToken('Bearer t', { verifier: verifierReporting('api://0b5e1c2a-other'), expectedResource: identifier }) + ).rejects.toMatchObject(notIssuedForThisResource); + }); +}); + describe('bearerAuthChallengeResponse', () => { it('answers 401 invalid_token with the WWW-Authenticate challenge, resource_metadata last', async () => { const response = bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, 'Token has expired'), { @@ -164,6 +241,24 @@ describe('requireBearerAuth (web-standard)', () => { const result = await gate(new Request('https://api.example.com/mcp', { headers: { authorization: 'Bearer t' } })); expect((result as Response).status).toBe(403); }); + + it('passes expectedResource through to the token check', async () => { + const expectedResource = new URL('https://api.example.com/mcp'); + const request = () => new Request('https://api.example.com/mcp', { headers: { authorization: 'Bearer t' } }); + + const sameResource = { ...validAuthInfo, resource: new URL('https://api.example.com/mcp') }; + expect(await requireBearerAuth({ verifier: verifierReturning(sameResource), expectedResource })(request())).toEqual(sameResource); + + const otherResource = { ...validAuthInfo, resource: new URL('https://other.example.com/mcp') }; + const result = await requireBearerAuth({ verifier: verifierReturning(otherResource), expectedResource })(request()); + expect(result).toBeInstanceOf(Response); + const response = result as Response; + expect(response.status).toBe(401); + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer error="invalid_token", error_description="Token was not issued for this resource"' + ); + expect(await response.json()).toEqual({ error: 'invalid_token', error_description: 'Token was not issued for this resource' }); + }); }); describe('hardening (review findings)', () => { diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index e686b04084..db73387982 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -1581,12 +1581,7 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#access-token-usage', behavior: 'The resource server validates that the token audience matches its resource identifier.', transports: ['streamableHttp'], - note: 'These exercise the HTTP hosting/auth layer (mostly over real Express); the matrix transport arg is ignored, so they run as a single streamableHttp-labelled cell to avoid duplicate runs.', - knownFailures: [ - { - note: 'src/server/auth/middleware/bearerAuth.ts: authInfo.resource is never compared to the resource identifier — audience validation missing.' - } - ] + note: 'These exercise the HTTP hosting/auth layer (mostly over real Express); the matrix transport arg is ignored, so they run as a single streamableHttp-labelled cell to avoid duplicate runs.' }, 'hosting:auth:authinfo-propagates': { source: 'sdk', diff --git a/test/e2e/scenarios/hosting-express.test.ts b/test/e2e/scenarios/hosting-express.test.ts index 3710414805..5d9880c6e3 100644 --- a/test/e2e/scenarios/hosting-express.test.ts +++ b/test/e2e/scenarios/hosting-express.test.ts @@ -215,7 +215,7 @@ verifies('hosting:auth:aud-validation', async (_args: TestArgs) => { const app = express(); app.use(express.json()); - app.use(requireBearerAuth({ verifier, resourceMetadataUrl: SERVER_RESOURCE_ID })); + app.use(requireBearerAuth({ verifier, resourceMetadataUrl: SERVER_RESOURCE_ID, expectedResource: new URL(SERVER_RESOURCE_ID) })); app.post('/mcp', (_req, res) => { res.json({ jsonrpc: '2.0', id: 1, result: { ok: true } }); }); From b6e5c5574e107c33cb6ae33c2e869780f5684c3a Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:52:51 +0100 Subject: [PATCH 33/44] test(e2e): cover prompts/get without arguments for prompts with a schema (#2928) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- test/e2e/requirements.ts | 10 ++++++ test/e2e/scenarios/prompts.test.ts | 53 ++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index db73387982..9033fb286e 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -823,6 +823,16 @@ export const REQUIREMENTS: Record = { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#getting-a-prompt', behavior: "prompts/get with no arguments returns the prompt's messages." }, + 'prompts:get:omitted-args:all-optional': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#getting-a-prompt', + behavior: + "prompts/get that omits the arguments field entirely for a prompt whose declared arguments are all optional returns the prompt's messages." + }, + 'prompts:get:omitted-args:required': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#error-handling', + behavior: + 'prompts/get that omits the arguments field entirely for a prompt with a required argument returns JSON-RPC error -32602 (Invalid params) naming that argument.' + }, 'prompts:get:unknown-name': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/prompts#error-handling', behavior: 'prompts/get for an unknown prompt name returns JSON-RPC error -32602 (Invalid params).' diff --git a/test/e2e/scenarios/prompts.test.ts b/test/e2e/scenarios/prompts.test.ts index a77829975a..aa2b1b81e4 100644 --- a/test/e2e/scenarios/prompts.test.ts +++ b/test/e2e/scenarios/prompts.test.ts @@ -276,6 +276,59 @@ verifies('prompts:get:missing-required-args', async ({ transport }: TestArgs) => }); }); +verifies('prompts:get:omitted-args:all-optional', async ({ transport }: TestArgs) => { + const received: unknown[] = []; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerPrompt( + 'code-review', + { description: 'Review code, optionally focused on one aspect.', argsSchema: z.object({ focus: z.string().optional() }) }, + args => { + received.push(args); + return { + messages: [ + { + role: 'user', + content: { type: 'text', text: args.focus ? `Review the code, focusing on ${args.focus}:` : 'Review the code:' } + } + ] + }; + } + ); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + // getPrompt({ name }) puts no `arguments` key on the wire at all (unlike `arguments: {}`). + const result = await client.getPrompt({ name: 'code-review' }); + + expect(result.messages).toEqual([{ role: 'user', content: { type: 'text', text: 'Review the code:' } }]); + // The omitted field is validated as an empty object, so the handler sees no keys rather than undefined. + expect(received).toEqual([{}]); +}); + +verifies('prompts:get:omitted-args:required', async ({ transport }: TestArgs) => { + const handlerCalls = { n: 0 }; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerPrompt('summarize', { argsSchema: z.object({ text: z.string() }) }, ({ text }) => { + handlerCalls.n++; + return { messages: [{ role: 'user', content: { type: 'text', text: `Summarize the following text:\n${text}` } }] }; + }); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + // No `arguments` key at all: still -32602, and the message names the missing argument. + await expect(client.getPrompt({ name: 'summarize' })).rejects.toMatchObject({ + code: ProtocolErrorCode.InvalidParams, + message: expect.stringContaining('text') + }); + expect(handlerCalls.n).toBe(0); +}); + verifies('prompts:get:unknown-name', async ({ transport }: TestArgs) => { const client = newClient(); await using _ = await wire(transport, explainCommitServer, client); From 2d731faef167b3df78d4be05f547a134f0e23bcf Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:19:39 +0100 Subject: [PATCH 34/44] fix(client): refresh again when an SSE retry failed for a reason other than 401 (#2934) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- packages/client/src/client/auth.ts | 3 +- packages/client/src/client/sse.ts | 8 ++- packages/client/src/client/streamableHttp.ts | 4 +- packages/client/test/client/sse.test.ts | 58 ++++++++++++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 0696eb240b..a95aca0a03 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -87,7 +87,8 @@ export interface AuthProvider { /** * Called when the server responds with 401. If provided, the transport will - * await this, then retry the request once. If the retry also gets 401, or if + * await this, then retry the request once. If the retry also gets 401, the + * transport throws `SdkHttpError` (`SdkErrorCode.ClientHttpAuthentication`). If * this method is not provided, the transport throws {@linkcode UnauthorizedError}. * * Implementations should refresh tokens, re-authenticate, etc. — whatever is diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts index 43c9c7c5db..55c346ac85 100644 --- a/packages/client/src/client/sse.ts +++ b/packages/client/src/client/sse.ts @@ -76,8 +76,8 @@ export type SSEClientTransportOptions = { * {@linkcode AuthProvider.token | token()} is called before every request to obtain the * bearer token. When the server responds with 401, {@linkcode AuthProvider.onUnauthorized | onUnauthorized()} * is called (if provided) to refresh credentials, then the request is retried once. If - * the retry also gets 401, or `onUnauthorized` is not provided, {@linkcode UnauthorizedError} - * is thrown. + * the retry also gets 401, `SdkHttpError` (`SdkErrorCode.ClientHttpAuthentication`) is thrown. + * If `onUnauthorized` is not provided, {@linkcode UnauthorizedError} is thrown. * * For simple bearer tokens: `{ token: async () => myApiKey }`. * @@ -203,7 +203,7 @@ export class SSEClientTransport implements Transport { } private _last401Response?: Response; - // True between a 401-triggered reconnect and the next successful open. + // True from a 401-triggered refresh until the retry opens or fails. private _connectAuthRetried = false; /** `baseFetch` with redirects handled as `redirectPolicy` says. */ @@ -318,6 +318,8 @@ export class SSEClientTransport implements Transport { return; } + // A retry that failed for another reason is over: a later 401 may refresh again. + this._connectAuthRetried = false; const error = new SseError(event.code, redirect ?? event.message, event); reject(error); this.onerror?.(error); diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts index e7a08c8dd4..091674e101 100644 --- a/packages/client/src/client/streamableHttp.ts +++ b/packages/client/src/client/streamableHttp.ts @@ -161,8 +161,8 @@ export type StreamableHTTPClientTransportOptions = { * {@linkcode AuthProvider.token | token()} is called before every request to obtain the * bearer token. When the server responds with 401, {@linkcode AuthProvider.onUnauthorized | onUnauthorized()} * is called (if provided) to refresh credentials, then the request is retried once. If - * the retry also gets 401, or `onUnauthorized` is not provided, {@linkcode UnauthorizedError} - * is thrown. + * the retry also gets 401, `SdkHttpError` (`SdkErrorCode.ClientHttpAuthentication`) is thrown. + * If `onUnauthorized` is not provided, {@linkcode UnauthorizedError} is thrown. * * For simple bearer tokens: `{ token: async () => myApiKey }`. * diff --git a/packages/client/test/client/sse.test.ts b/packages/client/test/client/sse.test.ts index 8b4a8edae8..c9ceb23c3d 100644 --- a/packages/client/test/client/sse.test.ts +++ b/packages/client/test/client/sse.test.ts @@ -1861,6 +1861,64 @@ describe('SSEClientTransport', () => { expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); }); + it('SSE reconnect: a retry that fails at the network level does not use up the refresh — a later 401 refreshes again', async () => { + await resourceServer.close(); + + let getAttempt = 0; + resourceServer = createServer((req, res) => { + if (req.method !== 'GET') { + res.writeHead(404).end(); + return; + } + getAttempt++; + // 1: opens then drops, 2: 401, 3: connection reset right after the refresh, 4: 401 again, 5: opens and stays. + if (getAttempt === 2 || getAttempt === 4) { + res.writeHead(401).end(); + return; + } + if (getAttempt === 3) { + req.socket.destroy(); + return; + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive' + }); + res.write('retry: 10\n'); + res.write('event: endpoint\n'); + res.write(`data: ${resourceBaseUrl.href}post\n\n`); + if (getAttempt === 1) { + res.end(); + } + }); + resourceBaseUrl = await listenOnRandomPort(resourceServer); + + const authProvider: AuthProvider = { + token: vi.fn(async () => 'token'), + onUnauthorized: vi.fn(async () => {}) + }; + transport = new SSEClientTransport(resourceBaseUrl, { authProvider }); + const onerror = vi.fn(); + transport.onerror = onerror; + + // The EventSource created after a refresh waits its default 3 s before it reconnects: skip that wait. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + try { + await transport.start(); + // Two errors so far: the dropped stream (attempt 1) and the reset (attempt 3). + await vi.waitFor(() => expect(onerror).toHaveBeenCalledTimes(2)); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(3000); + await vi.waitFor(() => expect(getAttempt).toBe(5)); + expect(authProvider.onUnauthorized).toHaveBeenCalledTimes(2); + expect(onerror).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + it('retry failure during SSE connect fires onerror exactly once', async () => { // Regression: when the retry EventSource rejected, its onerror fired inside, then // the outer .then() rejection handler fired onerror AGAIN for the same error. From a202a3621fcb97918e09fa45d0f9efd2dbdee19b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:41:26 +0100 Subject: [PATCH 35/44] Version Packages (#2896) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/bearer-auth-expected-resource.md | 6 --- .changeset/bound-tool-input-elements.md | 5 --- .../era-gate-explicit-schema-handlers.md | 6 --- .../eventsource-parser-large-sse-memory.md | 5 --- .changeset/fix-omitted-prompt-arguments.md | 5 --- .changeset/lazy-tool-schema-conversion.md | 5 --- .changeset/node-hono-regular-dependency.md | 5 --- .changeset/one-instance-per-request.md | 28 ------------ .changeset/origin-scheme-entries.md | 5 --- .changeset/package-license-field.md | 13 ------ .../probe-unusable-2xx-reply-message.md | 9 ---- .changeset/redirects-within-origin.md | 5 --- .../register-prompt-context-only-overload.md | 5 --- .changeset/sse-connect-retry-once.md | 5 --- packages/client/CHANGELOG.md | 25 +++++++++++ packages/client/package.json | 2 +- packages/codemod/CHANGELOG.md | 6 +++ packages/codemod/package.json | 2 +- packages/core-internal/CHANGELOG.md | 7 +++ packages/core-internal/package.json | 2 +- packages/core/CHANGELOG.md | 6 +++ packages/core/package.json | 2 +- packages/middleware/express/CHANGELOG.md | 30 +++++++++++++ packages/middleware/express/package.json | 2 +- packages/middleware/fastify/CHANGELOG.md | 28 ++++++++++++ packages/middleware/fastify/package.json | 2 +- packages/middleware/hono/CHANGELOG.md | 28 ++++++++++++ packages/middleware/hono/package.json | 2 +- packages/middleware/node/CHANGELOG.md | 30 +++++++++++++ packages/middleware/node/package.json | 2 +- packages/server-legacy/CHANGELOG.md | 9 ++++ packages/server-legacy/package.json | 2 +- packages/server/CHANGELOG.md | 44 +++++++++++++++++++ packages/server/package.json | 2 +- 34 files changed, 223 insertions(+), 117 deletions(-) delete mode 100644 .changeset/bearer-auth-expected-resource.md delete mode 100644 .changeset/bound-tool-input-elements.md delete mode 100644 .changeset/era-gate-explicit-schema-handlers.md delete mode 100644 .changeset/eventsource-parser-large-sse-memory.md delete mode 100644 .changeset/fix-omitted-prompt-arguments.md delete mode 100644 .changeset/lazy-tool-schema-conversion.md delete mode 100644 .changeset/node-hono-regular-dependency.md delete mode 100644 .changeset/one-instance-per-request.md delete mode 100644 .changeset/origin-scheme-entries.md delete mode 100644 .changeset/package-license-field.md delete mode 100644 .changeset/probe-unusable-2xx-reply-message.md delete mode 100644 .changeset/redirects-within-origin.md delete mode 100644 .changeset/register-prompt-context-only-overload.md delete mode 100644 .changeset/sse-connect-retry-once.md diff --git a/.changeset/bearer-auth-expected-resource.md b/.changeset/bearer-auth-expected-resource.md deleted file mode 100644 index 4effba0cac..0000000000 --- a/.changeset/bearer-auth-expected-resource.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@modelcontextprotocol/server': minor -'@modelcontextprotocol/express': patch ---- - -`requireBearerAuth` and `verifyBearerToken` take a new optional `expectedResource`, which makes them accept only tokens issued for this resource (the token's audience). Set it to the value your authorization server puts into tokens meant for this server, usually the server's URL. When it is set, a token is accepted only if the verifier reports that value in `AuthInfo.resource`; the two are compared as strings, ignoring a fragment and one trailing slash. A token reported for another value, or for none, is answered `401 invalid_token` with the usual `WWW-Authenticate` challenge. When it is not set, nothing changes. To use it, pass `expectedResource` and have `verifyAccessToken` fill `AuthInfo.resource`, for example from the `aud` claim. The option is declared on a new exported type, `VerifyBearerTokenOptions`, which extends `BearerAuthOptions`; `BearerAuthOptions` itself is unchanged. The Express `requireBearerAuth` passes the option through. With Express, `@modelcontextprotocol/express` has to be upgraded to this release as well: 2.0.1 does not pass the option on, so nothing is compared. Its options type does not have the option, so TypeScript reports an `expectedResource` written in a call to the 2.0.1 `requireBearerAuth` as an error. diff --git a/.changeset/bound-tool-input-elements.md b/.changeset/bound-tool-input-elements.md deleted file mode 100644 index 6b29329405..0000000000 --- a/.changeset/bound-tool-input-elements.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server': minor ---- - -`McpServer` now accepts a `maxToolInputElements` option that limits the number of elements in tool-call arguments: the largest combined number of array elements and object members a single `tools/call` `arguments` payload may contain. It is off by default, so behavior is unchanged unless you set it. When it is set and a call exceeds it, that call is answered with an `isError: true` tool result that names the limit, before the input schema runs, and the server keeps serving. Set it above the largest arguments your tools legitimately accept; `maxRequestBodySize` remains the primary limit on request size. The value must be a number of at least 1, or `Infinity` for no limit; any other value is rejected at construction. The options type is exported as `McpServerOptions`. diff --git a/.changeset/era-gate-explicit-schema-handlers.md b/.changeset/era-gate-explicit-schema-handlers.md deleted file mode 100644 index db051a95b9..0000000000 --- a/.changeset/era-gate-explicit-schema-handlers.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@modelcontextprotocol/client': patch -'@modelcontextprotocol/server': patch ---- - -A server can now serve, and a client can now call, `tasks/get` and `tasks/cancel` of the Tasks extension (SEP-2663) on a 2026-07-28 connection, when the handler is registered and the request is sent with an explicit schema. Every other method that a protocol revision removed is still refused. If one server factory serves both eras and such a handler is meant for 2025-era clients only, register it only when `ctx.era === 'legacy'`. diff --git a/.changeset/eventsource-parser-large-sse-memory.md b/.changeset/eventsource-parser-large-sse-memory.md deleted file mode 100644 index 168eab8bc4..0000000000 --- a/.changeset/eventsource-parser-large-sse-memory.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Receiving a large message as a single SSE event over Streamable HTTP, such as a tool result of tens of megabytes, is now fast: a 50 MB result that took about 13 seconds arrives in under a second. The client now requires `eventsource-parser` 3.0.8 or later. `SSEClientTransport` reads through the `eventsource` package and gets the same speed-up once that also resolves `eventsource-parser` 3.0.8 or later. diff --git a/.changeset/fix-omitted-prompt-arguments.md b/.changeset/fix-omitted-prompt-arguments.md deleted file mode 100644 index 95b0b17a4b..0000000000 --- a/.changeset/fix-omitted-prompt-arguments.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server': patch ---- - -`prompts/get` without `arguments` no longer fails with "Invalid arguments" when every argument of the prompt is optional. A missing `arguments` is now validated as `{}`, as it already is for `tools/call`, so a top-level `.optional()` or `.default(...)` on `argsSchema` no longer sees `undefined`. diff --git a/.changeset/lazy-tool-schema-conversion.md b/.changeset/lazy-tool-schema-conversion.md deleted file mode 100644 index 61d75a3f1c..0000000000 --- a/.changeset/lazy-tool-schema-conversion.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server': patch ---- - -`registerTool` no longer converts tool schemas up front, so a server built per request stops converting every tool on every request. The warning about an invalid `x-mcp-header` declaration now appears each time tools are listed, not when the tool is registered. diff --git a/.changeset/node-hono-regular-dependency.md b/.changeset/node-hono-regular-dependency.md deleted file mode 100644 index 1a70253b0c..0000000000 --- a/.changeset/node-hono-regular-dependency.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/node': patch ---- - -`hono` is now a regular dependency of `@modelcontextprotocol/node`, so installs with strict peer-dependency checking no longer fail on the `hono` peer that `@hono/node-server` requires. No runtime change. diff --git a/.changeset/one-instance-per-request.md b/.changeset/one-instance-per-request.md deleted file mode 100644 index fe83fc4193..0000000000 --- a/.changeset/one-instance-per-request.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@modelcontextprotocol/server': minor -'@modelcontextprotocol/node': patch -'@modelcontextprotocol/express': patch -'@modelcontextprotocol/fastify': patch -'@modelcontextprotocol/hono': patch ---- - -A `Server` or `McpServer` now serves one connection at a time, and a Streamable HTTP server transport without sessions (`sessionIdGenerator: undefined`) serves one request. An app that uses one server object, or one stateless transport, for every HTTP request fails on the second request after this upgrade. Build the server and the transport per request instead. - -What keeps working without a change: - -- `createMcpHandler(buildServer)` and `serveStdio(buildServer)`, where `buildServer` returns a new server on every call. -- A handler that builds a new server and a new stateless transport for each request. -- One server and one transport per session (a transport with a `sessionIdGenerator`). -- Connecting a server again after `close()`. -- `Client`. - -What fails now, how it shows, and what to change: - -- One server object with a new stateless transport per request (`const server = new McpServer(...)` outside the handler, `await server.connect(transport)` inside it): the second HTTP request the process receives fails, and so does every later one. `connect()` rejects with an `SdkError` of code `ALREADY_CONNECTED`. If the handler closes the transport when the response ends, requests that arrive one after the other still work and a request that overlaps another one fails. Change: move `new McpServer(...)` and its registrations into the handler. -- One stateless transport for every request (a transport built once with `sessionIdGenerator: undefined`): the second HTTP request fails. `WebStandardStreamableHTTPServerTransport.handleRequest()` rejects with `Stateless transport cannot be reused across requests. Create a new transport per request.`, and `NodeStreamableHTTPServerTransport.handleRequest()` answers `500`. Change: build the server and the transport inside the handler and connect them there. -- `createMcpHandler(() => server)` with a server built once: a request that arrives after the previous response has been read to its end still works. A request that arrives while another one is being served is answered `500` with the JSON-RPC error `-32603` (`Internal server error`); the reason is reported only through the `onerror` option. Change: pass a function that builds the server, as in `createMcpHandler(buildServer)`. -- One server object for every session: the `initialize` request of the second session fails with `ALREADY_CONNECTED`. Change: build a server per session. - -What the caller sees when `connect()` or `handleRequest()` rejects depends on the host. Express 5, Fastify and Hono answer `500`. A plain `node:http` listener without its own error handling gets an unhandled rejection, which ends the process. - -The README examples of `@modelcontextprotocol/express`, `@modelcontextprotocol/fastify`, `@modelcontextprotocol/hono` and `@modelcontextprotocol/node`, and the handler examples in the JSDoc of `WebStandardStreamableHTTPServerTransport` and `NodeStreamableHTTPServerTransport`, now build a server and a transport per request. diff --git a/.changeset/origin-scheme-entries.md b/.changeset/origin-scheme-entries.md deleted file mode 100644 index 2f3513dc0a..0000000000 --- a/.changeset/origin-scheme-entries.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server': minor ---- - -`allowedOrigins` and `validateOriginHeader` accept lowercase entries of the form `://*`, such as `moz-extension://*` or `chrome-extension://*`, which admit every origin of that scheme. This lets a server admit MCP clients that run as a browser extension when the extension ID cannot be listed, as on Firefox, where it differs on every install. `http://*` and `https://*` are not honoured, and the defaults are unchanged. diff --git a/.changeset/package-license-field.md b/.changeset/package-license-field.md deleted file mode 100644 index 68cd32c989..0000000000 --- a/.changeset/package-license-field.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@modelcontextprotocol/client': patch -'@modelcontextprotocol/codemod': patch -'@modelcontextprotocol/core': patch -'@modelcontextprotocol/server': patch -'@modelcontextprotocol/server-legacy': patch -'@modelcontextprotocol/node': patch -'@modelcontextprotocol/express': patch -'@modelcontextprotocol/fastify': patch -'@modelcontextprotocol/hono': patch ---- - -The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. diff --git a/.changeset/probe-unusable-2xx-reply-message.md b/.changeset/probe-unusable-2xx-reply-message.md deleted file mode 100644 index 13309768ff..0000000000 --- a/.changeset/probe-unusable-2xx-reply-message.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -With `versionNegotiation` in `'auto'` or pin mode, a `server/discover` probe answered with a 2xx that carries no usable reply (a body that -is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type) still rejects `connect()` with -`EraNegotiationFailed`; an empty SSE stream or a `202` surfaces as the probe timeout instead. The message now says -`the server answered with an unusable reply (...)` instead of reading like a network failure. To connect to a 2025 server behind a front that -answers the probe this way, pass `connect(transport, { prior: { kind: 'legacy' } })` or use `mode: 'legacy'`. diff --git a/.changeset/redirects-within-origin.md b/.changeset/redirects-within-origin.md deleted file mode 100644 index 7d1ff8593c..0000000000 --- a/.changeset/redirects-within-origin.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': minor ---- - -The HTTP client transports and the OAuth client helpers now follow a redirect only when it stays within the origin of the request (same scheme, host and port, or http to https on the same host with default ports) and keeps the method (a 307 or 308, or any redirect of a GET). Any other redirect is not followed. A transport then fails the request with an error that names the target; the session is kept and later messages still send. OAuth metadata discovery moves on to the next well-known URL, and any other OAuth request fails with an error that gives the status. Same-origin redirects that keep the method keep working on Node, up to five in a row, and no code changes are needed there. If your endpoint redirects to another origin, configure the transport with the URL it redirects to. A `requestInit.redirect` of `'error'` or `'manual'` is passed to fetch as it is for the requests a transport sends to the server (POST, GET and DELETE of the Streamable HTTP transport, POST of the SSE transport); for its OAuth requests, and for any other value, `requestInit.redirect` is not consulted by default. Browsers do not expose the target of a redirect to a page, so there a redirected request fails instead of being followed. Setting `redirectPolicy: 'follow'` on a transport leaves its redirects to the fetch implementation, as before this change. diff --git a/.changeset/register-prompt-context-only-overload.md b/.changeset/register-prompt-context-only-overload.md deleted file mode 100644 index 26203274ea..0000000000 --- a/.changeset/register-prompt-context-only-overload.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server': patch ---- - -`McpServer.registerPrompt()` now types the callback correctly when no `argsSchema` is given: its one parameter is the server context. Before, reading `ctx.mcpReq` there was a type error although it worked at runtime. Prompts registered with an `argsSchema` are unchanged. diff --git a/.changeset/sse-connect-retry-once.md b/.changeset/sse-connect-retry-once.md deleted file mode 100644 index cb90c3aa39..0000000000 --- a/.changeset/sse-connect-retry-once.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -`SSEClientTransport` now retries the SSE connection once after `onUnauthorized()` resolves, as documented. If the retry is also answered with 401, `start()` rejects with `SdkHttpError` (`ClientHttpAuthentication`) instead of calling `onUnauthorized()` again. A 401 on a later reconnect of a stream that had opened still gets one refresh. diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index a4c620cc35..7cfc048172 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,30 @@ # @modelcontextprotocol/client +## 2.3.0 + +### Minor Changes + +- [#2901](https://github.com/modelcontextprotocol/typescript-sdk/pull/2901) [`433eb41`](https://github.com/modelcontextprotocol/typescript-sdk/commit/433eb413dc305ebeb93b8ebcde0095a8fc0d5fa0) Thanks [@claude](https://github.com/apps/claude)! - The HTTP client transports and the OAuth client helpers now follow a redirect only when it stays within the origin of the request (same scheme, host and port, or http to https on the same host with default ports) and keeps the method (a 307 or 308, or any redirect of a GET). Any other redirect is not followed. A transport then fails the request with an error that names the target; the session is kept and later messages still send. OAuth metadata discovery moves on to the next well-known URL, and any other OAuth request fails with an error that gives the status. Same-origin redirects that keep the method keep working on Node, up to five in a row, and no code changes are needed there. If your endpoint redirects to another origin, configure the transport with the URL it redirects to. A `requestInit.redirect` of `'error'` or `'manual'` is passed to fetch as it is for the requests a transport sends to the server (POST, GET and DELETE of the Streamable HTTP transport, POST of the SSE transport); for its OAuth requests, and for any other value, `requestInit.redirect` is not consulted by default. Browsers do not expose the target of a redirect to a page, so there a redirected request fails instead of being followed. Setting `redirectPolicy: 'follow'` on a transport leaves its redirects to the fetch implementation, as before this change. + +### Patch Changes + +- [#2599](https://github.com/modelcontextprotocol/typescript-sdk/pull/2599) [`5238fba`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5238fba4424f82ec1ae9f6f458dd655ab322062d) Thanks [@freya0926](https://github.com/freya0926)! - A server can now serve, and a client can now call, `tasks/get` and `tasks/cancel` of the Tasks extension (SEP-2663) on a 2026-07-28 connection, when the handler is registered and the request is sent with an explicit schema. Every other method that a protocol revision removed is still refused. If one server factory serves both eras and such a handler is meant for 2025-era clients only, register it only when `ctx.era === 'legacy'`. + +- [#2846](https://github.com/modelcontextprotocol/typescript-sdk/pull/2846) [`63c0fca`](https://github.com/modelcontextprotocol/typescript-sdk/commit/63c0fcab49dc13f22dd7d016d6320c62d05e3ca8) Thanks [@Sthreal](https://github.com/Sthreal)! - Receiving a large message as a single SSE event over Streamable HTTP, such as a tool result of tens of megabytes, is now fast: a 50 MB result that took about 13 seconds arrives in under a second. The client now requires `eventsource-parser` 3.0.8 or later. `SSEClientTransport` reads through the `eventsource` package and gets the same speed-up once that also resolves `eventsource-parser` 3.0.8 or later. + +- [#2908](https://github.com/modelcontextprotocol/typescript-sdk/pull/2908) [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1) Thanks [@claude](https://github.com/apps/claude)! - The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. + +- [#2903](https://github.com/modelcontextprotocol/typescript-sdk/pull/2903) [`e765b3b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e765b3be84b7eba1837ba8da84acd6898cc47c65) Thanks [@claude](https://github.com/apps/claude)! - With `versionNegotiation` in `'auto'` or pin mode, a `server/discover` probe answered with a 2xx that carries no usable reply (a body that + is not JSON under `application/json`, a bare `204`, a missing or unaccepted content type) still rejects `connect()` with + `EraNegotiationFailed`; an empty SSE stream or a `202` surfaces as the probe timeout instead. The message now says + `the server answered with an unusable reply (...)` instead of reading like a network failure. To connect to a 2025 server behind a front that + answers the probe this way, pass `connect(transport, { prior: { kind: 'legacy' } })` or use `mode: 'legacy'`. + +- [#2905](https://github.com/modelcontextprotocol/typescript-sdk/pull/2905) [`c0cd01a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/c0cd01a21d867e57b29d7216416b25bf36898292) Thanks [@claude](https://github.com/apps/claude)! - `SSEClientTransport` now retries the SSE connection once after `onUnauthorized()` resolves, as documented. If the retry is also answered with 401, `start()` rejects with `SdkHttpError` (`ClientHttpAuthentication`) instead of calling `onUnauthorized()` again. A 401 on a later reconnect of a stream that had opened still gets one refresh. + +- Updated dependencies [[`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1)]: + - @modelcontextprotocol/core@2.3.0 + ## 2.2.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 66621972f9..b9b60f11c1 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/client", - "version": "2.2.0", + "version": "2.3.0", "description": "Model Context Protocol implementation for TypeScript - Client package", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/codemod/CHANGELOG.md b/packages/codemod/CHANGELOG.md index f40b02ca33..07211bd43f 100644 --- a/packages/codemod/CHANGELOG.md +++ b/packages/codemod/CHANGELOG.md @@ -1,5 +1,11 @@ # @modelcontextprotocol/codemod +## 2.3.0 + +### Patch Changes + +- [#2908](https://github.com/modelcontextprotocol/typescript-sdk/pull/2908) [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1) Thanks [@claude](https://github.com/apps/claude)! - The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. + ## 2.2.0 ### Patch Changes diff --git a/packages/codemod/package.json b/packages/codemod/package.json index cb8fe9b436..7f5cee40c6 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/codemod", - "version": "2.2.0", + "version": "2.3.0", "description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core-internal/CHANGELOG.md b/packages/core-internal/CHANGELOG.md index fcf3a8c856..78a9f80338 100644 --- a/packages/core-internal/CHANGELOG.md +++ b/packages/core-internal/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/core-internal +## 2.0.3 + +### Patch Changes + +- Updated dependencies [[`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1)]: + - @modelcontextprotocol/core@2.3.0 + ## 2.0.2 ### Patch Changes diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 07754feab9..3afe12009a 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.2", + "version": "2.0.3", "description": "Model Context Protocol implementation for TypeScript - Core package", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 669b0d07dd..39e2b1d8c3 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,11 @@ # @modelcontextprotocol/core +## 2.3.0 + +### Patch Changes + +- [#2908](https://github.com/modelcontextprotocol/typescript-sdk/pull/2908) [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1) Thanks [@claude](https://github.com/apps/claude)! - The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. + ## 2.2.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 8ba5d6c88b..c5696165d1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/core", - "version": "2.2.0", + "version": "2.3.0", "description": "Model Context Protocol for TypeScript — public Zod schemas (spec + OAuth/OpenID)", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/express/CHANGELOG.md b/packages/middleware/express/CHANGELOG.md index 951a2cc210..7a40c70e20 100644 --- a/packages/middleware/express/CHANGELOG.md +++ b/packages/middleware/express/CHANGELOG.md @@ -1,5 +1,35 @@ # @modelcontextprotocol/express +## 2.0.2 + +### Patch Changes + +- [#2929](https://github.com/modelcontextprotocol/typescript-sdk/pull/2929) [`40f8f4e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/40f8f4e229d963cd7fd2890bda2aeebe7005299a) Thanks [@claude](https://github.com/apps/claude)! - `requireBearerAuth` and `verifyBearerToken` take a new optional `expectedResource`, which makes them accept only tokens issued for this resource (the token's audience). Set it to the value your authorization server puts into tokens meant for this server, usually the server's URL. When it is set, a token is accepted only if the verifier reports that value in `AuthInfo.resource`; the two are compared as strings, ignoring a fragment and one trailing slash. A token reported for another value, or for none, is answered `401 invalid_token` with the usual `WWW-Authenticate` challenge. When it is not set, nothing changes. To use it, pass `expectedResource` and have `verifyAccessToken` fill `AuthInfo.resource`, for example from the `aud` claim. The option is declared on a new exported type, `VerifyBearerTokenOptions`, which extends `BearerAuthOptions`; `BearerAuthOptions` itself is unchanged. The Express `requireBearerAuth` passes the option through. With Express, `@modelcontextprotocol/express` has to be upgraded to this release as well: 2.0.1 does not pass the option on, so nothing is compared. Its options type does not have the option, so TypeScript reports an `expectedResource` written in a call to the 2.0.1 `requireBearerAuth` as an error. + +- [#2918](https://github.com/modelcontextprotocol/typescript-sdk/pull/2918) [`84804c2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/84804c22e45a662675a198f853b7f00063838a8d) Thanks [@claude](https://github.com/apps/claude)! - A `Server` or `McpServer` now serves one connection at a time, and a Streamable HTTP server transport without sessions (`sessionIdGenerator: undefined`) serves one request. An app that uses one server object, or one stateless transport, for every HTTP request fails on the second request after this upgrade. Build the server and the transport per request instead. + + What keeps working without a change: + - `createMcpHandler(buildServer)` and `serveStdio(buildServer)`, where `buildServer` returns a new server on every call. + - A handler that builds a new server and a new stateless transport for each request. + - One server and one transport per session (a transport with a `sessionIdGenerator`). + - Connecting a server again after `close()`. + - `Client`. + + What fails now, how it shows, and what to change: + - One server object with a new stateless transport per request (`const server = new McpServer(...)` outside the handler, `await server.connect(transport)` inside it): the second HTTP request the process receives fails, and so does every later one. `connect()` rejects with an `SdkError` of code `ALREADY_CONNECTED`. If the handler closes the transport when the response ends, requests that arrive one after the other still work and a request that overlaps another one fails. Change: move `new McpServer(...)` and its registrations into the handler. + - One stateless transport for every request (a transport built once with `sessionIdGenerator: undefined`): the second HTTP request fails. `WebStandardStreamableHTTPServerTransport.handleRequest()` rejects with `Stateless transport cannot be reused across requests. Create a new transport per request.`, and `NodeStreamableHTTPServerTransport.handleRequest()` answers `500`. Change: build the server and the transport inside the handler and connect them there. + - `createMcpHandler(() => server)` with a server built once: a request that arrives after the previous response has been read to its end still works. A request that arrives while another one is being served is answered `500` with the JSON-RPC error `-32603` (`Internal server error`); the reason is reported only through the `onerror` option. Change: pass a function that builds the server, as in `createMcpHandler(buildServer)`. + - One server object for every session: the `initialize` request of the second session fails with `ALREADY_CONNECTED`. Change: build a server per session. + + What the caller sees when `connect()` or `handleRequest()` rejects depends on the host. Express 5, Fastify and Hono answer `500`. A plain `node:http` listener without its own error handling gets an unhandled rejection, which ends the process. + + The README examples of `@modelcontextprotocol/express`, `@modelcontextprotocol/fastify`, `@modelcontextprotocol/hono` and `@modelcontextprotocol/node`, and the handler examples in the JSDoc of `WebStandardStreamableHTTPServerTransport` and `NodeStreamableHTTPServerTransport`, now build a server and a transport per request. + +- [#2908](https://github.com/modelcontextprotocol/typescript-sdk/pull/2908) [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1) Thanks [@claude](https://github.com/apps/claude)! - The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. + +- Updated dependencies [[`40f8f4e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/40f8f4e229d963cd7fd2890bda2aeebe7005299a), [`6d8dbc6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6d8dbc6cb590edf766fcb66b636ca6b654e99c37), [`5238fba`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5238fba4424f82ec1ae9f6f458dd655ab322062d), [`2fc49ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2fc49eaf17a6b9ac0810c875cc96d3b949f9522c), [`4d94e7b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/4d94e7b1ccf769d94a7bbba7789f1ee6c7dfdd8c), [`84804c2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/84804c22e45a662675a198f853b7f00063838a8d), [`e55f9ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e55f9ac1b1cae413599b499cbaa45c0378edba78), [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1), [`2237555`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2237555ed036c3e80341c0f3c28684e9c3ff0728)]: + - @modelcontextprotocol/server@2.3.0 + ## 2.0.1 ### Patch Changes diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index 7cc6b47a6e..8b2d903744 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.1", + "version": "2.0.2", "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/fastify/CHANGELOG.md b/packages/middleware/fastify/CHANGELOG.md index 35823f1e73..376c3e784a 100644 --- a/packages/middleware/fastify/CHANGELOG.md +++ b/packages/middleware/fastify/CHANGELOG.md @@ -1,5 +1,33 @@ # @modelcontextprotocol/fastify +## 2.0.1 + +### Patch Changes + +- [#2918](https://github.com/modelcontextprotocol/typescript-sdk/pull/2918) [`84804c2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/84804c22e45a662675a198f853b7f00063838a8d) Thanks [@claude](https://github.com/apps/claude)! - A `Server` or `McpServer` now serves one connection at a time, and a Streamable HTTP server transport without sessions (`sessionIdGenerator: undefined`) serves one request. An app that uses one server object, or one stateless transport, for every HTTP request fails on the second request after this upgrade. Build the server and the transport per request instead. + + What keeps working without a change: + - `createMcpHandler(buildServer)` and `serveStdio(buildServer)`, where `buildServer` returns a new server on every call. + - A handler that builds a new server and a new stateless transport for each request. + - One server and one transport per session (a transport with a `sessionIdGenerator`). + - Connecting a server again after `close()`. + - `Client`. + + What fails now, how it shows, and what to change: + - One server object with a new stateless transport per request (`const server = new McpServer(...)` outside the handler, `await server.connect(transport)` inside it): the second HTTP request the process receives fails, and so does every later one. `connect()` rejects with an `SdkError` of code `ALREADY_CONNECTED`. If the handler closes the transport when the response ends, requests that arrive one after the other still work and a request that overlaps another one fails. Change: move `new McpServer(...)` and its registrations into the handler. + - One stateless transport for every request (a transport built once with `sessionIdGenerator: undefined`): the second HTTP request fails. `WebStandardStreamableHTTPServerTransport.handleRequest()` rejects with `Stateless transport cannot be reused across requests. Create a new transport per request.`, and `NodeStreamableHTTPServerTransport.handleRequest()` answers `500`. Change: build the server and the transport inside the handler and connect them there. + - `createMcpHandler(() => server)` with a server built once: a request that arrives after the previous response has been read to its end still works. A request that arrives while another one is being served is answered `500` with the JSON-RPC error `-32603` (`Internal server error`); the reason is reported only through the `onerror` option. Change: pass a function that builds the server, as in `createMcpHandler(buildServer)`. + - One server object for every session: the `initialize` request of the second session fails with `ALREADY_CONNECTED`. Change: build a server per session. + + What the caller sees when `connect()` or `handleRequest()` rejects depends on the host. Express 5, Fastify and Hono answer `500`. A plain `node:http` listener without its own error handling gets an unhandled rejection, which ends the process. + + The README examples of `@modelcontextprotocol/express`, `@modelcontextprotocol/fastify`, `@modelcontextprotocol/hono` and `@modelcontextprotocol/node`, and the handler examples in the JSDoc of `WebStandardStreamableHTTPServerTransport` and `NodeStreamableHTTPServerTransport`, now build a server and a transport per request. + +- [#2908](https://github.com/modelcontextprotocol/typescript-sdk/pull/2908) [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1) Thanks [@claude](https://github.com/apps/claude)! - The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. + +- Updated dependencies [[`40f8f4e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/40f8f4e229d963cd7fd2890bda2aeebe7005299a), [`6d8dbc6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6d8dbc6cb590edf766fcb66b636ca6b654e99c37), [`5238fba`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5238fba4424f82ec1ae9f6f458dd655ab322062d), [`2fc49ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2fc49eaf17a6b9ac0810c875cc96d3b949f9522c), [`4d94e7b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/4d94e7b1ccf769d94a7bbba7789f1ee6c7dfdd8c), [`84804c2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/84804c22e45a662675a198f853b7f00063838a8d), [`e55f9ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e55f9ac1b1cae413599b499cbaa45c0378edba78), [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1), [`2237555`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2237555ed036c3e80341c0f3c28684e9c3ff0728)]: + - @modelcontextprotocol/server@2.3.0 + ## 2.0.0 ### Patch Changes diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json index 09ad7230dd..5da17a14f4 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", + "version": "2.0.1", "description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/hono/CHANGELOG.md b/packages/middleware/hono/CHANGELOG.md index d00c3ccc02..8d97c1764a 100644 --- a/packages/middleware/hono/CHANGELOG.md +++ b/packages/middleware/hono/CHANGELOG.md @@ -1,5 +1,33 @@ # @modelcontextprotocol/hono +## 2.0.2 + +### Patch Changes + +- [#2918](https://github.com/modelcontextprotocol/typescript-sdk/pull/2918) [`84804c2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/84804c22e45a662675a198f853b7f00063838a8d) Thanks [@claude](https://github.com/apps/claude)! - A `Server` or `McpServer` now serves one connection at a time, and a Streamable HTTP server transport without sessions (`sessionIdGenerator: undefined`) serves one request. An app that uses one server object, or one stateless transport, for every HTTP request fails on the second request after this upgrade. Build the server and the transport per request instead. + + What keeps working without a change: + - `createMcpHandler(buildServer)` and `serveStdio(buildServer)`, where `buildServer` returns a new server on every call. + - A handler that builds a new server and a new stateless transport for each request. + - One server and one transport per session (a transport with a `sessionIdGenerator`). + - Connecting a server again after `close()`. + - `Client`. + + What fails now, how it shows, and what to change: + - One server object with a new stateless transport per request (`const server = new McpServer(...)` outside the handler, `await server.connect(transport)` inside it): the second HTTP request the process receives fails, and so does every later one. `connect()` rejects with an `SdkError` of code `ALREADY_CONNECTED`. If the handler closes the transport when the response ends, requests that arrive one after the other still work and a request that overlaps another one fails. Change: move `new McpServer(...)` and its registrations into the handler. + - One stateless transport for every request (a transport built once with `sessionIdGenerator: undefined`): the second HTTP request fails. `WebStandardStreamableHTTPServerTransport.handleRequest()` rejects with `Stateless transport cannot be reused across requests. Create a new transport per request.`, and `NodeStreamableHTTPServerTransport.handleRequest()` answers `500`. Change: build the server and the transport inside the handler and connect them there. + - `createMcpHandler(() => server)` with a server built once: a request that arrives after the previous response has been read to its end still works. A request that arrives while another one is being served is answered `500` with the JSON-RPC error `-32603` (`Internal server error`); the reason is reported only through the `onerror` option. Change: pass a function that builds the server, as in `createMcpHandler(buildServer)`. + - One server object for every session: the `initialize` request of the second session fails with `ALREADY_CONNECTED`. Change: build a server per session. + + What the caller sees when `connect()` or `handleRequest()` rejects depends on the host. Express 5, Fastify and Hono answer `500`. A plain `node:http` listener without its own error handling gets an unhandled rejection, which ends the process. + + The README examples of `@modelcontextprotocol/express`, `@modelcontextprotocol/fastify`, `@modelcontextprotocol/hono` and `@modelcontextprotocol/node`, and the handler examples in the JSDoc of `WebStandardStreamableHTTPServerTransport` and `NodeStreamableHTTPServerTransport`, now build a server and a transport per request. + +- [#2908](https://github.com/modelcontextprotocol/typescript-sdk/pull/2908) [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1) Thanks [@claude](https://github.com/apps/claude)! - The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. + +- Updated dependencies [[`40f8f4e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/40f8f4e229d963cd7fd2890bda2aeebe7005299a), [`6d8dbc6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6d8dbc6cb590edf766fcb66b636ca6b654e99c37), [`5238fba`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5238fba4424f82ec1ae9f6f458dd655ab322062d), [`2fc49ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2fc49eaf17a6b9ac0810c875cc96d3b949f9522c), [`4d94e7b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/4d94e7b1ccf769d94a7bbba7789f1ee6c7dfdd8c), [`84804c2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/84804c22e45a662675a198f853b7f00063838a8d), [`e55f9ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e55f9ac1b1cae413599b499cbaa45c0378edba78), [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1), [`2237555`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2237555ed036c3e80341c0f3c28684e9c3ff0728)]: + - @modelcontextprotocol/server@2.3.0 + ## 2.0.1 ### Patch Changes diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json index 89d52003b2..b06c2a00ca 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.1", + "version": "2.0.2", "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/node/CHANGELOG.md b/packages/middleware/node/CHANGELOG.md index ebd9186a7b..6d467ced68 100644 --- a/packages/middleware/node/CHANGELOG.md +++ b/packages/middleware/node/CHANGELOG.md @@ -1,5 +1,35 @@ # @modelcontextprotocol/node +## 2.1.1 + +### Patch Changes + +- [#2897](https://github.com/modelcontextprotocol/typescript-sdk/pull/2897) [`7f4c12a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7f4c12a6ae6b8f22411f7772c88036e1c8055423) Thanks [@claude](https://github.com/apps/claude)! - `hono` is now a regular dependency of `@modelcontextprotocol/node`, so installs with strict peer-dependency checking no longer fail on the `hono` peer that `@hono/node-server` requires. No runtime change. + +- [#2918](https://github.com/modelcontextprotocol/typescript-sdk/pull/2918) [`84804c2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/84804c22e45a662675a198f853b7f00063838a8d) Thanks [@claude](https://github.com/apps/claude)! - A `Server` or `McpServer` now serves one connection at a time, and a Streamable HTTP server transport without sessions (`sessionIdGenerator: undefined`) serves one request. An app that uses one server object, or one stateless transport, for every HTTP request fails on the second request after this upgrade. Build the server and the transport per request instead. + + What keeps working without a change: + - `createMcpHandler(buildServer)` and `serveStdio(buildServer)`, where `buildServer` returns a new server on every call. + - A handler that builds a new server and a new stateless transport for each request. + - One server and one transport per session (a transport with a `sessionIdGenerator`). + - Connecting a server again after `close()`. + - `Client`. + + What fails now, how it shows, and what to change: + - One server object with a new stateless transport per request (`const server = new McpServer(...)` outside the handler, `await server.connect(transport)` inside it): the second HTTP request the process receives fails, and so does every later one. `connect()` rejects with an `SdkError` of code `ALREADY_CONNECTED`. If the handler closes the transport when the response ends, requests that arrive one after the other still work and a request that overlaps another one fails. Change: move `new McpServer(...)` and its registrations into the handler. + - One stateless transport for every request (a transport built once with `sessionIdGenerator: undefined`): the second HTTP request fails. `WebStandardStreamableHTTPServerTransport.handleRequest()` rejects with `Stateless transport cannot be reused across requests. Create a new transport per request.`, and `NodeStreamableHTTPServerTransport.handleRequest()` answers `500`. Change: build the server and the transport inside the handler and connect them there. + - `createMcpHandler(() => server)` with a server built once: a request that arrives after the previous response has been read to its end still works. A request that arrives while another one is being served is answered `500` with the JSON-RPC error `-32603` (`Internal server error`); the reason is reported only through the `onerror` option. Change: pass a function that builds the server, as in `createMcpHandler(buildServer)`. + - One server object for every session: the `initialize` request of the second session fails with `ALREADY_CONNECTED`. Change: build a server per session. + + What the caller sees when `connect()` or `handleRequest()` rejects depends on the host. Express 5, Fastify and Hono answer `500`. A plain `node:http` listener without its own error handling gets an unhandled rejection, which ends the process. + + The README examples of `@modelcontextprotocol/express`, `@modelcontextprotocol/fastify`, `@modelcontextprotocol/hono` and `@modelcontextprotocol/node`, and the handler examples in the JSDoc of `WebStandardStreamableHTTPServerTransport` and `NodeStreamableHTTPServerTransport`, now build a server and a transport per request. + +- [#2908](https://github.com/modelcontextprotocol/typescript-sdk/pull/2908) [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1) Thanks [@claude](https://github.com/apps/claude)! - The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. + +- Updated dependencies [[`40f8f4e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/40f8f4e229d963cd7fd2890bda2aeebe7005299a), [`6d8dbc6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6d8dbc6cb590edf766fcb66b636ca6b654e99c37), [`5238fba`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5238fba4424f82ec1ae9f6f458dd655ab322062d), [`2fc49ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2fc49eaf17a6b9ac0810c875cc96d3b949f9522c), [`4d94e7b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/4d94e7b1ccf769d94a7bbba7789f1ee6c7dfdd8c), [`84804c2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/84804c22e45a662675a198f853b7f00063838a8d), [`e55f9ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e55f9ac1b1cae413599b499cbaa45c0378edba78), [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1), [`2237555`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2237555ed036c3e80341c0f3c28684e9c3ff0728)]: + - @modelcontextprotocol/server@2.3.0 + ## 2.1.0 ### Minor Changes diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index 25ef2cabdb..48dafc4d20 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/node", - "version": "2.1.0", + "version": "2.1.1", "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/server-legacy/CHANGELOG.md b/packages/server-legacy/CHANGELOG.md index 84aeffc0b0..ae8a63bf41 100644 --- a/packages/server-legacy/CHANGELOG.md +++ b/packages/server-legacy/CHANGELOG.md @@ -1,5 +1,14 @@ # @modelcontextprotocol/server-legacy +## 2.3.0 + +### Patch Changes + +- [#2908](https://github.com/modelcontextprotocol/typescript-sdk/pull/2908) [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1) Thanks [@claude](https://github.com/apps/claude)! - The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. + +- Updated dependencies [[`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1)]: + - @modelcontextprotocol/core@2.3.0 + ## 2.2.0 ### Patch Changes diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index 4ee45161c2..554565368b 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.2.0", + "version": "2.3.0", "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": "Apache-2.0", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 8ea1932a19..c56874a091 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,49 @@ # @modelcontextprotocol/server +## 2.3.0 + +### Minor Changes + +- [#2929](https://github.com/modelcontextprotocol/typescript-sdk/pull/2929) [`40f8f4e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/40f8f4e229d963cd7fd2890bda2aeebe7005299a) Thanks [@claude](https://github.com/apps/claude)! - `requireBearerAuth` and `verifyBearerToken` take a new optional `expectedResource`, which makes them accept only tokens issued for this resource (the token's audience). Set it to the value your authorization server puts into tokens meant for this server, usually the server's URL. When it is set, a token is accepted only if the verifier reports that value in `AuthInfo.resource`; the two are compared as strings, ignoring a fragment and one trailing slash. A token reported for another value, or for none, is answered `401 invalid_token` with the usual `WWW-Authenticate` challenge. When it is not set, nothing changes. To use it, pass `expectedResource` and have `verifyAccessToken` fill `AuthInfo.resource`, for example from the `aud` claim. The option is declared on a new exported type, `VerifyBearerTokenOptions`, which extends `BearerAuthOptions`; `BearerAuthOptions` itself is unchanged. The Express `requireBearerAuth` passes the option through. With Express, `@modelcontextprotocol/express` has to be upgraded to this release as well: 2.0.1 does not pass the option on, so nothing is compared. Its options type does not have the option, so TypeScript reports an `expectedResource` written in a call to the 2.0.1 `requireBearerAuth` as an error. + +- [#2926](https://github.com/modelcontextprotocol/typescript-sdk/pull/2926) [`6d8dbc6`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6d8dbc6cb590edf766fcb66b636ca6b654e99c37) Thanks [@claude](https://github.com/apps/claude)! - `McpServer` now accepts a `maxToolInputElements` option that limits the number of elements in tool-call arguments: the largest combined number of array elements and object members a single `tools/call` `arguments` payload may contain. It is off by default, so behavior is unchanged unless you set it. When it is set and a call exceeds it, that call is answered with an `isError: true` tool result that names the limit, before the input schema runs, and the server keeps serving. Set it above the largest arguments your tools legitimately accept; `maxRequestBodySize` remains the primary limit on request size. The value must be a number of at least 1, or `Infinity` for no limit; any other value is rejected at construction. The options type is exported as `McpServerOptions`. + +- [#2918](https://github.com/modelcontextprotocol/typescript-sdk/pull/2918) [`84804c2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/84804c22e45a662675a198f853b7f00063838a8d) Thanks [@claude](https://github.com/apps/claude)! - A `Server` or `McpServer` now serves one connection at a time, and a Streamable HTTP server transport without sessions (`sessionIdGenerator: undefined`) serves one request. An app that uses one server object, or one stateless transport, for every HTTP request fails on the second request after this upgrade. Build the server and the transport per request instead. + + What keeps working without a change: + - `createMcpHandler(buildServer)` and `serveStdio(buildServer)`, where `buildServer` returns a new server on every call. + - A handler that builds a new server and a new stateless transport for each request. + - One server and one transport per session (a transport with a `sessionIdGenerator`). + - Connecting a server again after `close()`. + - `Client`. + + What fails now, how it shows, and what to change: + - One server object with a new stateless transport per request (`const server = new McpServer(...)` outside the handler, `await server.connect(transport)` inside it): the second HTTP request the process receives fails, and so does every later one. `connect()` rejects with an `SdkError` of code `ALREADY_CONNECTED`. If the handler closes the transport when the response ends, requests that arrive one after the other still work and a request that overlaps another one fails. Change: move `new McpServer(...)` and its registrations into the handler. + - One stateless transport for every request (a transport built once with `sessionIdGenerator: undefined`): the second HTTP request fails. `WebStandardStreamableHTTPServerTransport.handleRequest()` rejects with `Stateless transport cannot be reused across requests. Create a new transport per request.`, and `NodeStreamableHTTPServerTransport.handleRequest()` answers `500`. Change: build the server and the transport inside the handler and connect them there. + - `createMcpHandler(() => server)` with a server built once: a request that arrives after the previous response has been read to its end still works. A request that arrives while another one is being served is answered `500` with the JSON-RPC error `-32603` (`Internal server error`); the reason is reported only through the `onerror` option. Change: pass a function that builds the server, as in `createMcpHandler(buildServer)`. + - One server object for every session: the `initialize` request of the second session fails with `ALREADY_CONNECTED`. Change: build a server per session. + + What the caller sees when `connect()` or `handleRequest()` rejects depends on the host. Express 5, Fastify and Hono answer `500`. A plain `node:http` listener without its own error handling gets an unhandled rejection, which ends the process. + + The README examples of `@modelcontextprotocol/express`, `@modelcontextprotocol/fastify`, `@modelcontextprotocol/hono` and `@modelcontextprotocol/node`, and the handler examples in the JSDoc of `WebStandardStreamableHTTPServerTransport` and `NodeStreamableHTTPServerTransport`, now build a server and a transport per request. + +- [#2907](https://github.com/modelcontextprotocol/typescript-sdk/pull/2907) [`e55f9ac`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e55f9ac1b1cae413599b499cbaa45c0378edba78) Thanks [@claude](https://github.com/apps/claude)! - `allowedOrigins` and `validateOriginHeader` accept lowercase entries of the form `://*`, such as `moz-extension://*` or `chrome-extension://*`, which admit every origin of that scheme. This lets a server admit MCP clients that run as a browser extension when the extension ID cannot be listed, as on Firefox, where it differs on every install. `http://*` and `https://*` are not honoured, and the defaults are unchanged. + +### Patch Changes + +- [#2599](https://github.com/modelcontextprotocol/typescript-sdk/pull/2599) [`5238fba`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5238fba4424f82ec1ae9f6f458dd655ab322062d) Thanks [@freya0926](https://github.com/freya0926)! - A server can now serve, and a client can now call, `tasks/get` and `tasks/cancel` of the Tasks extension (SEP-2663) on a 2026-07-28 connection, when the handler is registered and the request is sent with an explicit schema. Every other method that a protocol revision removed is still refused. If one server factory serves both eras and such a handler is meant for 2025-era clients only, register it only when `ctx.era === 'legacy'`. + +- [#2107](https://github.com/modelcontextprotocol/typescript-sdk/pull/2107) [`2fc49ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2fc49eaf17a6b9ac0810c875cc96d3b949f9522c) Thanks [@pragnyanramtha](https://github.com/pragnyanramtha)! - `prompts/get` without `arguments` no longer fails with "Invalid arguments" when every argument of the prompt is optional. A missing `arguments` is now validated as `{}`, as it already is for `tools/call`, so a top-level `.optional()` or `.default(...)` on `argsSchema` no longer sees `undefined`. + +- [#2889](https://github.com/modelcontextprotocol/typescript-sdk/pull/2889) [`4d94e7b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/4d94e7b1ccf769d94a7bbba7789f1ee6c7dfdd8c) Thanks [@claude](https://github.com/apps/claude)! - `registerTool` no longer converts tool schemas up front, so a server built per request stops converting every tool on every request. The warning about an invalid `x-mcp-header` declaration now appears each time tools are listed, not when the tool is registered. + +- [#2908](https://github.com/modelcontextprotocol/typescript-sdk/pull/2908) [`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1) Thanks [@claude](https://github.com/apps/claude)! - The `license` field of the package manifests is now `Apache-2.0`; the `LICENSE` file shipped in each package carries the full terms, including the MIT text for earlier contributions. No code change. + +- [#2841](https://github.com/modelcontextprotocol/typescript-sdk/pull/2841) [`2237555`](https://github.com/modelcontextprotocol/typescript-sdk/commit/2237555ed036c3e80341c0f3c28684e9c3ff0728) Thanks [@sharziki](https://github.com/sharziki)! - `McpServer.registerPrompt()` now types the callback correctly when no `argsSchema` is given: its one parameter is the server context. Before, reading `ctx.mcpReq` there was a type error although it worked at runtime. Prompts registered with an `argsSchema` are unchanged. + +- Updated dependencies [[`633dd3e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/633dd3e12bff6869c932c4a526341622320912b1)]: + - @modelcontextprotocol/core@2.3.0 + ## 2.2.0 ### Patch Changes diff --git a/packages/server/package.json b/packages/server/package.json index 84f2d331a7..806417873d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server", - "version": "2.2.0", + "version": "2.3.0", "description": "Model Context Protocol implementation for TypeScript - Server package", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", From 818f7828898f367bfd4436bf55ccdfa17c3757d6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:34:03 +0100 Subject: [PATCH 36/44] docs: state the principles in CLAUDE.md and shorten REVIEW.md (#2938) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- CLAUDE.md | 294 ++---------------------------------------------- CONTRIBUTING.md | 10 ++ REVIEW.md | 96 +--------------- 3 files changed, 25 insertions(+), 375 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b027791d8e..041c5e11ef 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,284 +1,14 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Build & Test Commands - -```sh -pnpm install # Install all workspace dependencies - -pnpm build:all # Build all packages -pnpm lint:all # Run ESLint + Prettier checks across all packages -pnpm lint:fix:all # Auto-fix lint and formatting issues across all packages -pnpm typecheck:all # Type-check all packages -pnpm test:all # Run all tests (vitest) across all packages -pnpm check:all # typecheck + lint across all packages - -# Run a single package script (examples) -# Run a single package script from the repo root with pnpm filter -pnpm --filter @modelcontextprotocol/core-internal test # vitest run (core) -pnpm --filter @modelcontextprotocol/core-internal test:watch # vitest (watch) -pnpm --filter @modelcontextprotocol/core-internal test -- path/to/file.test.ts -pnpm --filter @modelcontextprotocol/core-internal test -- -t "test name" -``` - -## Breaking Changes - -When making breaking changes, add to the relevant subsystem section in -`docs/migration/upgrade-to-v2.md` (or `docs/migration/support-2026-07-28.md` if the -change is 2026-07-28-only). Mechanical renames go in -`packages/codemod/src/migrations/v1-to-v2/mappings/` and the codemod handles them — do -not reproduce mapping tables in the guide; link to the mapping file instead. - -Include what changed, why, and how to migrate. Search for related sections and group related changes together rather than adding new standalone sections. - -## Code Style Guidelines - -- **TypeScript**: Strict type checking, ES modules, explicit return types -- **Naming**: PascalCase for classes/types, camelCase for functions/variables -- **Files**: Lowercase with hyphens, test files with `.test.ts` suffix -- **Imports**: ES module style, no `.js` extension on relative imports (project uses `moduleResolution: bundler`), group imports logically -- **Formatting**: 2-space indentation, semicolons required, single quotes preferred -- **Testing**: Place tests under each package's `test/` directory (vitest only includes `test/**/*.test.ts`), use descriptive test names -- **Comments**: JSDoc for public APIs, inline comments for complex logic - -### JSDoc `@example` Code Snippets - -JSDoc `@example` tags should pull type-checked code from companion `.examples.ts` files (e.g., `client.ts` → `client.examples.ts`). Use ` ```ts source="./file.examples.ts#regionName" ` fences referencing `//#region regionName` blocks; region names follow `exportedName_variant` or `ClassName_methodName_variant` pattern (e.g., `applyMiddlewares_basicUsage`, `Client_connect_basicUsage`). For whole-file inclusion (any file type), omit the `#regionName`. - -Run `pnpm sync:snippets` to sync example content into JSDoc comments and markdown files. - -## Architecture Overview - -### Core Layers - -The SDK is organized into three main layers: - -1. **Types Layer** (`packages/core-internal/src/types/types.ts`) - Protocol types generated from the MCP specification. All JSON-RPC message types, schemas, and protocol constants are defined here using Zod v4. - -2. **Protocol Layer** (`packages/core-internal/src/shared/protocol.ts`) - The abstract `Protocol` class that handles JSON-RPC message routing, request/response correlation, capability negotiation, and transport management. Both `Client` and `Server` extend this class. - -3. **High-Level APIs**: - - `Client` (`packages/client/src/client/client.ts`) - Client implementation extending Protocol with typed methods for MCP operations - - `Server` (`packages/server/src/server/server.ts`) - Server implementation extending Protocol with request handler registration - - `McpServer` (`packages/server/src/server/mcp.ts`) - High-level server API with simplified resource/tool/prompt registration - -### Public API Exports - -The SDK separates internal code from the public API surface: - -- **`@modelcontextprotocol/core-internal`** (main entry, `packages/core-internal/src/index.ts`) — Internal barrel. Exports everything (including Zod schemas, Protocol class, stdio utils). Only consumed by sibling packages within the monorepo (`private: true`). -- **`@modelcontextprotocol/core-internal/public`** (`packages/core-internal/src/exports/public/index.ts`) — Curated public API. Exports TypeScript types, error classes, constants, guards, and the `Protocol` base class (+ `mergeCapabilities`). Re-exported by client and server packages. -- **`@modelcontextprotocol/client`** and **`@modelcontextprotocol/server`** (`packages/*/src/index.ts`) — Final public surface. Package-specific exports (named explicitly) plus re-exports from `core-internal/public`. -- **`@modelcontextprotocol/core`** (`packages/core/src/index.ts`) — Public Zod-schema package and the canonical home of the schema source modules (`src/schemas.ts`, `src/auth.ts`, `src/constants.ts`). The root entry re-exports **only** the `*Schema` Zod constants (MCP spec + OAuth/OpenID) — the published home for raw runtime validation (`CallToolResultSchema.parse(...)`); runtime-neutral (`zod` is its only dependency). The `./internal` subpath re-exports the schema modules wholesale for the sibling packages: `core-internal` re-exports them at the old module paths, and the `client`/`server`/`server-legacy` bundles resolve `@modelcontextprotocol/core/internal` as a real external dependency instead of carrying their own schema copies (their public surfaces stay Zod-free). - -When modifying exports: - -- Use explicit named exports, not `export *`, in package `index.ts` files and `core-internal/public`. -- Adding a symbol to a package `index.ts` makes it public API — do so intentionally. -- Internal helpers should stay in the core internal barrel and not be added to `core-internal/public` or package index files. -- The package root entry must stay runtime-neutral so browser and Cloudflare Workers bundlers can consume it. Exports whose module graph transitively touches unpolyfillable Node builtins (`node:child_process`, `node:net`, `cross-spawn`, etc.) must live at a named subpath export (e.g. `./stdio`) and be covered by a `barrelClean` test in that package. - -### Transport System - -Transports (`packages/core-internal/src/shared/transport.ts`) provide the communication layer: - -- **Streamable HTTP** (`packages/server/src/server/streamableHttp.ts`, `packages/client/src/client/streamableHttp.ts`) - Recommended transport for remote servers, supports SSE for streaming -- **SSE** (`packages/server/src/server/sse.ts`, `packages/client/src/client/sse.ts`) - Legacy HTTP+SSE transport for backwards compatibility -- **stdio** (`packages/server/src/server/stdio.ts`, `packages/client/src/client/stdio.ts`) - For local process-spawned integrations - -### Server-Side Features - -- **Tools/Resources/Prompts**: Registered via `McpServer.tool()`, `.resource()`, `.prompt()` methods -- **OAuth/Auth**: Full OAuth 2.0 server implementation in `packages/server/src/server/auth/` -- **Completions**: Auto-completion support via `packages/server/src/server/completable.ts` - -### Client-Side Features - -- **Auth**: OAuth client support in `packages/client/src/client/auth.ts` and `packages/client/src/client/auth-extensions.ts` -- **Client middleware**: Request middleware in `packages/client/src/client/middleware.ts` (unrelated to the framework adapter packages below) -- **Sampling**: Clients can handle `sampling/createMessage` requests from servers (LLM completions) -- **Elicitation**: Clients can handle `elicitation/create` requests for user input (form or URL mode) -- **Roots**: Clients can expose filesystem roots to servers via `roots/list` - -### Middleware packages (framework/runtime adapters) - -The repo also ships “middleware” packages under `packages/middleware/` (e.g. `@modelcontextprotocol/express`, `@modelcontextprotocol/hono`, `@modelcontextprotocol/node`). These are thin integration layers for specific frameworks/runtimes and should not add new MCP functionality. - -### Experimental Features - -Located in `packages/*/src/experimental/`. Currently empty. - -### Zod Schemas - -The SDK uses `zod/v4` internally. Schema utilities live in: - -- `packages/core-internal/src/util/schema.ts` - AnySchema alias and helpers for inspecting Zod objects - -### Validation - -Pluggable JSON Schema validation (`packages/core-internal/src/validators/`): - -- `ajvProvider.ts` - Default Ajv-based validator -- `cfWorkerProvider.ts` - Cloudflare Workers-compatible alternative - -### Examples - -Runnable examples in `examples//{server.ts,client.ts}` — each story is its own -`@mcp-examples/` workspace package and a self-verifying e2e test (the client connects, -asserts results, exits non-zero on mismatch). `pnpm run:examples` runs every story over its -configured transport×era legs; the `examples (build + e2e)` CI job is part of the per-PR gate -basket. See `examples/README.md` for the full story matrix. - -- `examples/shared/` — `@mcp-examples/shared` package. Root export is args-only (`parseExampleArgs`, `check`, `siblingPath`); the demo OAuth provider and `InMemoryEventStore` live at the `@mcp-examples/shared/auth` subpath so non-auth stories don't eagerly evaluate better-auth/express/better-sqlite3. Stories import only this plumbing and inline the SDK transport setup themselves — see `examples/CONTRIBUTING.md`. -- `scripts/examples/` — runner (`run-examples.ts`) -- `examples/guides/` — per-page snippet companions for the `docs/` guide pages (one `
/.examples.ts` per page); fences sync via `pnpm sync:snippets`, and the runnable ones are executed in CI by `pnpm docs:examples` - -## Message Flow (Bidirectional Protocol) - -MCP is bidirectional: both client and server can send requests. Understanding this flow is essential when implementing new request types. - -### Class Hierarchy - -``` -Protocol (abstract base) -├── Client (packages/client/src/client/client.ts) - can send requests TO server, handle requests FROM server -└── Server (packages/server/src/server/server.ts) - can send requests TO client, handle requests FROM client - └── McpServer (packages/server/src/server/mcp.ts) - high-level wrapper around Server -``` - -### Outbound Flow: Sending Requests - -When code calls `client.callTool()` or `server.createMessage()`: - -1. **High-level method** (e.g., `Client.callTool()`) calls `this.request()` -2. **`Protocol.request()`**: - - Assigns unique message ID - - Checks capabilities via `assertCapabilityForMethod()` (abstract, implemented by Client/Server) - - Creates response handler promise - - Calls `transport.send()` with JSON-RPC request - - Waits for response handler to resolve -3. **Transport** serializes and sends over wire (HTTP, stdio, etc.) -4. **`Protocol._onresponse()`** resolves the promise when response arrives - -### Inbound Flow: Handling Requests - -When a request arrives from the remote side: - -1. **Transport** receives message, calls `transport.onmessage()` -2. **`Protocol.connect()`** routes to `_onrequest()`, `_onresponse()`, or `_onnotification()` -3. **`Protocol._onrequest()`**: - - Looks up handler in `_requestHandlers` map (keyed by method name) - - Creates `BaseContext` with `signal`, `sessionId`, `sendNotification`, `sendRequest`, etc. - - Calls `buildContext()` to let subclasses enrich the context (e.g., Server adds HTTP request info) - - Invokes handler, sends JSON-RPC response back via transport -4. **Handler** was registered via `setRequestHandler('method', handler)` - -### Handler Registration - -```typescript -// In Client (for server→client requests like sampling, elicitation) -client.setRequestHandler('sampling/createMessage', async (request, ctx) => { - // Handle sampling request from server - return { role: "assistant", content: {...}, model: "..." }; -}); - -// In Server (for client→server requests like tools/call) -server.setRequestHandler('tools/call', async (request, ctx) => { - // Handle tool call from client - return { content: [...] }; -}); -``` - -### Request Handler Context - -The `ctx` parameter in handlers provides a structured context: - -**`BaseContext`** (common to both Server and Client), fields organized into nested groups: - -- `sessionId?`: Transport session identifier -- `mcpReq`: Request-level concerns - - `id`: JSON-RPC message ID - - `method`: Request method string (e.g., 'tools/call') - - `_meta?`: Request metadata - - `signal`: AbortSignal for cancellation - - `send(request, schema, options?)`: Send related request (for bidirectional flows) - - `notify(notification)`: Send related notification back -- `http?`: HTTP transport info (undefined for stdio) - - `authInfo?`: Validated auth token info - -**`ServerContext`** extends `BaseContext.mcpReq` and `BaseContext.http?` via type intersection: - -- `mcpReq` adds: `log(level, data, logger?)`, `elicitInput(params, options?)`, `requestSampling(params, options?)` -- `http?` adds: `req?` (HTTP request info), `closeSSE?`, `closeStandaloneSSE?` - -**`ClientContext`** is currently identical to `BaseContext`. - -### Capability Checking - -Both sides declare capabilities during initialization. The SDK enforces these: - -- **Client→Server**: `Client.assertCapabilityForMethod()` checks `_serverCapabilities` -- **Server→Client**: `Server.assertCapabilityForMethod()` checks `_clientCapabilities` -- **Handler registration**: `assertRequestHandlerCapability()` validates local capabilities - -### Adding a New Request Type - -1. **Define schema** in `src/types.ts` (request params, result schema) -2. **Add capability** to `ClientCapabilities` or `ServerCapabilities` in types -3. **Implement sender** method in Client or Server class -4. **Add capability check** in the appropriate `assertCapabilityForMethod()` -5. **Register handler** on the receiving side with `setRequestHandler()` -6. **For McpServer**: Add high-level wrapper method if needed - -### Server-Initiated Requests (Sampling, Elicitation) - -Server can request actions from client (requires client capability): - -```typescript -// Server sends sampling request to client -const result = await server.createMessage({ - messages: [...], - maxTokens: 100 -}); - -// Client must have registered handler: -client.setRequestHandler('sampling/createMessage', async (request, extra) => { - // Client-side LLM call - return { role: "assistant", content: {...} }; -}); -``` - -## Key Patterns - -### Request Handler Registration (Low-Level Server) - -```typescript -server.setRequestHandler('tools/call', async (request, extra) => { - // extra contains sessionId, authInfo, sendNotification, etc. - return { - /* result */ - }; -}); -``` - -### Tool Registration (High-Level McpServer) - -```typescript -mcpServer.tool('tool-name', { param: z.string() }, async ({ param }, extra) => { - return { content: [{ type: 'text', text: 'result' }] }; -}); -``` - -### Transport Connection - -```typescript -// Server -// (Node.js IncomingMessage/ServerResponse wrapper; exported by @modelcontextprotocol/node) -const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); -await server.connect(transport); - -// Client -const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); -await client.connect(transport); -``` +The principles this SDK is built and reviewed by. They are not a checklist: apply them with judgement. + +1. **Minimalism** — The SDK should do less, not more: prefer changes that add no API or option unless justified. +2. **Spec is the anchor** — The spec decides; where it leaves a choice open, check what the other official SDKs do before making your own choice. +3. **Strict by default** — Accept what the spec allows, and add no leniency for peers that break it. +4. **Backwards compatible** — New features and changes arrive in backwards compatible ways; when a bug, a spec violation or an unsafe default forces a break, the changeset says what breaks. +5. **Small changes** — Guard scope hard: a smaller change that lands beats a bigger one that doesn't, and small, independent pull requests can land in any order. +6. **The words are true** — Changesets, docs and comments say what the code does. +7. **Test what users see** — Prefer tests that cover the end-to-end usage a user would actually see, where sensible; `test/e2e/requirements.ts` lists those behaviours. +8. **Brief comments** — Keep inline comments to one line and JSDoc to the public API; the reasoning goes in the commit message or PR description. + +Style belongs to Prettier, ESLint and the compiler. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a3a01784ce..0291cd4e40 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,6 +110,16 @@ Then: 4. Run `pnpm test:all` to verify all tests pass 5. Submit a pull request +### Good to know + +Details about this repository that neither the code nor the tools will tell you. + +- **Spec** — The full spec text is at `https://modelcontextprotocol.io/llms-full.txt`, and the schema for a protocol revision is `schema//schema.ts` in `modelcontextprotocol/modelcontextprotocol`. +- **Tests** — Tests live under each package's `test/` directory; a test file next to its source never runs. +- **Adapters** — The packages under `packages/middleware/` are thin adapters, versioned separately from client and server, so a user can run a new server with an older adapter. +- **Package roots** — A package's root entry stays runtime-neutral so browser and Workers bundlers can use it; Node-only code lives at a subpath export such as `./stdio`. +- **Migration guide** — `docs/migration/upgrade-to-v2.md` covers the upgrade from v1 only; mechanical renames go in `packages/codemod/src/migrations/v1-to-v2/mappings/`. + ### Running Examples See [`examples/README.md`](examples/README.md) for the full list of runnable examples — one self-verifying client/server pair per directory. diff --git a/REVIEW.md b/REVIEW.md index 65b4c35935..9320ba9ff0 100644 --- a/REVIEW.md +++ b/REVIEW.md @@ -1,95 +1,5 @@ -# typescript-sdk Review Conventions +# Reviewing typescript-sdk -Guidance for reviewing pull requests on this repository. The first three sections are -stable principles; the **Recurring Catches** section is auto-maintained from past human -review rounds and grows over time. +Review by the principles in `CLAUDE.md`, with judgement: they are not a checklist. -## Guiding Principles - -1. **Minimalism** — The SDK should do less, not more. Protocol correctness, transport - lifecycle, types, and clean handler context belong in the SDK. Middleware engines, - registry managers, builder patterns, and content helpers belong in userland. -2. **Burden of proof is on addition** — The default answer to "should we add this?" is - no. Removing something from the public API is far harder than not adding it. -3. **Justify with concrete evidence** — Every new abstraction needs a concrete consumer - today. Ask for real issues, benchmarks, real-world examples; apply the same standard - to your own review (link spec sections, link code, show the simpler alternative). -4. **Spec is the anchor** — The SDK implements the protocol spec. The further a feature - drifts from the spec, the stronger the justification needs to be. -5. **Kill at the highest level** — If the design is wrong, don't review the - implementation. Lead with the highest-level concern; specific bugs are supporting - detail. -6. **Decompose by default** — A PR doing multiple things should be multiple PRs unless - there's a strong reason to bundle. - -## Review Ordering - -1. **Design justification** — Is the overall approach sound? Is the complexity warranted? -2. **Structural concerns** — Is the architecture right? Are abstractions justified? -3. **Correctness** — Bugs, regressions, missing functionality. -4. **Style and naming** — Nits, conventions, documentation. - -## Checklist - -**Protocol & spec** -- Types match the schema for the protocol revision being changed (`schema//schema.ts` for released revisions; `schema/draft/schema.ts` only for unreleased work) -- Correct `ProtocolError` codes (enum `ProtocolErrorCode`); HTTP status codes match spec (e.g., 404 vs 410) -- Works for both stdio and Streamable HTTP transports — no transport-specific assumptions -- Cross-SDK consistency: check what `python-sdk` does for the same feature - -**API surface** -- Every new export is intentional (see CLAUDE.md § Public API Exports); helpers users can write themselves belong in a cookbook, not the SDK -- New abstractions have at least one concrete callsite in the PR -- One way to do things — improving an existing API beats adding a parallel one - -**Correctness** -- Async: race conditions, cleanup on cancellation, unhandled rejections, missing `await` -- Error propagation: caught/rethrown properly, resources cleaned up on error paths -- Type safety: no unjustified `any`, no unsafe `as` assertions -- Backwards compat: public-interface changes, default changes, removed exports — flagged and justified - -**Tests & docs** -- New behavior has vitest coverage including error paths -- Breaking changes documented in `docs/migration/upgrade-to-v2.md` (or `docs/migration/support-2026-07-28.md` if 2026-only); mechanical renames added to `packages/codemod/src/migrations/v1-to-v2/mappings/` -- Bugfix or behavior change: check whether `docs/**/*.md` describes the old behavior and needs updating; flag prose that now contradicts the implementation -- New feature: verify prose documentation is added (not just JSDoc), and assess whether `examples/` needs a new or updated example -- Behavior change: assess whether existing `examples/` still compile and demonstrate the current API - -## Reference - -When verifying spec compliance, consult the spec directly rather than relying on memory: - -- MCP documentation server: `https://modelcontextprotocol.io/mcp` -- Full spec text (single file, LLM-friendly): `https://modelcontextprotocol.io/llms-full.txt` — fetch to a temp file and grep for the relevant section -- Schema source of truth: the revision-matched `schema.ts` in `modelcontextprotocol/modelcontextprotocol` (`schema//schema.ts` for released revisions; `schema/draft/schema.ts` only for unreleased work) - -## Recurring Catches - -### HTTP Transport - -- When validating `Mcp-Session-Id`, return **400** for a missing header and **404** for an unknown/expired session — never conflate `!sessionId || !transports[sessionId]` into one status, because the client needs to distinguish "fix your request" from "start a new session". Flag any diff that branches on session-id presence/lookup with a single 4xx. (#1707, #1770) - -### Error Handling - -- Broad `catch` blocks must not emit client-fault JSON-RPC codes (`-32700` ParseError, `-32602` InvalidParams) for server-internal failures like stream setup, task-store misses, or polling errors — map those to `-32603` InternalError so clients don't retry/reformat pointlessly. Flag any catch-all that hard-codes ParseError/InvalidParams without discriminating the thrown cause. (#1752, #1769) - -### Schema Compliance - -- When editing Zod protocol schemas in `schemas.ts`, verify unknown-key handling matches the spec `schema.ts`: if the spec type has no `additionalProperties: false`, the SDK schema must use `z.looseObject()` / `.catchall(z.unknown())` rather than implicit strict — over-strict Zod (incl. `z.literal('object')` on `type`) rejects spec-valid payloads from other SDKs. Also confirm the `spec.types.*.test.ts` comparisons still pass bidirectionally. (#1768, #1849, #1169) - -### Async / Lifecycle - -- In `close()` / shutdown paths, wrap user-supplied or chained callbacks (`onclose?.()`, cancel fns) in `try/finally` so a throw can't skip the remaining teardown (`abort()`, `_onclose()`, map clears) — otherwise the transport is left half-open. (#1735, #1763) -- Deferred callbacks (`setTimeout`, `.finally()`, reconnect closures) must check closed/aborted state before mutating `this._*` or starting I/O — a callback scheduled pre-close can fire after close/reconnect and corrupt the new connection's state (e.g., delete the new request's `AbortController`). (#1735, #1763) - -### Completeness - -- When a PR replaces a pattern (error class, auth-flow step, catch shape), grep the package for surviving instances of the old form — partial migrations leave sibling code paths with the very bug the PR claims to fix. Flag every leftover site. (#1657, #1761, #1595) - -### Documentation & Changesets - -- Read added `.changeset/*.md` text and new inline comments against the implementation in the same diff — prose that promises behavior the code no longer ships misleads consumers and contradicts stated intent. Flag any claim the diff doesn't back. (#1718, #1838) - -### CI & GitHub Actions - -- Do **not** assert that a third-party GitHub Action or publish toolchain will fail or needs extra permissions/tokens without verifying its docs or source — `pnpm publish` delegates to the system npm CLI (so npm OIDC works), and `changesets/action` in publish mode has no PR-comment step requiring `pull-requests: write`. For diffs under `.github/workflows/`, confirm claimed behavior in the action's README/source before flagging. (#1838, #1836) +- **Style is not a review topic** — Prettier, ESLint and the compiler own it. From 33fecfb119cb637022fcb345c61c09227ed04fc4 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:38:43 +0100 Subject: [PATCH 37/44] test(e2e): cover tools/call without arguments (#2933) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- test/e2e/requirements.ts | 10 +++++ test/e2e/scenarios/tools.test.ts | 74 +++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 9033fb286e..fa37dbf5a8 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -379,6 +379,16 @@ export const REQUIREMENTS: Record = { behavior: "Log notifications emitted by a tool handler during execution reach the client's logging callback before the tool result returns." }, + 'tools:call:omitted-args:all-optional': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#calling-tools', + behavior: + 'tools/call that omits the arguments field entirely for a tool whose input schema fields are all optional runs the handler with an empty object and returns its result.' + }, + 'tools:call:omitted-args:required': { + source: 'https://modelcontextprotocol.io/specification/2025-11-25/server/tools#error-handling', + behavior: + 'tools/call that omits the arguments field entirely for a tool with a required argument is answered as a tool execution error (isError: true) whose content names the missing argument, without invoking the handler.' + }, 'tools:call:progress': { source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/progress#progress-flow', behavior: "Progress notifications emitted by a tool handler reach the caller's progress callback before the tool result returns.", diff --git a/test/e2e/scenarios/tools.test.ts b/test/e2e/scenarios/tools.test.ts index 741665143f..b21c997820 100644 --- a/test/e2e/scenarios/tools.test.ts +++ b/test/e2e/scenarios/tools.test.ts @@ -31,7 +31,14 @@ import type { RequestId, Tool } from '@modelcontextprotocol/server'; -import { McpServer, ProtocolError, ProtocolErrorCode, Server, UrlElicitationRequiredError } from '@modelcontextprotocol/server'; +import { + isJSONRPCRequest, + McpServer, + ProtocolError, + ProtocolErrorCode, + Server, + UrlElicitationRequiredError +} from '@modelcontextprotocol/server'; import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; import { expect, vi } from 'vitest'; import { z } from 'zod/v4'; @@ -517,6 +524,71 @@ verifies('tools:call:is-error', async ({ transport }: TestArgs) => { client.transport!.onmessage = original; }); +verifies('tools:call:omitted-args:all-optional', async ({ transport }: TestArgs) => { + // Shared across factory calls so stateless still observes what the handler received. + const received: unknown[] = []; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool( + 'greet', + { description: 'Greets by name, or generically.', inputSchema: z.object({ name: z.string().optional() }) }, + args => { + received.push(args); + return { content: [{ type: 'text', text: args.name ? `Hello, ${args.name}!` : 'Hello!' }] }; + } + ); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + // Tap outbound wire messages to prove the request carries no `arguments` key at all (unlike `arguments: {}`). + const outbound: JSONRPCMessage[] = []; + const tx = client.transport!; + const originalSend = tx.send.bind(tx); + tx.send = async (m, opts) => { + outbound.push(m); + return originalSend(m, opts); + }; + + const result = await client.callTool({ name: 'greet' }); + + const call = outbound.find(m => isJSONRPCRequest(m) && m.method === 'tools/call'); + expect(call).toBeDefined(); + if (!call || !isJSONRPCRequest(call)) throw new Error('expected tools/call request'); + expect(call.params).not.toHaveProperty('arguments'); + + expect(result.isError).toBeFalsy(); + expect(result.content).toEqual([{ type: 'text', text: 'Hello!' }]); + // The omitted field is validated as an empty object, so the handler sees no keys rather than undefined. + expect(received).toEqual([{}]); + + tx.send = originalSend; +}); + +verifies('tools:call:omitted-args:required', async ({ transport }: TestArgs) => { + // Shared across factory calls so stateless still observes the count. + const handlerCalls = { n: 0 }; + const makeServer = () => { + const s = new McpServer({ name: 's', version: '0' }); + s.registerTool('summarize', { inputSchema: z.object({ text: z.string() }) }, ({ text }) => { + handlerCalls.n++; + return { content: [{ type: 'text', text: `Summary of: ${text}` }] }; + }); + return s; + }; + const client = newClient(); + await using _ = await wire(transport, makeServer, client); + + // No `arguments` key at all: an input validation failure is a tool execution error, not a JSON-RPC error. + const result = await client.callTool({ name: 'summarize' }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: 'text', text: expect.stringMatching(/input validation error/i) }]); + expect(result.content).toEqual([{ type: 'text', text: expect.stringContaining('text') }]); + expect(handlerCalls.n).toBe(0); +}); + verifies( 'tools:call:unknown-name', async ({ transport }: TestArgs) => { From 8aabbdcef6e016978a3132045281dfbd51c69792 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 18:43:19 +0000 Subject: [PATCH 38/44] test(e2e): expect SdkHttpError after a second 401 from onUnauthorized (#2936) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- test/e2e/requirements.ts | 10 ++-------- test/e2e/scenarios/client-auth.test.ts | 10 +++++++--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index fa37dbf5a8..36da4fc523 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2937,15 +2937,9 @@ export const REQUIREMENTS: Record = { 'client-auth:authprovider:onunauthorized-retry': { source: 'sdk', behavior: - 'When the server answers 401, the transport awaits AuthProvider.onUnauthorized() and retries the request once with the refreshed token; a second 401 (or a provider without onUnauthorized) surfaces as UnauthorizedError.', + 'When the server answers 401, the transport awaits AuthProvider.onUnauthorized() and retries the request once with the refreshed token; a second 401 on that retry rejects with SdkHttpError (ClientHttpAuthentication), while a provider without onUnauthorized surfaces the 401 as UnauthorizedError.', transports: ['streamableHttp'], - note: "This exercises the HTTP client transport's auth hook; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.", - knownFailures: [ - { - test: 'second 401 after retry surfaces as UnauthorizedError', - note: 'A second 401 after onUnauthorized() re-authentication surfaces as SdkHttpError (ClientHttpAuthentication) instead of the UnauthorizedError documented on AuthProvider.onUnauthorized().' - } - ] + note: "This exercises the HTTP client transport's auth hook; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs." }, 'client-auth:authprovider:oauth-provider-adapted': { source: 'sdk', diff --git a/test/e2e/scenarios/client-auth.test.ts b/test/e2e/scenarios/client-auth.test.ts index 556cd29ef8..134edd4dd4 100644 --- a/test/e2e/scenarios/client-auth.test.ts +++ b/test/e2e/scenarios/client-auth.test.ts @@ -32,6 +32,8 @@ import { RegistrationRejectedError, resolveClientMetadata, SdkError, + SdkErrorCode, + SdkHttpError, SSEClientTransport, SseError, startAuthorization, @@ -2221,7 +2223,7 @@ verifies( const STALE = 'stale-bearer-token'; const ROTATED = 'rotated-but-still-rejected-token'; - // The provider refreshes on 401 but the resource keeps rejecting: the retry's 401 must surface as UnauthorizedError. + // The provider refreshes on 401 but the resource keeps rejecting: the retry's 401 must reject with SdkHttpError (ClientHttpAuthentication). let currentToken = STALE; let unauthorizedCalls = 0; const provider: AuthProvider = { @@ -2244,7 +2246,9 @@ verifies( const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: alwaysUnauthorizedFetch }); try { - await expect(client.connect(transport)).rejects.toThrow(UnauthorizedError); + const connectPromise = client.connect(transport); + await expect(connectPromise).rejects.toBeInstanceOf(SdkHttpError); + await expect(connectPromise).rejects.toMatchObject({ code: SdkErrorCode.ClientHttpAuthentication, status: 401 }); // onUnauthorized ran once and the transport retried exactly once before giving up. expect(unauthorizedCalls).toBe(1); @@ -2253,7 +2257,7 @@ verifies( await client.close(); } }, - { title: 'second 401 after retry surfaces as UnauthorizedError' } + { title: 'second 401 after retry rejects with SdkHttpError' } ); verifies('client-auth:authprovider:oauth-provider-adapted', async (_args: TestArgs) => { From 3ab61c61f5ff1cd6b67eec6222247c66635495fd Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:14:51 +0100 Subject: [PATCH 39/44] docs: update the README banners (#2940) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- README.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 749070e8c5..52d9fdac90 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,15 @@ > [!IMPORTANT] -> **This is the `main` branch — v2 of the SDK** (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`), implementing the [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). +> **This is the `main` branch: v2 of the SDK** (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`), the stable release line, implementing the [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). Start with the [v2 documentation](https://ts.sdk.modelcontextprotocol.io/v2/); coming from v1, see the [migration guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2). > -> **Have feedback? Please [open a v2 issue](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml)** — it is the most useful thing you can do for the SDK right now. The [v2 documentation](https://ts.sdk.modelcontextprotocol.io/v2/) starts with a ten-minute server tutorial. +> **v1.x** (`@modelcontextprotocol/sdk`) lives on the [`v1.x` branch](https://github.com/modelcontextprotocol/typescript-sdk/tree/v1.x) and continues to receive bug fixes and security updates for at least six months after the v2 release (2026-07-27). Its documentation is at [ts.sdk.modelcontextprotocol.io](https://ts.sdk.modelcontextprotocol.io/). > -> **v2 is the stable release line**, released alongside the 2026-07-28 spec. v1.x continues to receive bug fixes and security updates for at least 6 months after v2's release. v1 documentation: [ts.sdk.modelcontextprotocol.io](https://ts.sdk.modelcontextprotocol.io/) · v2: [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/). +> Found a problem in v2? [Open an issue](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml). -> [!WARNING] -> **We're limiting pull requests to 1 per new contributor while v2 settles after the [2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28) release.** -> -> [Issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml) are the most useful feedback right now — we'll reopen PRs as v2 stabilizes. +> [!NOTE] +> **Contributors without write access can have one open pull request at a time.** Draft pull requests don't count toward the limit. For a feature or a larger change, please [open an issue](https://github.com/modelcontextprotocol/typescript-sdk/issues/new/choose) first; see [CONTRIBUTING.md](CONTRIBUTING.md). [![NPM Version - Server](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fserver?label=%40modelcontextprotocol%2Fserver)](https://www.npmjs.com/package/@modelcontextprotocol/server) [![NPM Version - Client](https://img.shields.io/npm/v/%40modelcontextprotocol%2Fclient?label=%40modelcontextprotocol%2Fclient)](https://www.npmjs.com/package/@modelcontextprotocol/client) ![Apache-2.0 licensed](https://img.shields.io/npm/l/%40modelcontextprotocol%2Fserver) From 5a1867390b91b8836f49af76c45427279f513325 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:22:31 +0100 Subject: [PATCH 40/44] feat(server-legacy): add expectedResource to requireBearerAuth (#2952) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/server-legacy-expected-resource.md | 5 ++ .../src/auth/middleware/bearerAuth.ts | 31 +++++++- .../test/auth/middleware/bearerAuth.test.ts | 78 +++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 .changeset/server-legacy-expected-resource.md diff --git a/.changeset/server-legacy-expected-resource.md b/.changeset/server-legacy-expected-resource.md new file mode 100644 index 0000000000..a0e0e60f02 --- /dev/null +++ b/.changeset/server-legacy-expected-resource.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server-legacy': patch +--- + +`requireBearerAuth` in `@modelcontextprotocol/server-legacy` takes the optional `expectedResource` that `@modelcontextprotocol/server` 2.3.0 and `@modelcontextprotocol/sdk` 1.32.0 added: the resource the token must be issued for (its audience), usually the server's URL. When it is set, a token is accepted only if the verifier reports that value in `AuthInfo.resource`; the two are compared as strings, ignoring a fragment and one trailing slash. A token reported for another value, or for none, is answered `401 invalid_token` with the usual `WWW-Authenticate` challenge. When it is not set, nothing changes. The package stays frozen otherwise; this option is added so that its `requireBearerAuth` matches the 1.x middleware it is a copy of. diff --git a/packages/server-legacy/src/auth/middleware/bearerAuth.ts b/packages/server-legacy/src/auth/middleware/bearerAuth.ts index 14710221a2..bc516984f7 100644 --- a/packages/server-legacy/src/auth/middleware/bearerAuth.ts +++ b/packages/server-legacy/src/auth/middleware/bearerAuth.ts @@ -19,8 +19,28 @@ export type BearerAuthMiddlewareOptions = { * Optional resource metadata URL to include in WWW-Authenticate header. */ resourceMetadataUrl?: string; + + /** + * Optional resource the token must be issued for (its audience), usually the server's URL. + * When set, the verifier has to report this value in `AuthInfo.resource` (a fragment and one trailing slash + * are ignored); any other token is refused with `401 invalid_token`. + */ + expectedResource?: URL; }; +// The serialized value without its fragment and without one trailing slash. +function comparableResource(value: URL): string { + const text = String(value); + const hash = text.indexOf('#'); + return (hash === -1 ? text : text.slice(0, hash)).replace(/\/$/, ''); +} + +// A reported resource matches when it serializes to the same string as the expected one, fragment and one trailing slash aside. +function sameResource(reported: URL | undefined, expected: URL): boolean { + if (!reported) return false; + return comparableResource(reported) === comparableResource(expected); +} + declare module 'express-serve-static-core' { interface Request { /** @@ -38,7 +58,12 @@ declare module 'express-serve-static-core' { * If resourceMetadataUrl is provided, it will be included in the WWW-Authenticate header * for 401 responses as per the OAuth 2.0 Protected Resource Metadata spec. */ -export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetadataUrl }: BearerAuthMiddlewareOptions): RequestHandler { +export function requireBearerAuth({ + verifier, + requiredScopes = [], + resourceMetadataUrl, + expectedResource +}: BearerAuthMiddlewareOptions): RequestHandler { const buildWwwAuthHeader = (errorCode: string, message: string): string => { let header = `Bearer error="${errorCode}", error_description="${message}"`; if (requiredScopes.length > 0) { @@ -64,6 +89,10 @@ export function requireBearerAuth({ verifier, requiredScopes = [], resourceMetad const authInfo = await verifier.verifyAccessToken(token); + if (expectedResource !== undefined && !sameResource(authInfo.resource, expectedResource)) { + throw new InvalidTokenError('Token was not issued for this resource'); + } + if (requiredScopes.length > 0) { const hasAllScopes = requiredScopes.every(scope => authInfo.scopes.includes(scope)); diff --git a/packages/server-legacy/test/auth/middleware/bearerAuth.test.ts b/packages/server-legacy/test/auth/middleware/bearerAuth.test.ts index 58df280616..1f4ef6f4a6 100644 --- a/packages/server-legacy/test/auth/middleware/bearerAuth.test.ts +++ b/packages/server-legacy/test/auth/middleware/bearerAuth.test.ts @@ -498,4 +498,82 @@ describe('requireBearerAuth middleware', () => { expect(nextFunction).not.toHaveBeenCalled(); }); }); + + describe('with expectedResource', () => { + const expectedResource = new URL('https://api.example.com/mcp'); + const notIssuedForThisResource = { error: 'invalid_token', error_description: 'Token was not issued for this resource' }; + + async function handle(reported: string | undefined, options: { expectedResource?: URL; requiredScopes?: string[] } = {}) { + const authInfo: AuthInfo = { + token: 'valid-token', + clientId: 'client-123', + scopes: ['read'], + expiresAt: Date.now() / 1000 + 3600 + }; + if (reported !== undefined) authInfo.resource = new URL(reported); + mockVerifyAccessToken.mockResolvedValue(authInfo); + const req = { headers: { authorization: 'Bearer valid-token' } } as Request; + const res = createExpressResponseMock(); + const next = vi.fn(); + await requireBearerAuth({ verifier: mockVerifier, ...options })(req, res, next); + return { req, res, next, authInfo }; + } + + function expectAccepted({ req, res, next, authInfo }: Awaited>) { + expect(req.auth).toEqual(authInfo); + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + } + + function expectUnauthorized({ req, res, next }: Awaited>) { + expect(res.status).toHaveBeenCalledWith(401); + expect(res.set).toHaveBeenCalledWith( + 'WWW-Authenticate', + expect.stringMatching(/^Bearer error="invalid_token", error_description="Token was not issued for this resource"/) + ); + expect(res.json).toHaveBeenCalledWith(notIssuedForThisResource); + expect(req.auth).toBeUndefined(); + expect(next).not.toHaveBeenCalled(); + } + + it('should compare AuthInfo.resource only when expectedResource is set', async () => { + expectAccepted(await handle('https://other.example.com/mcp')); + expectUnauthorized(await handle('https://other.example.com/mcp', { expectedResource })); + }); + + it('should reject a token without a reported resource only when expectedResource is set', async () => { + expectAccepted(await handle(undefined)); + expectUnauthorized(await handle(undefined, { expectedResource })); + }); + + it('should accept a token reported for expectedResource, one trailing slash aside on either value', async () => { + expectAccepted(await handle('https://api.example.com/mcp', { expectedResource })); + expectAccepted(await handle('https://api.example.com/mcp/', { expectedResource })); + expectAccepted(await handle('https://api.example.com/mcp', { expectedResource: new URL('https://api.example.com/mcp/') })); + expectAccepted(await handle('HTTPS://API.example.com:443/mcp', { expectedResource })); + expectUnauthorized(await handle('https://api.example.com/mcp//', { expectedResource })); + }); + + it('should ignore a fragment on either value', async () => { + expectAccepted(await handle('https://api.example.com/mcp#section', { expectedResource })); + expectAccepted(await handle('https://api.example.com/mcp', { expectedResource: new URL(`${expectedResource}#section`) })); + expectAccepted(await handle('https://api.example.com/mcp/#section', { expectedResource })); + expectUnauthorized(await handle('https://api.example.com/other#section', { expectedResource })); + }); + + it.each([ + 'https://other.example.com/mcp', + 'http://api.example.com/mcp', + 'https://api.example.com:8443/mcp', + 'https://api.example.com/', + 'https://api.example.com/mcp/tools', + 'https://api.example.com/mcp?tenant=a' + ])('should return 401 for a token reported for %s', async reported => { + expectUnauthorized(await handle(reported, { expectedResource })); + }); + + it('should return 401 for a token reported for another resource before checking scopes', async () => { + expectUnauthorized(await handle('https://other.example.com/mcp', { expectedResource, requiredScopes: ['write'] })); + }); + }); }); From e8b7f05f5b9e4204510289dc0596531265e9ddcb Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:40:24 +0100 Subject: [PATCH 41/44] docs: one opening note in the server and client package READMEs (#2955) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- packages/client/README.md | 8 +------- packages/server/README.md | 8 +------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/client/README.md b/packages/client/README.md index b142797ef1..b14d37bcc4 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -2,13 +2,7 @@ The MCP (Model Context Protocol) TypeScript client SDK. Build MCP clients that connect to MCP servers. - -> [!WARNING] -> **v2 is the stable release line**, implementing the [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). Migrating from v1? Start with the [migration guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/). - - -> [!NOTE] -> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md)** if you're coming from v1. +> **This is v2 of the MCP TypeScript SDK, the stable release line**, implementing the [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). It replaces the single `@modelcontextprotocol/sdk` package from v1. Start with the [documentation](https://ts.sdk.modelcontextprotocol.io/v2/); coming from v1, see the [migration guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2). Found a problem? [Open an issue](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml). ## Install diff --git a/packages/server/README.md b/packages/server/README.md index fac599cdde..b6918d2ca9 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -2,13 +2,7 @@ The MCP (Model Context Protocol) TypeScript server SDK. Build MCP servers that expose tools, resources, and prompts. - -> [!WARNING] -> **v2 is the stable release line**, implementing the [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). Migrating from v1? Start with the [migration guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/). - - -> [!NOTE] -> This is **v2** of the MCP TypeScript SDK. It replaces the monolithic `@modelcontextprotocol/sdk` package from v1. See the **[migration guide](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/docs/migration/upgrade-to-v2.md)** if you're coming from v1. +> **This is v2 of the MCP TypeScript SDK, the stable release line**, implementing the [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). It replaces the single `@modelcontextprotocol/sdk` package from v1. Start with the [documentation](https://ts.sdk.modelcontextprotocol.io/v2/); coming from v1, see the [migration guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2). Found a problem? [Open an issue](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml). ## Install From fcef852f6cdafea07f36cf6fae69b37c8adf85be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:46:43 +0100 Subject: [PATCH 42/44] Version Packages (#2953) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/server-legacy-expected-resource.md | 5 ----- packages/client/CHANGELOG.md | 7 +++++++ packages/client/package.json | 2 +- packages/codemod/CHANGELOG.md | 2 ++ packages/codemod/package.json | 2 +- packages/core-internal/CHANGELOG.md | 7 +++++++ packages/core-internal/package.json | 2 +- packages/core/CHANGELOG.md | 2 ++ packages/core/package.json | 2 +- packages/server-legacy/CHANGELOG.md | 9 +++++++++ packages/server-legacy/package.json | 2 +- packages/server/CHANGELOG.md | 7 +++++++ packages/server/package.json | 2 +- 13 files changed, 40 insertions(+), 11 deletions(-) delete mode 100644 .changeset/server-legacy-expected-resource.md diff --git a/.changeset/server-legacy-expected-resource.md b/.changeset/server-legacy-expected-resource.md deleted file mode 100644 index a0e0e60f02..0000000000 --- a/.changeset/server-legacy-expected-resource.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/server-legacy': patch ---- - -`requireBearerAuth` in `@modelcontextprotocol/server-legacy` takes the optional `expectedResource` that `@modelcontextprotocol/server` 2.3.0 and `@modelcontextprotocol/sdk` 1.32.0 added: the resource the token must be issued for (its audience), usually the server's URL. When it is set, a token is accepted only if the verifier reports that value in `AuthInfo.resource`; the two are compared as strings, ignoring a fragment and one trailing slash. A token reported for another value, or for none, is answered `401 invalid_token` with the usual `WWW-Authenticate` challenge. When it is not set, nothing changes. The package stays frozen otherwise; this option is added so that its `requireBearerAuth` matches the 1.x middleware it is a copy of. diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 7cfc048172..60d81ece75 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/client +## 2.3.1 + +### Patch Changes + +- Updated dependencies []: + - @modelcontextprotocol/core@2.3.1 + ## 2.3.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index b9b60f11c1..29bdd4f8db 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/client", - "version": "2.3.0", + "version": "2.3.1", "description": "Model Context Protocol implementation for TypeScript - Client package", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/codemod/CHANGELOG.md b/packages/codemod/CHANGELOG.md index 07211bd43f..4b71100ccc 100644 --- a/packages/codemod/CHANGELOG.md +++ b/packages/codemod/CHANGELOG.md @@ -1,5 +1,7 @@ # @modelcontextprotocol/codemod +## 2.3.1 + ## 2.3.0 ### Patch Changes diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 7f5cee40c6..5e3ecac403 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/codemod", - "version": "2.3.0", + "version": "2.3.1", "description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core-internal/CHANGELOG.md b/packages/core-internal/CHANGELOG.md index 78a9f80338..9030725f05 100644 --- a/packages/core-internal/CHANGELOG.md +++ b/packages/core-internal/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/core-internal +## 2.0.4 + +### Patch Changes + +- Updated dependencies []: + - @modelcontextprotocol/core@2.3.1 + ## 2.0.3 ### Patch Changes diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 3afe12009a..86b79e20b9 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.3", + "version": "2.0.4", "description": "Model Context Protocol implementation for TypeScript - Core package", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 39e2b1d8c3..b17c3af0ef 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,7 @@ # @modelcontextprotocol/core +## 2.3.1 + ## 2.3.0 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index c5696165d1..697faa7890 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/core", - "version": "2.3.0", + "version": "2.3.1", "description": "Model Context Protocol for TypeScript — public Zod schemas (spec + OAuth/OpenID)", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/server-legacy/CHANGELOG.md b/packages/server-legacy/CHANGELOG.md index ae8a63bf41..c05a07c0bd 100644 --- a/packages/server-legacy/CHANGELOG.md +++ b/packages/server-legacy/CHANGELOG.md @@ -1,5 +1,14 @@ # @modelcontextprotocol/server-legacy +## 2.3.1 + +### Patch Changes + +- [#2952](https://github.com/modelcontextprotocol/typescript-sdk/pull/2952) [`5a18673`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5a1867390b91b8836f49af76c45427279f513325) Thanks [@claude](https://github.com/apps/claude)! - `requireBearerAuth` in `@modelcontextprotocol/server-legacy` takes the optional `expectedResource` that `@modelcontextprotocol/server` 2.3.0 and `@modelcontextprotocol/sdk` 1.32.0 added: the resource the token must be issued for (its audience), usually the server's URL. When it is set, a token is accepted only if the verifier reports that value in `AuthInfo.resource`; the two are compared as strings, ignoring a fragment and one trailing slash. A token reported for another value, or for none, is answered `401 invalid_token` with the usual `WWW-Authenticate` challenge. When it is not set, nothing changes. The package stays frozen otherwise; this option is added so that its `requireBearerAuth` matches the 1.x middleware it is a copy of. + +- Updated dependencies []: + - @modelcontextprotocol/core@2.3.1 + ## 2.3.0 ### Patch Changes diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index 554565368b..2ffcf4b619 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.3.0", + "version": "2.3.1", "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": "Apache-2.0", diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index c56874a091..69c222e817 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/server +## 2.3.1 + +### Patch Changes + +- Updated dependencies []: + - @modelcontextprotocol/core@2.3.1 + ## 2.3.0 ### Minor Changes diff --git a/packages/server/package.json b/packages/server/package.json index 806417873d..652683ed0a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server", - "version": "2.3.0", + "version": "2.3.1", "description": "Model Context Protocol implementation for TypeScript - Server package", "license": "Apache-2.0", "author": "Anthropic, PBC (https://anthropic.com)", From 324299b22ce5d4c95f6fc266ad2033c89e4363d6 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:19:05 +0100 Subject: [PATCH 43/44] ci: skip the Publish job when every version is already on npm (#2956) Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .github/workflows/release.yml | 9 ++++++- scripts/check-unpublished.mjs | 47 +++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 scripts/check-unpublished.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3682c55480..50bacb5c02 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,7 @@ jobs: pull-requests: write outputs: hasChangesets: ${{ steps.changesets.outputs.hasChangesets }} + allPublished: ${{ steps.registry.outputs.allPublished }} steps: - uses: actions/checkout@v7 @@ -34,6 +35,11 @@ jobs: - name: Install dependencies run: pnpm install + - name: Check npm for unpublished versions + id: registry + continue-on-error: true + run: node scripts/check-unpublished.mjs + - name: Create or update Version Packages PR id: changesets uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1 @@ -43,7 +49,8 @@ jobs: publish: name: Publish needs: version - if: needs.version.outputs.hasChangesets == 'false' + # Runs unless changesets are pending or the check proved every version is already on npm; a crashed check proves nothing. + if: needs.version.outputs.hasChangesets == 'false' && needs.version.outputs.allPublished != 'true' runs-on: ubuntu-latest environment: release permissions: diff --git a/scripts/check-unpublished.mjs b/scripts/check-unpublished.mjs new file mode 100644 index 0000000000..936f606ba0 --- /dev/null +++ b/scripts/check-unpublished.mjs @@ -0,0 +1,47 @@ +// Tells the Release workflow whether every public package version is already on npm, so Publish only runs when it has work. +import { appendFileSync, readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +const registry = 'https://registry.npmjs.org'; +const repo = path.resolve(import.meta.dirname, '..'); + +// Every package.json outside node_modules, not only the workspace globs, so a publishable package is not left out. +function packageJsonFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + if (entry.isDirectory()) { + return entry.name === 'node_modules' || entry.name === '.git' ? [] : packageJsonFiles(path.join(dir, entry.name)); + } + return entry.name === 'package.json' ? [path.join(dir, entry.name)] : []; + }); +} + +// Published means the registry returned the document for exactly this name and version; anything else is not proof. +async function isPublished({ name, version }) { + const response = await fetch(`${registry}/${name}/${version}`, { signal: AbortSignal.timeout(30_000) }); + const body = await response.text(); + const doc = response.status === 200 ? JSON.parse(body) : {}; + const published = doc.name === name && doc.version === version; + console.log(`${name}@${version}: ${published ? 'on npm' : `not confirmed on npm (HTTP ${response.status})`}`); + return published; +} + +// Any doubt (no packages found, a network error, an odd reply) counts as unpublished, so Publish is still reached. +let allPublished = false; +try { + const packages = packageJsonFiles(repo) + .map(file => JSON.parse(readFileSync(file, 'utf8'))) + .filter(pkg => !pkg.private && pkg.name && pkg.version); + let published = 0; + for (const pkg of packages) { + if (await isPublished(pkg)) published++; + } + allPublished = packages.length > 0 && published === packages.length; + console.log(`${published} of ${packages.length} public package versions are on npm`); +} catch (error) { + console.log(`Check failed: ${error.cause ?? error}`); +} + +console.log(`allPublished=${allPublished}`); +if (process.env.GITHUB_OUTPUT) { + appendFileSync(process.env.GITHUB_OUTPUT, `allPublished=${allPublished}\n`); +} From b022522089a0c8b632595c6e7b536453945ed5a9 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Mon, 5 Oct 2026 15:38:02 +0100 Subject: [PATCH 44/44] fix(core): reject x-mcp-header on number-typed parameters (#2762) Co-authored-by: Claude Co-authored-by: Felix Weinberger <3823880+felixweinberger@users.noreply.github.com> --- .changeset/x-mcp-header-reject-number.md | 7 +++++++ .../core-internal/src/shared/mcpParamHeaders.ts | 15 ++++++--------- .../test/shared/mcpParamHeaders.test.ts | 4 ++++ 3 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 .changeset/x-mcp-header-reject-number.md diff --git a/.changeset/x-mcp-header-reject-number.md b/.changeset/x-mcp-header-reject-number.md new file mode 100644 index 0000000000..99cb02e83c --- /dev/null +++ b/.changeset/x-mcp-header-reject-number.md @@ -0,0 +1,7 @@ +--- +'@modelcontextprotocol/core-internal': patch +'@modelcontextprotocol/client': patch +'@modelcontextprotocol/server': patch +--- + +`x-mcp-header` on a `number`-typed tool parameter is now rejected, matching the 2026-07-28 spec ("Parameters with type `number` are not permitted"; clients MUST exclude such tools from `tools/list`). Previously `number` was accepted only to satisfy an older conformance fixture that has since been corrected. `integer`, `string` and `boolean` are unaffected. diff --git a/packages/core-internal/src/shared/mcpParamHeaders.ts b/packages/core-internal/src/shared/mcpParamHeaders.ts index 493cf50aeb..5a49a92dd3 100644 --- a/packages/core-internal/src/shared/mcpParamHeaders.ts +++ b/packages/core-internal/src/shared/mcpParamHeaders.ts @@ -58,16 +58,13 @@ export type XMcpHeaderScanResult = { valid: true; declarations: readonly XMcpHea const RFC9110_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; /** - * JSON Schema `type` values the spec admits on an `x-mcp-header` property. - * - * The spec text names `integer`, `string`, `boolean` and explicitly excludes - * `number`. The published conformance referee at the pinned release ships its - * `http-custom-headers` scenario with two `type: "number"` `x-mcp-header` - * parameters and expects the client to mirror them, so `number` is accepted - * here so that the conformance gate passes; the discrepancy is tracked - * upstream. Everything else (`object`, `array`, `null`, absent) is rejected. + * JSON Schema `type` values the spec admits on an `x-mcp-header` property: + * `integer`, `string` and `boolean`. `number` is explicitly not permitted + * (2026-07-28 Streamable HTTP, "Schema Extension"), so a tool that annotates a + * `number`-typed property is rejected like any other invalid declaration. + * Everything else (`object`, `array`, `null`, absent) is rejected too. */ -const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set(['string', 'integer', 'boolean', 'number']); +const PERMITTED_X_MCP_HEADER_TYPES: ReadonlySet = new Set(['string', 'integer', 'boolean']); /** * Scan a tool's JSON-serialized `inputSchema` for `x-mcp-header` declarations diff --git a/packages/core-internal/test/shared/mcpParamHeaders.test.ts b/packages/core-internal/test/shared/mcpParamHeaders.test.ts index 13d8ea582f..fedad8d36f 100644 --- a/packages/core-internal/test/shared/mcpParamHeaders.test.ts +++ b/packages/core-internal/test/shared/mcpParamHeaders.test.ts @@ -144,6 +144,10 @@ describe('scanXMcpHeaderDeclarations — constraint table', () => { expect(invalid({ type: 'object', properties: { a: { type: 'array', [X_MCP_HEADER_KEY]: 'Items' } } })).toMatch(/primitive/); }); + test('number-typed property is rejected (only integer, string, boolean are permitted)', () => { + expect(invalid({ type: 'object', properties: { a: { type: 'number', [X_MCP_HEADER_KEY]: 'Score' } } })).toMatch(/primitive/); + }); + test('null-typed property is rejected', () => { expect(invalid({ type: 'object', properties: { a: { type: 'null', [X_MCP_HEADER_KEY]: 'Nil' } } })).toMatch(/primitive/); });