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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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 + ); + }); +});