Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/await-notification-send.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/client-cjs-types-inline-jose.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/codemod-preserve-file-header.md
Original file line number Diff line number Diff line change
@@ -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`, `/// <reference>` 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.
5 changes: 5 additions & 0 deletions .changeset/correct-token-endpoint-tls-citation.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/curvy-rivers-restore.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/dcr-deprecation-citation.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/input-required-meta-passthrough.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/listen-opening-unhandled-rejection.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 0 additions & 7 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 4 additions & 2 deletions docs/migration/upgrade-to-v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 16 additions & 6 deletions packages/client/src/client/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -2481,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,
Expand Down
10 changes: 6 additions & 4 deletions packages/client/src/client/authErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
33 changes: 23 additions & 10 deletions packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2106,18 +2106,31 @@ export class Client extends Protocol<ClientContext> {
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;
Expand Down
27 changes: 27 additions & 0 deletions packages/client/test/client/inputRequiredEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<void> {}

async send(message: JSONRPCMessage): Promise<void> {
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<void> {
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<void>(resolve => setTimeout(resolve, 0));
expect(unhandled).toEqual([]);
});
});
Loading
Loading