diff --git a/.changeset/brave-donkeys-listen.md b/.changeset/brave-donkeys-listen.md deleted file mode 100644 index c6b92902e1..0000000000 --- a/.changeset/brave-donkeys-listen.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@modelcontextprotocol/core-internal': patch -'@modelcontextprotocol/client': patch -'@modelcontextprotocol/server': patch ---- - -`SdkError` and `SdkHttpError` accept standard `ErrorOptions` as an optional fourth constructor argument and forward it to `Error`, so a wrapped error is reachable through the standard `Error.cause` chain. Version-negotiation probe failures (`SdkErrorCode.EraNegotiationFailed`) now use it: the underlying `TypeError: fetch failed` and the DNS or socket error beneath it surface via `error.cause`, so pino, Sentry, and `util.inspect` render `ENOTFOUND` / `ECONNREFUSED` / `ETIMEDOUT` instead of stopping at the `SdkError` (#2657). The previous `error.data.cause` slot is still populated for compatibility but is deprecated and slated for removal; read `error.cause` instead. diff --git a/.changeset/cancelled-request-id-zero.md b/.changeset/cancelled-request-id-zero.md deleted file mode 100644 index 13cc84332d..0000000000 --- a/.changeset/cancelled-request-id-zero.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@modelcontextprotocol/core-internal': patch -'@modelcontextprotocol/client': patch -'@modelcontextprotocol/server': patch ---- - -Treat request id `0` as a real id. Two guards tested a `RequestId` for truthiness, so the legal JSON-RPC ids `0` and `''` were read as absent. Id `0` is not a corner case: the outbound request counter is zero-based, so it is the first id every peer assigns, which on the server→client leg is the first `sampling/createMessage`, `elicitation/create`, or `roots/list` a server sends. - -- `notifications/cancelled` carrying id `0` was ignored, and the in-flight handler ran to completion with its `AbortSignal` never fired. -- A notification sent with `relatedRequestId: 0` wrongly passed the debounce gate (for methods opted into `debouncedNotificationMethods`). Because the pending set is keyed by method alone, a second such notification in the same tick was silently dropped rather than sent. - -Absent is now the only value that means "no id". diff --git a/.changeset/codemod-string-literal-imports.md b/.changeset/codemod-string-literal-imports.md deleted file mode 100644 index 9d96bcded8..0000000000 --- a/.changeset/codemod-string-literal-imports.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/codemod': patch ---- - -Project-type inference no longer counts bare SDK paths that appear only in ordinary string data. The v1→v2 codemod's source scanner matched any quoted `@modelcontextprotocol/sdk/client|server` subpath anywhere in a file, so a server path stored as data (example text, a log message, a config value) misclassified a client-only project as `both` — rewriting shared type imports to `@modelcontextprotocol/server` and adding a server dependency the project never uses. The scanner now requires a module-specifier position: static imports and re-exports (`from '...'`), side-effect imports, dynamic `import('...')` (including webpack magic comments), `require('...')` / `require.resolve('...')`, and the `vi.`/`jest.` mock-method calls the mock-paths transform rewrites. Known limitation: the scan is lexical, so a string whose text embeds a complete import statement still counts. diff --git a/.changeset/config.json b/.changeset/config.json index 962a802171..7709182e45 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -23,5 +23,8 @@ "ignore": [ "@modelcontextprotocol/examples", "@mcp-examples/*" - ] + ], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/dpop-client-tokens.md b/.changeset/dpop-client-tokens.md deleted file mode 100644 index 86477bbc42..0000000000 --- a/.changeset/dpop-client-tokens.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@modelcontextprotocol/client': minor -'@modelcontextprotocol/core': minor ---- - -Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client. - -- Opt in by implementing `OAuthClientProvider.dpop()` returning a `DpopSession` (new, along with `generateDpopKeyPair`, `accessTokenHash`, `isDpopNonceChallenge`). `auth()` / `exchangeAuthorization` / `refreshAuthorization` / `fetchToken` then sign a DPoP proof into token requests (retrying once on an authorization-server `use_dpop_nonce` challenge, with client authentication re-applied per attempt), and `StreamableHTTPClientTransport`, `SSEClientTransport` and `withOAuth` present a `token_type: "DPoP"` access token as `Authorization: DPoP ` plus a fresh per-request proof, retry a resource-server `use_dpop_nonce` challenge once, and pick up a `DPoP-Nonce` delivered on any response. Tokens the AS issued as `Bearer` are still presented as Bearer. -- DPoP is applied at the fetch layer: the transports wrap their resource-server `fetch` (including a caller-supplied `fetch` / `eventSourceInit.fetch`) with the new `withDpopFromProvider(provider)` middleware, so proofs are always bound to the request actually sent. `withDpop(session, getToken)` is exported for callers that manage tokens themselves (e.g. alongside a minimal `AuthProvider`); the `AuthProvider` interface itself is unchanged. -- `auth()` now recovers from `invalid_dpop_proof` on refresh (e.g. a refresh token bound to a key that is no longer held) by discarding the tokens and re-authorizing, like `invalid_grant`. `OAuthErrorCode` gains `InvalidDpopProof` and `UseDpopNonce`; `extractWWWAuthenticateParams` recognizes the `DPoP` challenge scheme; `OAuthMetadataSchema` gains `dpop_signing_alg_values_supported`. diff --git a/.changeset/loopback-localhost-subdomains.md b/.changeset/loopback-localhost-subdomains.md new file mode 100644 index 0000000000..938ec481fd --- /dev/null +++ b/.changeset/loopback-localhost-subdomains.md @@ -0,0 +1,5 @@ +--- +'@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/.changeset/no-cancel-notification-for-initialize.md b/.changeset/no-cancel-notification-for-initialize.md deleted file mode 100644 index b7acd086d3..0000000000 --- a/.changeset/no-cancel-notification-for-initialize.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@modelcontextprotocol/core-internal': patch -'@modelcontextprotocol/client': patch -'@modelcontextprotocol/server': patch ---- - -Stop sending `notifications/cancelled` for the `initialize` handshake. The spec is explicit that a client MUST NOT attempt to cancel its `initialize` request, but the outbound cancel path fired for any in-flight request: aborting the `AbortSignal` passed to `connect()`, or letting the handshake hit its timeout, put a forbidden cancellation on the wire naming the initialize request id. - -The local behaviour is unchanged — the caller's promise still rejects with the same abort/timeout error, and `connect()` still tears the connection down. Only the wire notification is suppressed. Every other method keeps the existing cancellation path. diff --git a/.changeset/oauth-header-spread-order.md b/.changeset/oauth-header-spread-order.md deleted file mode 100644 index 45b58775b0..0000000000 --- a/.changeset/oauth-header-spread-order.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -`StreamableHTTPClientTransport` and `SSEClientTransport` now give their transport-managed headers precedence over same-named entries in `requestInit.headers`: `Authorization` when `authProvider` yields a token, `mcp-protocol-version`, and (Streamable HTTP) `mcp-session-id`. Header names compare case-insensitively and every `HeadersInit` form is covered (plain object, tuple array, `Headers` instance). Previously the caller-supplied value won, so a static `Authorization` placeholder (e.g. an env-var API key) kept overriding the OAuth token even after the provider obtained one and the fallback-to-OAuth flow never completed; a `Headers` instance or lowercase key produced a combined `Bearer , Bearer ` value instead. A configured `Authorization` is still sent while the provider has no token, and other configured headers pass through unchanged. Closes #2208. diff --git a/.changeset/plenty-plums-sip.md b/.changeset/plenty-plums-sip.md deleted file mode 100644 index 334cf24a33..0000000000 --- a/.changeset/plenty-plums-sip.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Preserve the exact OAuth resource indicator from protected resource metadata when building authorization and token requests. Previously a pathless `resource` such as `https://example.com` was normalized to `https://example.com/` via `URL.href`, which breaks authorization servers that require the `resource` parameter to match the published value exactly (Microsoft Entra ID rejects it with `AADSTS9010010`). The exported OAuth helpers (`startAuthorization`, `exchangeAuthorization`, `refreshAuthorization`, `fetchToken`, `executeTokenRequest`) now also accept a `string` for `resource`; `selectResourceURL` still returns a `URL`, and a provider's `validateResourceURL` result is used unchanged. Fixes #1968. diff --git a/.changeset/propagate-save-tokens-errors-after-refresh.md b/.changeset/propagate-save-tokens-errors-after-refresh.md deleted file mode 100644 index 6b06223d84..0000000000 --- a/.changeset/propagate-save-tokens-errors-after-refresh.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@modelcontextprotocol/client': patch ---- - -Let `saveTokens` failures surface after a successful token refresh. In `auth()`, one `try` -wrapped both `refreshAuthorization()` and the `provider.saveTokens()` that persists its -result, and the `catch` deliberately swallows anything that is not an `OAuthError` — plus -`ServerError` — so that a failed refresh falls through to a fresh authorization request. -A persistence error thrown by the provider landed in that same branch: it was discarded -with no log and no rethrow, and `auth()` continued to `startAuthorization()` and returned -`'REDIRECT'`. - -Against an authorization server that rotates refresh tokens (the OAuth 2.1 default, and -Keycloak's) this loses credentials rather than merely hiding an error. The exchange has -already succeeded server-side, so the old refresh token is invalidated at the moment the -new one is issued; dropping the new token set leaves nothing usable on either side. On a -headless or CLI client, where `redirectToAuthorization` is typically a no-op, the fallthrough -is silent and the client is left with stale tokens and no indication of why. - -The `try`/`catch` now covers only `refreshAuthorization()`. Persisting the result happens -after it, on an unguarded path, so a provider's I/O error propagates to the caller. - -Refresh-request failures keep their existing control flow exactly: a `ServerError` or an -unknown error still falls through to a new authorization flow, a non-`ServerError` -`OAuthError` is still rethrown, and `InsecureTokenEndpointError` is still surfaced. The -SEP-2352 `issuer` stamp written with the refreshed tokens is unchanged. - -Those fallbacks no longer happen in silence, though. Both routes to an unexplained -re-authorization now emit a `console.warn` naming the cause: the in-place fallthrough in -the refresh block, and `auth()`'s outer recovery for `invalid_grant`, `invalid_client`, -and `unauthorized_client`, which discards stored credentials and retries. The second one -matters most in practice — an expired, revoked, or rotation-reuse-detected refresh token -is reported as `invalid_grant`, which is precisely the state a dropped token set leaves -behind for the next call. - -Consumers whose `OAuthClientProvider.saveTokens` can reject should note that `auth()` may -now reject where it previously returned `'REDIRECT'` — that rejection is the failure that -was being discarded. diff --git a/.changeset/request-body-size-limit.md b/.changeset/request-body-size-limit.md deleted file mode 100644 index 0057ebbf4e..0000000000 --- a/.changeset/request-body-size-limit.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@modelcontextprotocol/server': patch -'@modelcontextprotocol/node': patch -'@modelcontextprotocol/hono': patch -'@modelcontextprotocol/express': patch ---- - -Read Streamable HTTP request bodies with a size limit. Every SDK-owned body read — -`WebStandardStreamableHTTPServerTransport` (and the Node transport built on it), -`createMcpHandler`, `toNodeHandler`, and `createMcpHonoApp`'s JSON pre-parse — now stops at -4 MiB by default (the limit the legacy SSE transport already uses; the Express adapter and stdio -bound their reads too) and answers `413 Payload Too Large` before anything is parsed. -`toWebRequest` (when it reads the Node stream itself) now rejects once the body exceeds the -limit with an error whose `name` is `'RequestBodyTooLargeError'` and `status` is `413`, and -`toNodeHandler` answers that with `413`; hand-wired callers of `toWebRequest` should handle the -rejection or pass a pre-parsed body, and `isLegacyRequest` reports such a request as non-legacy -so the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer -batch is answered `400` / `-32600` and none of it is dispatched. - -The limit is configurable with a new `maxRequestBodySize` option (bytes, default -`DEFAULT_MAX_REQUEST_BODY_SIZE` = 4 MiB, exported from `@modelcontextprotocol/server`) on -`WebStandardStreamableHTTPServerTransportOptions`, `CreateMcpHandlerOptions` (forwarded to its -stateless legacy leg; `isLegacyRequest` and `legacyStatelessFallback` take the same option), -`CreateMcpHonoAppOptions`, and `ToNodeHandlerOptions` / `ToWebRequestOptions` (the adapter's -bound applies before the handler's, so raise both). The bounded reader is exported as -`readRequestBody` for adapter authors. Hosts that pre-parse the body and pass it as -`parsedBody` skip the SDK's read and its size limit entirely; the batch bound applies either way. - -`createMcpHonoApp` and `createMcpExpressApp` now run their Host/Origin validation before the -JSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is -answered `403` rather than `400`, and its body is not read. diff --git a/.changeset/require-protocol-version-header-on-modern-post.md b/.changeset/require-protocol-version-header-on-modern-post.md deleted file mode 100644 index a120853db7..0000000000 --- a/.changeset/require-protocol-version-header-on-modern-post.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@modelcontextprotocol/core-internal': patch -'@modelcontextprotocol/server': patch ---- - -Reject a modern (2026-07-28) POST that omits the required `MCP-Protocol-Version` header. - -`createMcpHandler` accepted a request whose body carried a valid per-request `_meta` -envelope but whose `MCP-Protocol-Version` header was absent: the request was classified -modern, dispatched, and answered `200` — tool handlers ran. Only the _mismatch_ case -(header present, disagreeing with the body) was rejected, so of the standard headers -SEP-2243 requires on a modern POST, presence was enforced for `Mcp-Method` (and for -`Mcp-Name` on the methods that mirror `params.name` / `params.uri`) but not for -`MCP-Protocol-Version`. - -Such a request is now refused with `400 Bad Request` and JSON-RPC `-32020` -(`HeaderMismatch`), matching the shape the sibling missing-header cells already emit and -echoing the request id — per the Streamable HTTP spec, which requires the header on every -POST and lists a missing required standard header as a `HeaderMismatch` failure. The -spec's allowance to treat a header-less request as `2025-03-26` is available only to a -server that also serves pre-2025-06-18 clients, and permits routing it to _legacy_ -handling — never serving it as 2026-07-28; under `legacy: 'reject'` the requirement is -unconditional. - -Era classification is deliberately unchanged and stays body-primary: a proxy that strips -the header still must not change the era, so such a request is still _classified_ modern -and is refused one rung later, at `standard-header-validation` — the same rung that -already answers a missing `Mcp-Method`. Legacy-era traffic is untouched, notifications -are unaffected, body-less `GET` / `DELETE` session operations are method-routed before -any header validation, and stdio serving (which has no HTTP headers) is not involved. - -Clients built with this SDK always send the header, so no first-party client is affected; -hand-rolled clients that omitted it must add it. diff --git a/.changeset/scope-challenge-server.md b/.changeset/scope-challenge-server.md deleted file mode 100644 index 567e37f804..0000000000 --- a/.changeset/scope-challenge-server.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@modelcontextprotocol/server': minor -'@modelcontextprotocol/node': minor ---- - -Add request-time OAuth scope challenges for tools, resources, resource templates, -and prompts. Each primitive's `scopeChallenge` callback receives the parsed -request and verified authentication info, then either continues or returns the -exact scope set for an `insufficient_scope` response. `requireScopes` provides a -small helper for static all-of checks. - -`createMcpHandler` and Streamable HTTP transports return HTTP 403 with an -`insufficient_scope` challenge before handler execution or SSE setup. The -preflight is active whenever a registered primitive carries a `scopeChallenge` -callback — there is no handler- or transport-level configuration. The -challenge's `WWW-Authenticate` header is built by the same formatter as the -bearer-auth 401/403 answers, and its `resource_metadata` parameter is derived -from the verified `AuthInfo`: `requireBearerAuth` / `verifyBearerToken` now -stamp their configured `resourceMetadataUrl` onto the `AuthInfo` they return -(new optional `AuthInfo.resourceMetadataUrl` field), with a fallback to the -well-known location for an HTTP(S) RFC 8707 `resource` identifier; the -parameter is omitted when neither is available. diff --git a/.changeset/tasks-mcp-name-header.md b/.changeset/tasks-mcp-name-header.md deleted file mode 100644 index 7313338839..0000000000 --- a/.changeset/tasks-mcp-name-header.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@modelcontextprotocol/core-internal': patch -'@modelcontextprotocol/client': patch -'@modelcontextprotocol/server': patch ---- - -Emit and validate the `Mcp-Name` header for tasks requests per SEP-2663's Streamable HTTP binding: the client transport now mirrors `params.taskId` into `Mcp-Name` on `tasks/get` / `tasks/update` / `tasks/cancel` (previously omitted, causing conforming servers to reject every task poll with `-32020 HeaderMismatch`), and the server-side standard-header validation cross-checks it via the same shared `MCP_NAME_HEADER_SOURCE` table. - -On the server, `createMcpHandler` now answers a modern (2026-07-28) `tasks/get` / `tasks/update` / `tasks/cancel` POST that omits `Mcp-Name`, or whose header disagrees with `params.taskId`, with `400` / `-32020` (`HeaderMismatch`) at the `standard-header-validation` rung, the same treatment `tools/call` / `prompts/get` / `resources/read` already get. Legacy-era (2025-11-25) tasks traffic is unaffected. Clients built with this SDK release send the header; hand-rolled clients that omitted it must add it. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 3353fa5cb7..fa8f2f2175 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1241,7 +1241,7 @@ rejection now throws `RegistrationRejectedError` (carrying `status`, `body`, `exchangeAuthorization()`, `refreshAuthorization()`, `fetchToken()`, and the Cross-App Access helpers throw `InsecureTokenEndpointError` when the token endpoint is not -`https:` (loopback `localhost` / `127.0.0.1` / `::1` exempt). `auth()` surfaces this on +`https:` (loopback `localhost` / `*.localhost` / `127.0.0.1` / `::1` exempt). `auth()` surfaces this on every path including refresh — switch any plain-`http:` AS on a non-loopback host to TLS; there is no opt-out. Storage confidentiality of `refresh_token` remains your `saveTokens()` implementation's responsibility. diff --git a/docs/serving/stdio.md b/docs/serving/stdio.md index 2855626eca..bc328fffdb 100644 --- a/docs/serving/stdio.md +++ b/docs/serving/stdio.md @@ -70,6 +70,23 @@ process.on('SIGINT', () => { `close()` resolves once the instance the factory built and the underlying transport are both shut down. +A signal handler is one path to teardown; the pipe itself is the other. When the client closes its end (stdin reaches end-of-file), the transport closes itself and the connection tears down automatically — no signal handler needed. Requests still in flight at that moment are aborted and never answered (EOF means the client is gone), so a client that wants answers keeps stdin open until it has read them. A server that holds nothing else keeping the event loop alive then exits on its own. If yours does hold a keep-alive handle — a timer, a connection pool, a file watcher — release it when the connection closes so the process can exit: + +```ts source="../../examples/guides/serving/stdio.examples.ts#serveStdio_releaseKeepAlive" +serveStdio(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + + // A handle that keeps the event loop alive: a heartbeat timer, a + // connection pool, a file watcher, ... + const heartbeat = setInterval(() => console.error('notes server: still serving'), 60_000); + + // Release it when the connection closes, so the process can exit. + server.server.onclose = () => clearInterval(heartbeat); + + return server; +}); +``` + ## Recap - `serveStdio(factory)` is the stdio entry point: it owns the transport and calls your factory to build the instance that serves the connection. @@ -77,3 +94,4 @@ process.on('SIGINT', () => { - One `console.log` puts a line no JSON-RPC parser accepts into the stream the host parses. - `npx @modelcontextprotocol/inspector ` exercises a stdio server without configuring it in a host. - The returned `StdioServerHandle`'s `close()` tears down the pinned instance and the transport. +- When the client closes the pipe (stdin EOF) the connection tears down by itself; release your own keep-alive handles on close so the process can exit. diff --git a/examples/guides/serving/stdio.examples.ts b/examples/guides/serving/stdio.examples.ts index 18a445c5a5..4ff54781e8 100644 --- a/examples/guides/serving/stdio.examples.ts +++ b/examples/guides/serving/stdio.examples.ts @@ -3,7 +3,8 @@ * * Each `//#region` block is synced byte-for-byte into that page's `ts` fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The regions - * are one linear stdio server program. The file runs in two modes: + * are one linear stdio server program, plus one typechecked-but-not-run variant + * (the keep-alive release pattern). The file runs in two modes: * * - `node --import tsx stdio.examples.ts --serve` — be that stdio server, plus * the one deliberate `console.log` the page's gotcha section describes, and @@ -39,6 +40,26 @@ process.on('SIGINT', () => { }); //#endregion serveStdio_shutdown +// "Shut down cleanly" — the keep-alive release pattern: typechecked, not run +// (running it here would start a second stdio transport on this process's +// streams alongside the server above). +export function notesServerWithKeepAlive(): void { + //#region serveStdio_releaseKeepAlive + serveStdio(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + + // A handle that keeps the event loop alive: a heartbeat timer, a + // connection pool, a file watcher, ... + const heartbeat = setInterval(() => console.error('notes server: still serving'), 60_000); + + // Release it when the connection closes, so the process can exit. + server.server.onclose = () => clearInterval(heartbeat); + + return server; + }); + //#endregion serveStdio_releaseKeepAlive +} + // --------------------------------------------------------------------------- // Harness (not shown on the page). // diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 5f3dea3b2d..6c39696494 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,76 @@ # @modelcontextprotocol/client +## 2.1.0 + +### Minor Changes + +- [#2629](https://github.com/modelcontextprotocol/typescript-sdk/pull/2629) [`dcc0102`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcc01028ff6a499a5728c2b6181c1727d52e2fab) Thanks [@gbshankar](https://github.com/gbshankar)! - Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client. + - Opt in by implementing `OAuthClientProvider.dpop()` returning a `DpopSession` (new, along with `generateDpopKeyPair`, `accessTokenHash`, `isDpopNonceChallenge`). `auth()` / `exchangeAuthorization` / `refreshAuthorization` / `fetchToken` then sign a DPoP proof into token requests (retrying once on an authorization-server `use_dpop_nonce` challenge, with client authentication re-applied per attempt), and `StreamableHTTPClientTransport`, `SSEClientTransport` and `withOAuth` present a `token_type: "DPoP"` access token as `Authorization: DPoP ` plus a fresh per-request proof, retry a resource-server `use_dpop_nonce` challenge once, and pick up a `DPoP-Nonce` delivered on any response. Tokens the AS issued as `Bearer` are still presented as Bearer. + - DPoP is applied at the fetch layer: the transports wrap their resource-server `fetch` (including a caller-supplied `fetch` / `eventSourceInit.fetch`) with the new `withDpopFromProvider(provider)` middleware, so proofs are always bound to the request actually sent. `withDpop(session, getToken)` is exported for callers that manage tokens themselves (e.g. alongside a minimal `AuthProvider`); the `AuthProvider` interface itself is unchanged. + - `auth()` now recovers from `invalid_dpop_proof` on refresh (e.g. a refresh token bound to a key that is no longer held) by discarding the tokens and re-authorizing, like `invalid_grant`. `OAuthErrorCode` gains `InvalidDpopProof` and `UseDpopNonce`; `extractWWWAuthenticateParams` recognizes the `DPoP` challenge scheme; `OAuthMetadataSchema` gains `dpop_signing_alg_values_supported`. + +### Patch Changes + +- [#2726](https://github.com/modelcontextprotocol/typescript-sdk/pull/2726) [`6fa4227`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6fa42279fecaba423635072f716bbb2f6f7c77f3) Thanks [@LuckTerence](https://github.com/LuckTerence)! - `SdkError` and `SdkHttpError` accept standard `ErrorOptions` as an optional fourth constructor argument and forward it to `Error`, so a wrapped error is reachable through the standard `Error.cause` chain. Version-negotiation probe failures (`SdkErrorCode.EraNegotiationFailed`) now use it: the underlying `TypeError: fetch failed` and the DNS or socket error beneath it surface via `error.cause`, so pino, Sentry, and `util.inspect` render `ENOTFOUND` / `ECONNREFUSED` / `ETIMEDOUT` instead of stopping at the `SdkError` (#2657). The previous `error.data.cause` slot is still populated for compatibility but is deprecated and slated for removal; read `error.cause` instead. + +- [#2654](https://github.com/modelcontextprotocol/typescript-sdk/pull/2654) [`03842cd`](https://github.com/modelcontextprotocol/typescript-sdk/commit/03842cd9cae9a9b142c77d2fb65e829fc4e03eab) Thanks [@pshah19](https://github.com/pshah19)! - Treat request id `0` as a real id. Two guards tested a `RequestId` for truthiness, so the legal JSON-RPC ids `0` and `''` were read as absent. Id `0` is not a corner case: the outbound request counter is zero-based, so it is the first id every peer assigns, which on the server→client leg is the first `sampling/createMessage`, `elicitation/create`, or `roots/list` a server sends. + - `notifications/cancelled` carrying id `0` was ignored, and the in-flight handler ran to completion with its `AbortSignal` never fired. + - A notification sent with `relatedRequestId: 0` wrongly passed the debounce gate (for methods opted into `debouncedNotificationMethods`). Because the pending set is keyed by method alone, a second such notification in the same tick was silently dropped rather than sent. + + Absent is now the only value that means "no id". + +- [#2043](https://github.com/modelcontextprotocol/typescript-sdk/pull/2043) [`c4248a9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/c4248a935f245a86b2fb8b88b9c3d594e2bfea75) Thanks [@ChrisJr404](https://github.com/ChrisJr404)! - On Windows, stdio servers spawned by `StdioClientTransport` now also inherit `COMSPEC`, `PATHEXT`, `PROGRAMDATA`, `PROGRAMFILES(X86)`, `PROGRAMW6432`, and `WINDIR` (added to `DEFAULT_INHERITED_ENV_VARS`). Programs a server launches can depend on them: PowerShell finds no native executables without `PATHEXT`, and Windows OpenSSH exits 255 without `ProgramData`. + +- [#2668](https://github.com/modelcontextprotocol/typescript-sdk/pull/2668) [`3e90449`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3e90449fd52997da43b79a536d2c19c446603cc7) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - Stop sending `notifications/cancelled` for the `initialize` handshake. The spec is explicit that a client MUST NOT attempt to cancel its `initialize` request, but the outbound cancel path fired for any in-flight request: aborting the `AbortSignal` passed to `connect()`, or letting the handshake hit its timeout, put a forbidden cancellation on the wire naming the initialize request id. + + The local behaviour is unchanged — the caller's promise still rejects with the same abort/timeout error, and `connect()` still tears the connection down. Only the wire notification is suppressed. Every other method keeps the existing cancellation path. + +- [#2475](https://github.com/modelcontextprotocol/typescript-sdk/pull/2475) [`b654261`](https://github.com/modelcontextprotocol/typescript-sdk/commit/b65426158ed9f29aea8ef3dc09ca22d7d9d6f970) Thanks [@sanjibani](https://github.com/sanjibani)! - `StreamableHTTPClientTransport` and `SSEClientTransport` now give their transport-managed headers precedence over same-named entries in `requestInit.headers`: `Authorization` when `authProvider` yields a token, `mcp-protocol-version`, and (Streamable HTTP) `mcp-session-id`. Header names compare case-insensitively and every `HeadersInit` form is covered (plain object, tuple array, `Headers` instance). Previously the caller-supplied value won, so a static `Authorization` placeholder (e.g. an env-var API key) kept overriding the OAuth token even after the provider obtained one and the fallback-to-OAuth flow never completed; a `Headers` instance or lowercase key produced a combined `Bearer , Bearer ` value instead. A configured `Authorization` is still sent while the provider has no token, and other configured headers pass through unchanged. Closes #2208. + +- [#2581](https://github.com/modelcontextprotocol/typescript-sdk/pull/2581) [`5119ee7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5119ee7fd7790e335a3fb60ef36f85334e2a6326) Thanks [@hugosmoreira](https://github.com/hugosmoreira)! - Preserve the exact OAuth resource indicator from protected resource metadata when building authorization and token requests. Previously a pathless `resource` such as `https://example.com` was normalized to `https://example.com/` via `URL.href`, which breaks authorization servers that require the `resource` parameter to match the published value exactly (Microsoft Entra ID rejects it with `AADSTS9010010`). The exported OAuth helpers (`startAuthorization`, `exchangeAuthorization`, `refreshAuthorization`, `fetchToken`, `executeTokenRequest`) now also accept a `string` for `resource`; `selectResourceURL` still returns a `URL`, and a provider's `validateResourceURL` result is used unchanged. Fixes #1968. + +- [`3924de9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3924de99df834302d89f5997a1b64ca268282284) - Let `saveTokens` failures surface after a successful token refresh. In `auth()`, one `try` + wrapped both `refreshAuthorization()` and the `provider.saveTokens()` that persists its + result, and the `catch` deliberately swallows anything that is not an `OAuthError` — plus + `ServerError` — so that a failed refresh falls through to a fresh authorization request. + A persistence error thrown by the provider landed in that same branch: it was discarded + with no log and no rethrow, and `auth()` continued to `startAuthorization()` and returned + `'REDIRECT'`. + + Against an authorization server that rotates refresh tokens (the OAuth 2.1 default, and + Keycloak's) this loses credentials rather than merely hiding an error. The exchange has + already succeeded server-side, so the old refresh token is invalidated at the moment the + new one is issued; dropping the new token set leaves nothing usable on either side. On a + headless or CLI client, where `redirectToAuthorization` is typically a no-op, the fallthrough + is silent and the client is left with stale tokens and no indication of why. + + The `try`/`catch` now covers only `refreshAuthorization()`. Persisting the result happens + after it, on an unguarded path, so a provider's I/O error propagates to the caller. + + Refresh-request failures keep their existing control flow exactly: a `ServerError` or an + unknown error still falls through to a new authorization flow, a non-`ServerError` + `OAuthError` is still rethrown, and `InsecureTokenEndpointError` is still surfaced. The + SEP-2352 `issuer` stamp written with the refreshed tokens is unchanged. + + Those fallbacks no longer happen in silence, though. Both routes to an unexplained + re-authorization now emit a `console.warn` naming the cause: the in-place fallthrough in + the refresh block, and `auth()`'s outer recovery for `invalid_grant`, `invalid_client`, + and `unauthorized_client`, which discards stored credentials and retries. The second one + matters most in practice — an expired, revoked, or rotation-reuse-detected refresh token + is reported as `invalid_grant`, which is precisely the state a dropped token set leaves + behind for the next call. + + Consumers whose `OAuthClientProvider.saveTokens` can reject should note that `auth()` may + now reject where it previously returned `'REDIRECT'` — that rejection is the failure that + was being discarded. + +- [#2613](https://github.com/modelcontextprotocol/typescript-sdk/pull/2613) [`70de0c8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/70de0c8b569b0d664a56b90be2f141d1d1645880) Thanks [@jwcarman](https://github.com/jwcarman)! - Emit and validate the `Mcp-Name` header for tasks requests per SEP-2663's Streamable HTTP binding: the client transport now mirrors `params.taskId` into `Mcp-Name` on `tasks/get` / `tasks/update` / `tasks/cancel` (previously omitted, causing conforming servers to reject every task poll with `-32020 HeaderMismatch`), and the server-side standard-header validation cross-checks it via the same shared `MCP_NAME_HEADER_SOURCE` table. + + On the server, `createMcpHandler` now answers a modern (2026-07-28) `tasks/get` / `tasks/update` / `tasks/cancel` POST that omits `Mcp-Name`, or whose header disagrees with `params.taskId`, with `400` / `-32020` (`HeaderMismatch`) at the `standard-header-validation` rung, the same treatment `tools/call` / `prompts/get` / `resources/read` already get. Legacy-era (2025-11-25) tasks traffic is unaffected. Clients built with this SDK release send the header; hand-rolled clients that omitted it must add it. + +- Updated dependencies [[`dcc0102`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcc01028ff6a499a5728c2b6181c1727d52e2fab)]: + - @modelcontextprotocol/core@2.1.0 + ## 2.0.0 ### Minor Changes diff --git a/packages/client/package.json b/packages/client/package.json index 3ebfde64d3..997e9bcfff 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/client", - "version": "2.0.0", + "version": "2.1.0", "description": "Model Context Protocol implementation for TypeScript - Client package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/client/src/client/auth.ts b/packages/client/src/client/auth.ts index 4c37339297..6426d8dcb2 100644 --- a/packages/client/src/client/auth.ts +++ b/packages/client/src/client/auth.ts @@ -860,9 +860,18 @@ export function applyPublicAuth(clientId: string, params: URLSearchParams): void params.set('client_id', clientId); } -/** Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). */ +/** + * Loopback hosts exempt from the in-transit `https:` requirement (RFC 8252 §7.3). + * Includes bare `localhost` and any name ending in `.localhost` (RFC 6761 §6.3). + */ function isLoopbackHost(hostname: string): boolean { - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1'; + return ( + hostname === 'localhost' || + hostname.endsWith('.localhost') || + hostname === '127.0.0.1' || + hostname === '[::1]' || + hostname === '::1' + ); } /** diff --git a/packages/client/src/client/authErrors.ts b/packages/client/src/client/authErrors.ts index e8925b2f86..5afa88413d 100644 --- a/packages/client/src/client/authErrors.ts +++ b/packages/client/src/client/authErrors.ts @@ -148,7 +148,7 @@ export class InsecureTokenEndpointError extends OAuthClientFlowError { constructor(tokenEndpoint: string) { super( `Refusing to send credentials to non-https token endpoint '${tokenEndpoint}'. ` + - `OAuth token requests MUST use TLS (localhost / 127.0.0.1 / ::1 are exempt).` + `OAuth token requests MUST use TLS (localhost / *.localhost / 127.0.0.1 / ::1 are exempt).` ); this.tokenEndpoint = tokenEndpoint; } diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index a4664e1c93..6dbddb667c 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -55,17 +55,23 @@ export const DEFAULT_INHERITED_ENV_VARS = process.platform === 'win32' ? [ 'APPDATA', + 'COMSPEC', 'HOMEDRIVE', 'HOMEPATH', 'LOCALAPPDATA', 'PATH', + 'PATHEXT', 'PROCESSOR_ARCHITECTURE', + 'PROGRAMDATA', + 'PROGRAMFILES', + 'PROGRAMFILES(X86)', + 'PROGRAMW6432', 'SYSTEMDRIVE', 'SYSTEMROOT', 'TEMP', 'USERNAME', 'USERPROFILE', - 'PROGRAMFILES' + 'WINDIR' ] : /* list inspired by the default env inheritance of sudo */ ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; diff --git a/packages/client/test/client/auth.test.ts b/packages/client/test/client/auth.test.ts index 3ac9c7ddff..e57ab4e91c 100644 --- a/packages/client/test/client/auth.test.ts +++ b/packages/client/test/client/auth.test.ts @@ -2476,6 +2476,8 @@ describe('OAuth Authorization', () => { it('assertSecureTokenEndpoint: throws on non-loopback http, returns URL for loopback', () => { expect(() => assertSecureTokenEndpoint('http://10.0.0.5/token')).toThrow(InsecureTokenEndpointError); expect(assertSecureTokenEndpoint('http://127.0.0.1:3000/token')).toBeInstanceOf(URL); + expect(assertSecureTokenEndpoint('http://tenant.example.localhost:3300/token')).toBeInstanceOf(URL); + expect(assertSecureTokenEndpoint('http://localhost/token')).toBeInstanceOf(URL); }); it('rejects a non-https token_endpoint before sending credentials', async () => { @@ -2544,24 +2546,27 @@ describe('OAuth Authorization', () => { expect(mockFetch.mock.calls.some(c => c[0].toString().includes('/token'))).toBe(false); }); - it.each(['http://localhost:9001/token', 'http://127.0.0.1:9001/token', 'http://[::1]:9001/token'])( - 'permits loopback host %s', - async tokenEndpoint => { - mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' })); - await expect( - refreshAuthorization('http://localhost:9001', { - metadata: { - issuer: 'http://localhost:9001', - authorization_endpoint: 'http://localhost:9001/authorize', - token_endpoint: tokenEndpoint, - response_types_supported: ['code'] - }, - clientInformation, - refreshToken: 'rt' - }) - ).resolves.toBeDefined(); - } - ); + it.each([ + 'http://localhost:9001/token', + 'http://127.0.0.1:9001/token', + 'http://[::1]:9001/token', + 'http://tenant.example.localhost:3300/token', + 'http://app.localhost:9001/token' + ])('permits loopback host %s', async tokenEndpoint => { + mockFetch.mockResolvedValueOnce(Response.json({ access_token: 't', token_type: 'Bearer' })); + await expect( + refreshAuthorization('http://localhost:9001', { + metadata: { + issuer: 'http://localhost:9001', + authorization_endpoint: 'http://localhost:9001/authorize', + token_endpoint: tokenEndpoint, + response_types_supported: ['code'] + }, + clientInformation, + refreshToken: 'rt' + }) + ).resolves.toBeDefined(); + }); }); // SEP-2207 verify-only: behaviors already correct at the v2 baseline, @@ -4647,6 +4652,7 @@ describe('OAuth Authorization', () => { describe('SEP-837: application_type heuristic default', () => { it.each([ ['http://localhost:3000/callback', 'native'], + ['http://tenant.example.localhost:3300/callback', 'native'], ['http://127.0.0.1:8080/cb', 'native'], ['http://[::1]:8080/cb', 'native'], ['myapp://oauth/callback', 'native'], diff --git a/packages/client/test/client/stdio.test.ts b/packages/client/test/client/stdio.test.ts index 315b8a2595..fe6d9258ce 100644 --- a/packages/client/test/client/stdio.test.ts +++ b/packages/client/test/client/stdio.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; import type { StdioServerParameters } from '../../src/client/stdio'; -import { StdioClientTransport } from '../../src/client/stdio'; +import { DEFAULT_INHERITED_ENV_VARS, StdioClientTransport } from '../../src/client/stdio'; // Configure default server parameters based on OS // Uses 'more' command for Windows and 'tee' command for Unix/Linux @@ -150,3 +150,34 @@ test('_dispose releases the parent-side pipe handles even when a helper process expect(proc.stdout?.destroyed).toBe(true); expect(proc.stdin?.destroyed).toBe(true); }, 10_000); + +test('DEFAULT_INHERITED_ENV_VARS matches the host platform', () => { + if (process.platform === 'win32') { + // Variables Windows tooling needs to resolve executables and shells. + // Missing PATHEXT or COMSPEC causes spawn ENOENT for npm/git/etc. + expect(DEFAULT_INHERITED_ENV_VARS).toEqual( + expect.arrayContaining([ + 'APPDATA', + 'COMSPEC', + 'HOMEDRIVE', + 'HOMEPATH', + 'LOCALAPPDATA', + 'PATH', + 'PATHEXT', + 'PROCESSOR_ARCHITECTURE', + 'PROGRAMDATA', + 'PROGRAMFILES', + 'PROGRAMFILES(X86)', + 'PROGRAMW6432', + 'SYSTEMDRIVE', + 'SYSTEMROOT', + 'TEMP', + 'USERNAME', + 'USERPROFILE', + 'WINDIR' + ]) + ); + } else { + expect(DEFAULT_INHERITED_ENV_VARS).toEqual(['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']); + } +}); diff --git a/packages/client/test/client/stdioEnvPins.test.ts b/packages/client/test/client/stdioEnvPins.test.ts index 7a9f03d3b0..cfc4cabc52 100644 --- a/packages/client/test/client/stdioEnvPins.test.ts +++ b/packages/client/test/client/stdioEnvPins.test.ts @@ -24,17 +24,23 @@ const SAFELIST = process.platform === 'win32' ? [ 'APPDATA', + 'COMSPEC', 'HOMEDRIVE', 'HOMEPATH', 'LOCALAPPDATA', 'PATH', + 'PATHEXT', 'PROCESSOR_ARCHITECTURE', + 'PROGRAMDATA', + 'PROGRAMFILES', + 'PROGRAMFILES(X86)', + 'PROGRAMW6432', 'SYSTEMDRIVE', 'SYSTEMROOT', 'TEMP', 'USERNAME', 'USERPROFILE', - 'PROGRAMFILES' + 'WINDIR' ] : ['HOME', 'LOGNAME', 'PATH', 'SHELL', 'TERM', 'USER']; diff --git a/packages/codemod/CHANGELOG.md b/packages/codemod/CHANGELOG.md index 3506a1e5f9..2a3d2558a4 100644 --- a/packages/codemod/CHANGELOG.md +++ b/packages/codemod/CHANGELOG.md @@ -1,5 +1,11 @@ # @modelcontextprotocol/codemod +## 2.1.0 + +### Patch Changes + +- [#2765](https://github.com/modelcontextprotocol/typescript-sdk/pull/2765) [`5ecc791`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5ecc791d81a7221ebe15ae3ae3f36a8e978af820) Thanks [@claude](https://github.com/apps/claude)! - Project-type inference no longer counts bare SDK paths that appear only in ordinary string data. The v1→v2 codemod's source scanner matched any quoted `@modelcontextprotocol/sdk/client|server` subpath anywhere in a file, so a server path stored as data (example text, a log message, a config value) misclassified a client-only project as `both` — rewriting shared type imports to `@modelcontextprotocol/server` and adding a server dependency the project never uses. The scanner now requires a module-specifier position: static imports and re-exports (`from '...'`), side-effect imports, dynamic `import('...')` (including webpack magic comments), `require('...')` / `require.resolve('...')`, and the `vi.`/`jest.` mock-method calls the mock-paths transform rewrites. Known limitation: the scan is lexical, so a string whose text embeds a complete import statement still counts. + ## 2.0.0 ### Patch Changes diff --git a/packages/codemod/package.json b/packages/codemod/package.json index 09cabe0bcf..976e923f8a 100644 --- a/packages/codemod/package.json +++ b/packages/codemod/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/codemod", - "version": "2.0.0", + "version": "2.1.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 450c293bf3..7c96d30e13 100644 --- a/packages/core-internal/CHANGELOG.md +++ b/packages/core-internal/CHANGELOG.md @@ -1,5 +1,57 @@ # @modelcontextprotocol/core-internal +## 2.0.1 + +### Patch Changes + +- [#2726](https://github.com/modelcontextprotocol/typescript-sdk/pull/2726) [`6fa4227`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6fa42279fecaba423635072f716bbb2f6f7c77f3) Thanks [@LuckTerence](https://github.com/LuckTerence)! - `SdkError` and `SdkHttpError` accept standard `ErrorOptions` as an optional fourth constructor argument and forward it to `Error`, so a wrapped error is reachable through the standard `Error.cause` chain. Version-negotiation probe failures (`SdkErrorCode.EraNegotiationFailed`) now use it: the underlying `TypeError: fetch failed` and the DNS or socket error beneath it surface via `error.cause`, so pino, Sentry, and `util.inspect` render `ENOTFOUND` / `ECONNREFUSED` / `ETIMEDOUT` instead of stopping at the `SdkError` (#2657). The previous `error.data.cause` slot is still populated for compatibility but is deprecated and slated for removal; read `error.cause` instead. + +- [#2654](https://github.com/modelcontextprotocol/typescript-sdk/pull/2654) [`03842cd`](https://github.com/modelcontextprotocol/typescript-sdk/commit/03842cd9cae9a9b142c77d2fb65e829fc4e03eab) Thanks [@pshah19](https://github.com/pshah19)! - Treat request id `0` as a real id. Two guards tested a `RequestId` for truthiness, so the legal JSON-RPC ids `0` and `''` were read as absent. Id `0` is not a corner case: the outbound request counter is zero-based, so it is the first id every peer assigns, which on the server→client leg is the first `sampling/createMessage`, `elicitation/create`, or `roots/list` a server sends. + - `notifications/cancelled` carrying id `0` was ignored, and the in-flight handler ran to completion with its `AbortSignal` never fired. + - A notification sent with `relatedRequestId: 0` wrongly passed the debounce gate (for methods opted into `debouncedNotificationMethods`). Because the pending set is keyed by method alone, a second such notification in the same tick was silently dropped rather than sent. + + Absent is now the only value that means "no id". + +- [#2668](https://github.com/modelcontextprotocol/typescript-sdk/pull/2668) [`3e90449`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3e90449fd52997da43b79a536d2c19c446603cc7) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - Stop sending `notifications/cancelled` for the `initialize` handshake. The spec is explicit that a client MUST NOT attempt to cancel its `initialize` request, but the outbound cancel path fired for any in-flight request: aborting the `AbortSignal` passed to `connect()`, or letting the handshake hit its timeout, put a forbidden cancellation on the wire naming the initialize request id. + + The local behaviour is unchanged — the caller's promise still rejects with the same abort/timeout error, and `connect()` still tears the connection down. Only the wire notification is suppressed. Every other method keeps the existing cancellation path. + +- [#2590](https://github.com/modelcontextprotocol/typescript-sdk/pull/2590) [`75dc7ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/75dc7ea6e2913e1ac37d4f06eec62cd5cfac9e7a) Thanks [@davidpavlovschi](https://github.com/davidpavlovschi)! - Reject a modern (2026-07-28) POST that omits the required `MCP-Protocol-Version` header. + + `createMcpHandler` accepted a request whose body carried a valid per-request `_meta` + envelope but whose `MCP-Protocol-Version` header was absent: the request was classified + modern, dispatched, and answered `200` — tool handlers ran. Only the _mismatch_ case + (header present, disagreeing with the body) was rejected, so of the standard headers + SEP-2243 requires on a modern POST, presence was enforced for `Mcp-Method` (and for + `Mcp-Name` on the methods that mirror `params.name` / `params.uri`) but not for + `MCP-Protocol-Version`. + + Such a request is now refused with `400 Bad Request` and JSON-RPC `-32020` + (`HeaderMismatch`), matching the shape the sibling missing-header cells already emit and + echoing the request id — per the Streamable HTTP spec, which requires the header on every + POST and lists a missing required standard header as a `HeaderMismatch` failure. The + spec's allowance to treat a header-less request as `2025-03-26` is available only to a + server that also serves pre-2025-06-18 clients, and permits routing it to _legacy_ + handling — never serving it as 2026-07-28; under `legacy: 'reject'` the requirement is + unconditional. + + Era classification is deliberately unchanged and stays body-primary: a proxy that strips + the header still must not change the era, so such a request is still _classified_ modern + and is refused one rung later, at `standard-header-validation` — the same rung that + already answers a missing `Mcp-Method`. Legacy-era traffic is untouched, notifications + are unaffected, body-less `GET` / `DELETE` session operations are method-routed before + any header validation, and stdio serving (which has no HTTP headers) is not involved. + + Clients built with this SDK always send the header, so no first-party client is affected; + hand-rolled clients that omitted it must add it. + +- [#2613](https://github.com/modelcontextprotocol/typescript-sdk/pull/2613) [`70de0c8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/70de0c8b569b0d664a56b90be2f141d1d1645880) Thanks [@jwcarman](https://github.com/jwcarman)! - Emit and validate the `Mcp-Name` header for tasks requests per SEP-2663's Streamable HTTP binding: the client transport now mirrors `params.taskId` into `Mcp-Name` on `tasks/get` / `tasks/update` / `tasks/cancel` (previously omitted, causing conforming servers to reject every task poll with `-32020 HeaderMismatch`), and the server-side standard-header validation cross-checks it via the same shared `MCP_NAME_HEADER_SOURCE` table. + + On the server, `createMcpHandler` now answers a modern (2026-07-28) `tasks/get` / `tasks/update` / `tasks/cancel` POST that omits `Mcp-Name`, or whose header disagrees with `params.taskId`, with `400` / `-32020` (`HeaderMismatch`) at the `standard-header-validation` rung, the same treatment `tools/call` / `prompts/get` / `resources/read` already get. Legacy-era (2025-11-25) tasks traffic is unaffected. Clients built with this SDK release send the header; hand-rolled clients that omitted it must add it. + +- Updated dependencies [[`dcc0102`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcc01028ff6a499a5728c2b6181c1727d52e2fab)]: + - @modelcontextprotocol/core@2.1.0 + ## 2.0.0 ### Minor Changes diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json index 44a4be1cd3..63719b3c2b 100644 --- a/packages/core-internal/package.json +++ b/packages/core-internal/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/core-internal", "private": true, - "version": "2.0.0", + "version": "2.0.1", "description": "Model Context Protocol implementation for TypeScript - Core package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index d109bed7a6..2f1543900b 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,14 @@ # @modelcontextprotocol/core +## 2.1.0 + +### Minor Changes + +- [#2629](https://github.com/modelcontextprotocol/typescript-sdk/pull/2629) [`dcc0102`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcc01028ff6a499a5728c2b6181c1727d52e2fab) Thanks [@gbshankar](https://github.com/gbshankar)! - Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client. + - Opt in by implementing `OAuthClientProvider.dpop()` returning a `DpopSession` (new, along with `generateDpopKeyPair`, `accessTokenHash`, `isDpopNonceChallenge`). `auth()` / `exchangeAuthorization` / `refreshAuthorization` / `fetchToken` then sign a DPoP proof into token requests (retrying once on an authorization-server `use_dpop_nonce` challenge, with client authentication re-applied per attempt), and `StreamableHTTPClientTransport`, `SSEClientTransport` and `withOAuth` present a `token_type: "DPoP"` access token as `Authorization: DPoP ` plus a fresh per-request proof, retry a resource-server `use_dpop_nonce` challenge once, and pick up a `DPoP-Nonce` delivered on any response. Tokens the AS issued as `Bearer` are still presented as Bearer. + - DPoP is applied at the fetch layer: the transports wrap their resource-server `fetch` (including a caller-supplied `fetch` / `eventSourceInit.fetch`) with the new `withDpopFromProvider(provider)` middleware, so proofs are always bound to the request actually sent. `withDpop(session, getToken)` is exported for callers that manage tokens themselves (e.g. alongside a minimal `AuthProvider`); the `AuthProvider` interface itself is unchanged. + - `auth()` now recovers from `invalid_dpop_proof` on refresh (e.g. a refresh token bound to a key that is no longer held) by discarding the tokens and re-authorizing, like `invalid_grant`. `OAuthErrorCode` gains `InvalidDpopProof` and `UseDpopNonce`; `extractWWWAuthenticateParams` recognizes the `DPoP` challenge scheme; `OAuthMetadataSchema` gains `dpop_signing_alg_values_supported`. + ## 2.0.0 ### Minor Changes diff --git a/packages/core/package.json b/packages/core/package.json index 0e02ff8b1e..d11125e961 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/core", - "version": "2.0.0", + "version": "2.1.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/middleware/express/CHANGELOG.md b/packages/middleware/express/CHANGELOG.md index d23a21928a..951a2cc210 100644 --- a/packages/middleware/express/CHANGELOG.md +++ b/packages/middleware/express/CHANGELOG.md @@ -1,5 +1,37 @@ # @modelcontextprotocol/express +## 2.0.1 + +### Patch Changes + +- [#2698](https://github.com/modelcontextprotocol/typescript-sdk/pull/2698) [`7b781ed`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7b781ed4e25355a25d15974f3c76de81299694ed) Thanks [@maxisbey](https://github.com/maxisbey)! - Read Streamable HTTP request bodies with a size limit. Every SDK-owned body read — + `WebStandardStreamableHTTPServerTransport` (and the Node transport built on it), + `createMcpHandler`, `toNodeHandler`, and `createMcpHonoApp`'s JSON pre-parse — now stops at + 4 MiB by default (the limit the legacy SSE transport already uses; the Express adapter and stdio + bound their reads too) and answers `413 Payload Too Large` before anything is parsed. + `toWebRequest` (when it reads the Node stream itself) now rejects once the body exceeds the + limit with an error whose `name` is `'RequestBodyTooLargeError'` and `status` is `413`, and + `toNodeHandler` answers that with `413`; hand-wired callers of `toWebRequest` should handle the + rejection or pass a pre-parsed body, and `isLegacyRequest` reports such a request as non-legacy + so the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer + batch is answered `400` / `-32600` and none of it is dispatched. + + The limit is configurable with a new `maxRequestBodySize` option (bytes, default + `DEFAULT_MAX_REQUEST_BODY_SIZE` = 4 MiB, exported from `@modelcontextprotocol/server`) on + `WebStandardStreamableHTTPServerTransportOptions`, `CreateMcpHandlerOptions` (forwarded to its + stateless legacy leg; `isLegacyRequest` and `legacyStatelessFallback` take the same option), + `CreateMcpHonoAppOptions`, and `ToNodeHandlerOptions` / `ToWebRequestOptions` (the adapter's + bound applies before the handler's, so raise both). The bounded reader is exported as + `readRequestBody` for adapter authors. Hosts that pre-parse the body and pass it as + `parsedBody` skip the SDK's read and its size limit entirely; the batch bound applies either way. + + `createMcpHonoApp` and `createMcpExpressApp` now run their Host/Origin validation before the + JSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is + answered `403` rather than `400`, and its body is not read. + +- Updated dependencies [[`6fa4227`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6fa42279fecaba423635072f716bbb2f6f7c77f3), [`03842cd`](https://github.com/modelcontextprotocol/typescript-sdk/commit/03842cd9cae9a9b142c77d2fb65e829fc4e03eab), [`3e90449`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3e90449fd52997da43b79a536d2c19c446603cc7), [`7b781ed`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7b781ed4e25355a25d15974f3c76de81299694ed), [`75dc7ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/75dc7ea6e2913e1ac37d4f06eec62cd5cfac9e7a), [`6032170`](https://github.com/modelcontextprotocol/typescript-sdk/commit/60321700871029401a2e3bed8fdf4f02c9ec3331), [`6a05402`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6a054025e8cebaecead76fb51d92ba3974e8d091), [`70de0c8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/70de0c8b569b0d664a56b90be2f141d1d1645880)]: + - @modelcontextprotocol/server@2.1.0 + ## 2.0.0 ### Patch Changes diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json index 5a2602bafb..92fe653f5c 100644 --- a/packages/middleware/express/package.json +++ b/packages/middleware/express/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/express", "private": false, - "version": "2.0.0", + "version": "2.0.1", "description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/hono/CHANGELOG.md b/packages/middleware/hono/CHANGELOG.md index 060d31794e..d00c3ccc02 100644 --- a/packages/middleware/hono/CHANGELOG.md +++ b/packages/middleware/hono/CHANGELOG.md @@ -1,5 +1,37 @@ # @modelcontextprotocol/hono +## 2.0.1 + +### Patch Changes + +- [#2698](https://github.com/modelcontextprotocol/typescript-sdk/pull/2698) [`7b781ed`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7b781ed4e25355a25d15974f3c76de81299694ed) Thanks [@maxisbey](https://github.com/maxisbey)! - Read Streamable HTTP request bodies with a size limit. Every SDK-owned body read — + `WebStandardStreamableHTTPServerTransport` (and the Node transport built on it), + `createMcpHandler`, `toNodeHandler`, and `createMcpHonoApp`'s JSON pre-parse — now stops at + 4 MiB by default (the limit the legacy SSE transport already uses; the Express adapter and stdio + bound their reads too) and answers `413 Payload Too Large` before anything is parsed. + `toWebRequest` (when it reads the Node stream itself) now rejects once the body exceeds the + limit with an error whose `name` is `'RequestBodyTooLargeError'` and `status` is `413`, and + `toNodeHandler` answers that with `413`; hand-wired callers of `toWebRequest` should handle the + rejection or pass a pre-parsed body, and `isLegacyRequest` reports such a request as non-legacy + so the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer + batch is answered `400` / `-32600` and none of it is dispatched. + + The limit is configurable with a new `maxRequestBodySize` option (bytes, default + `DEFAULT_MAX_REQUEST_BODY_SIZE` = 4 MiB, exported from `@modelcontextprotocol/server`) on + `WebStandardStreamableHTTPServerTransportOptions`, `CreateMcpHandlerOptions` (forwarded to its + stateless legacy leg; `isLegacyRequest` and `legacyStatelessFallback` take the same option), + `CreateMcpHonoAppOptions`, and `ToNodeHandlerOptions` / `ToWebRequestOptions` (the adapter's + bound applies before the handler's, so raise both). The bounded reader is exported as + `readRequestBody` for adapter authors. Hosts that pre-parse the body and pass it as + `parsedBody` skip the SDK's read and its size limit entirely; the batch bound applies either way. + + `createMcpHonoApp` and `createMcpExpressApp` now run their Host/Origin validation before the + JSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is + answered `403` rather than `400`, and its body is not read. + +- Updated dependencies [[`6fa4227`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6fa42279fecaba423635072f716bbb2f6f7c77f3), [`03842cd`](https://github.com/modelcontextprotocol/typescript-sdk/commit/03842cd9cae9a9b142c77d2fb65e829fc4e03eab), [`3e90449`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3e90449fd52997da43b79a536d2c19c446603cc7), [`7b781ed`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7b781ed4e25355a25d15974f3c76de81299694ed), [`75dc7ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/75dc7ea6e2913e1ac37d4f06eec62cd5cfac9e7a), [`6032170`](https://github.com/modelcontextprotocol/typescript-sdk/commit/60321700871029401a2e3bed8fdf4f02c9ec3331), [`6a05402`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6a054025e8cebaecead76fb51d92ba3974e8d091), [`70de0c8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/70de0c8b569b0d664a56b90be2f141d1d1645880)]: + - @modelcontextprotocol/server@2.1.0 + ## 2.0.0 ### Patch Changes diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json index 3cd2650a4a..188f210e4d 100644 --- a/packages/middleware/hono/package.json +++ b/packages/middleware/hono/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/hono", "private": false, - "version": "2.0.0", + "version": "2.0.1", "description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/middleware/node/CHANGELOG.md b/packages/middleware/node/CHANGELOG.md index 32e8e337ec..ebd9186a7b 100644 --- a/packages/middleware/node/CHANGELOG.md +++ b/packages/middleware/node/CHANGELOG.md @@ -1,5 +1,57 @@ # @modelcontextprotocol/node +## 2.1.0 + +### Minor Changes + +- [#1624](https://github.com/modelcontextprotocol/typescript-sdk/pull/1624) [`6032170`](https://github.com/modelcontextprotocol/typescript-sdk/commit/60321700871029401a2e3bed8fdf4f02c9ec3331) Thanks [@SamMorrowDrums](https://github.com/SamMorrowDrums)! - Add request-time OAuth scope challenges for tools, resources, resource templates, + and prompts. Each primitive's `scopeChallenge` callback receives the parsed + request and verified authentication info, then either continues or returns the + exact scope set for an `insufficient_scope` response. `requireScopes` provides a + small helper for static all-of checks. + + `createMcpHandler` and Streamable HTTP transports return HTTP 403 with an + `insufficient_scope` challenge before handler execution or SSE setup. The + preflight is active whenever a registered primitive carries a `scopeChallenge` + callback — there is no handler- or transport-level configuration. The + challenge's `WWW-Authenticate` header is built by the same formatter as the + bearer-auth 401/403 answers, and its `resource_metadata` parameter is derived + from the verified `AuthInfo`: `requireBearerAuth` / `verifyBearerToken` now + stamp their configured `resourceMetadataUrl` onto the `AuthInfo` they return + (new optional `AuthInfo.resourceMetadataUrl` field), with a fallback to the + well-known location for an HTTP(S) RFC 8707 `resource` identifier; the + parameter is omitted when neither is available. + +### Patch Changes + +- [#2698](https://github.com/modelcontextprotocol/typescript-sdk/pull/2698) [`7b781ed`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7b781ed4e25355a25d15974f3c76de81299694ed) Thanks [@maxisbey](https://github.com/maxisbey)! - Read Streamable HTTP request bodies with a size limit. Every SDK-owned body read — + `WebStandardStreamableHTTPServerTransport` (and the Node transport built on it), + `createMcpHandler`, `toNodeHandler`, and `createMcpHonoApp`'s JSON pre-parse — now stops at + 4 MiB by default (the limit the legacy SSE transport already uses; the Express adapter and stdio + bound their reads too) and answers `413 Payload Too Large` before anything is parsed. + `toWebRequest` (when it reads the Node stream itself) now rejects once the body exceeds the + limit with an error whose `name` is `'RequestBodyTooLargeError'` and `status` is `413`, and + `toNodeHandler` answers that with `413`; hand-wired callers of `toWebRequest` should handle the + rejection or pass a pre-parsed body, and `isLegacyRequest` reports such a request as non-legacy + so the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer + batch is answered `400` / `-32600` and none of it is dispatched. + + The limit is configurable with a new `maxRequestBodySize` option (bytes, default + `DEFAULT_MAX_REQUEST_BODY_SIZE` = 4 MiB, exported from `@modelcontextprotocol/server`) on + `WebStandardStreamableHTTPServerTransportOptions`, `CreateMcpHandlerOptions` (forwarded to its + stateless legacy leg; `isLegacyRequest` and `legacyStatelessFallback` take the same option), + `CreateMcpHonoAppOptions`, and `ToNodeHandlerOptions` / `ToWebRequestOptions` (the adapter's + bound applies before the handler's, so raise both). The bounded reader is exported as + `readRequestBody` for adapter authors. Hosts that pre-parse the body and pass it as + `parsedBody` skip the SDK's read and its size limit entirely; the batch bound applies either way. + + `createMcpHonoApp` and `createMcpExpressApp` now run their Host/Origin validation before the + JSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is + answered `403` rather than `400`, and its body is not read. + +- Updated dependencies [[`6fa4227`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6fa42279fecaba423635072f716bbb2f6f7c77f3), [`03842cd`](https://github.com/modelcontextprotocol/typescript-sdk/commit/03842cd9cae9a9b142c77d2fb65e829fc4e03eab), [`3e90449`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3e90449fd52997da43b79a536d2c19c446603cc7), [`7b781ed`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7b781ed4e25355a25d15974f3c76de81299694ed), [`75dc7ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/75dc7ea6e2913e1ac37d4f06eec62cd5cfac9e7a), [`6032170`](https://github.com/modelcontextprotocol/typescript-sdk/commit/60321700871029401a2e3bed8fdf4f02c9ec3331), [`6a05402`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6a054025e8cebaecead76fb51d92ba3974e8d091), [`70de0c8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/70de0c8b569b0d664a56b90be2f141d1d1645880)]: + - @modelcontextprotocol/server@2.1.0 + ## 2.0.0 ### Patch Changes diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json index 68ccb73ffd..5e5cc6a47c 100644 --- a/packages/middleware/node/package.json +++ b/packages/middleware/node/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/node", - "version": "2.0.0", + "version": "2.1.0", "description": "Model Context Protocol implementation for TypeScript - Node.js middleware", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/server-legacy/CHANGELOG.md b/packages/server-legacy/CHANGELOG.md index b3d95738d1..553cba100f 100644 --- a/packages/server-legacy/CHANGELOG.md +++ b/packages/server-legacy/CHANGELOG.md @@ -1,5 +1,12 @@ # @modelcontextprotocol/server-legacy +## 2.1.0 + +### Patch Changes + +- Updated dependencies [[`dcc0102`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcc01028ff6a499a5728c2b6181c1727d52e2fab)]: + - @modelcontextprotocol/core@2.1.0 + ## 2.0.0 ### Minor Changes diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json index 9ef13dcff7..45b9fe65e1 100644 --- a/packages/server-legacy/package.json +++ b/packages/server-legacy/package.json @@ -1,7 +1,7 @@ { "name": "@modelcontextprotocol/server-legacy", "private": false, - "version": "2.0.0", + "version": "2.1.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 c24a3604e5..e82a8dac9c 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -1,5 +1,104 @@ # @modelcontextprotocol/server +## 2.1.0 + +### Minor Changes + +- [#1624](https://github.com/modelcontextprotocol/typescript-sdk/pull/1624) [`6032170`](https://github.com/modelcontextprotocol/typescript-sdk/commit/60321700871029401a2e3bed8fdf4f02c9ec3331) Thanks [@SamMorrowDrums](https://github.com/SamMorrowDrums)! - Add request-time OAuth scope challenges for tools, resources, resource templates, + and prompts. Each primitive's `scopeChallenge` callback receives the parsed + request and verified authentication info, then either continues or returns the + exact scope set for an `insufficient_scope` response. `requireScopes` provides a + small helper for static all-of checks. + + `createMcpHandler` and Streamable HTTP transports return HTTP 403 with an + `insufficient_scope` challenge before handler execution or SSE setup. The + preflight is active whenever a registered primitive carries a `scopeChallenge` + callback — there is no handler- or transport-level configuration. The + challenge's `WWW-Authenticate` header is built by the same formatter as the + bearer-auth 401/403 answers, and its `resource_metadata` parameter is derived + from the verified `AuthInfo`: `requireBearerAuth` / `verifyBearerToken` now + stamp their configured `resourceMetadataUrl` onto the `AuthInfo` they return + (new optional `AuthInfo.resourceMetadataUrl` field), with a fallback to the + well-known location for an HTTP(S) RFC 8707 `resource` identifier; the + parameter is omitted when neither is available. + +### Patch Changes + +- [#2726](https://github.com/modelcontextprotocol/typescript-sdk/pull/2726) [`6fa4227`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6fa42279fecaba423635072f716bbb2f6f7c77f3) Thanks [@LuckTerence](https://github.com/LuckTerence)! - `SdkError` and `SdkHttpError` accept standard `ErrorOptions` as an optional fourth constructor argument and forward it to `Error`, so a wrapped error is reachable through the standard `Error.cause` chain. Version-negotiation probe failures (`SdkErrorCode.EraNegotiationFailed`) now use it: the underlying `TypeError: fetch failed` and the DNS or socket error beneath it surface via `error.cause`, so pino, Sentry, and `util.inspect` render `ENOTFOUND` / `ECONNREFUSED` / `ETIMEDOUT` instead of stopping at the `SdkError` (#2657). The previous `error.data.cause` slot is still populated for compatibility but is deprecated and slated for removal; read `error.cause` instead. + +- [#2654](https://github.com/modelcontextprotocol/typescript-sdk/pull/2654) [`03842cd`](https://github.com/modelcontextprotocol/typescript-sdk/commit/03842cd9cae9a9b142c77d2fb65e829fc4e03eab) Thanks [@pshah19](https://github.com/pshah19)! - Treat request id `0` as a real id. Two guards tested a `RequestId` for truthiness, so the legal JSON-RPC ids `0` and `''` were read as absent. Id `0` is not a corner case: the outbound request counter is zero-based, so it is the first id every peer assigns, which on the server→client leg is the first `sampling/createMessage`, `elicitation/create`, or `roots/list` a server sends. + - `notifications/cancelled` carrying id `0` was ignored, and the in-flight handler ran to completion with its `AbortSignal` never fired. + - A notification sent with `relatedRequestId: 0` wrongly passed the debounce gate (for methods opted into `debouncedNotificationMethods`). Because the pending set is keyed by method alone, a second such notification in the same tick was silently dropped rather than sent. + + Absent is now the only value that means "no id". + +- [#2668](https://github.com/modelcontextprotocol/typescript-sdk/pull/2668) [`3e90449`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3e90449fd52997da43b79a536d2c19c446603cc7) Thanks [@KKonstantinov](https://github.com/KKonstantinov)! - Stop sending `notifications/cancelled` for the `initialize` handshake. The spec is explicit that a client MUST NOT attempt to cancel its `initialize` request, but the outbound cancel path fired for any in-flight request: aborting the `AbortSignal` passed to `connect()`, or letting the handshake hit its timeout, put a forbidden cancellation on the wire naming the initialize request id. + + The local behaviour is unchanged — the caller's promise still rejects with the same abort/timeout error, and `connect()` still tears the connection down. Only the wire notification is suppressed. Every other method keeps the existing cancellation path. + +- [#2698](https://github.com/modelcontextprotocol/typescript-sdk/pull/2698) [`7b781ed`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7b781ed4e25355a25d15974f3c76de81299694ed) Thanks [@maxisbey](https://github.com/maxisbey)! - Read Streamable HTTP request bodies with a size limit. Every SDK-owned body read — + `WebStandardStreamableHTTPServerTransport` (and the Node transport built on it), + `createMcpHandler`, `toNodeHandler`, and `createMcpHonoApp`'s JSON pre-parse — now stops at + 4 MiB by default (the limit the legacy SSE transport already uses; the Express adapter and stdio + bound their reads too) and answers `413 Payload Too Large` before anything is parsed. + `toWebRequest` (when it reads the Node stream itself) now rejects once the body exceeds the + limit with an error whose `name` is `'RequestBodyTooLargeError'` and `status` is `413`, and + `toNodeHandler` answers that with `413`; hand-wired callers of `toWebRequest` should handle the + rejection or pass a pre-parsed body, and `isLegacyRequest` reports such a request as non-legacy + so the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer + batch is answered `400` / `-32600` and none of it is dispatched. + + The limit is configurable with a new `maxRequestBodySize` option (bytes, default + `DEFAULT_MAX_REQUEST_BODY_SIZE` = 4 MiB, exported from `@modelcontextprotocol/server`) on + `WebStandardStreamableHTTPServerTransportOptions`, `CreateMcpHandlerOptions` (forwarded to its + stateless legacy leg; `isLegacyRequest` and `legacyStatelessFallback` take the same option), + `CreateMcpHonoAppOptions`, and `ToNodeHandlerOptions` / `ToWebRequestOptions` (the adapter's + bound applies before the handler's, so raise both). The bounded reader is exported as + `readRequestBody` for adapter authors. Hosts that pre-parse the body and pass it as + `parsedBody` skip the SDK's read and its size limit entirely; the batch bound applies either way. + + `createMcpHonoApp` and `createMcpExpressApp` now run their Host/Origin validation before the + JSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is + answered `403` rather than `400`, and its body is not read. + +- [#2590](https://github.com/modelcontextprotocol/typescript-sdk/pull/2590) [`75dc7ea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/75dc7ea6e2913e1ac37d4f06eec62cd5cfac9e7a) Thanks [@davidpavlovschi](https://github.com/davidpavlovschi)! - Reject a modern (2026-07-28) POST that omits the required `MCP-Protocol-Version` header. + + `createMcpHandler` accepted a request whose body carried a valid per-request `_meta` + envelope but whose `MCP-Protocol-Version` header was absent: the request was classified + modern, dispatched, and answered `200` — tool handlers ran. Only the _mismatch_ case + (header present, disagreeing with the body) was rejected, so of the standard headers + SEP-2243 requires on a modern POST, presence was enforced for `Mcp-Method` (and for + `Mcp-Name` on the methods that mirror `params.name` / `params.uri`) but not for + `MCP-Protocol-Version`. + + Such a request is now refused with `400 Bad Request` and JSON-RPC `-32020` + (`HeaderMismatch`), matching the shape the sibling missing-header cells already emit and + echoing the request id — per the Streamable HTTP spec, which requires the header on every + POST and lists a missing required standard header as a `HeaderMismatch` failure. The + spec's allowance to treat a header-less request as `2025-03-26` is available only to a + server that also serves pre-2025-06-18 clients, and permits routing it to _legacy_ + handling — never serving it as 2026-07-28; under `legacy: 'reject'` the requirement is + unconditional. + + Era classification is deliberately unchanged and stays body-primary: a proxy that strips + the header still must not change the era, so such a request is still _classified_ modern + and is refused one rung later, at `standard-header-validation` — the same rung that + already answers a missing `Mcp-Method`. Legacy-era traffic is untouched, notifications + are unaffected, body-less `GET` / `DELETE` session operations are method-routed before + any header validation, and stdio serving (which has no HTTP headers) is not involved. + + Clients built with this SDK always send the header, so no first-party client is affected; + hand-rolled clients that omitted it must add it. + +- [#2494](https://github.com/modelcontextprotocol/typescript-sdk/pull/2494) [`6a05402`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6a054025e8cebaecead76fb51d92ba3974e8d091) Thanks [@claude](https://github.com/apps/claude)! - `StdioServerTransport` now closes itself and fires `onclose` when its stdin ends or closes. The stdio binding says servers "SHOULD exit promptly when their standard input is closed" — stdin EOF is the primary graceful-shutdown signal, and on some platforms (notably Windows, where no signal is delivered when the parent goes away) the only reliable one. Previously the transport listened only for `data` and `error`, so when an MCP client hung up its end of the pipe (window closed, session restarted, host crashed) the server never noticed: `onclose` never fired, nothing tore down, and server processes accumulated as zombies until killed by hand. The transport now attaches `end`/`close` listeners on stdin that close the transport (idempotently — `onclose` still fires exactly once if `close()` is also called), so `Server`/`McpServer` and `serveStdio` tear down through the existing `onclose` chain and a well-behaved server process exits naturally. Requests still in flight when stdin ends are aborted (their handlers observe `signal.aborted`) and their responses are not written: EOF means the client has hung up and is no longer waiting. A client that wants answers keeps stdin open until it has read them. + +- [#2613](https://github.com/modelcontextprotocol/typescript-sdk/pull/2613) [`70de0c8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/70de0c8b569b0d664a56b90be2f141d1d1645880) Thanks [@jwcarman](https://github.com/jwcarman)! - Emit and validate the `Mcp-Name` header for tasks requests per SEP-2663's Streamable HTTP binding: the client transport now mirrors `params.taskId` into `Mcp-Name` on `tasks/get` / `tasks/update` / `tasks/cancel` (previously omitted, causing conforming servers to reject every task poll with `-32020 HeaderMismatch`), and the server-side standard-header validation cross-checks it via the same shared `MCP_NAME_HEADER_SOURCE` table. + + On the server, `createMcpHandler` now answers a modern (2026-07-28) `tasks/get` / `tasks/update` / `tasks/cancel` POST that omits `Mcp-Name`, or whose header disagrees with `params.taskId`, with `400` / `-32020` (`HeaderMismatch`) at the `standard-header-validation` rung, the same treatment `tools/call` / `prompts/get` / `resources/read` already get. Legacy-era (2025-11-25) tasks traffic is unaffected. Clients built with this SDK release send the header; hand-rolled clients that omitted it must add it. + +- Updated dependencies [[`dcc0102`](https://github.com/modelcontextprotocol/typescript-sdk/commit/dcc01028ff6a499a5728c2b6181c1727d52e2fab)]: + - @modelcontextprotocol/core@2.1.0 + ## 2.0.0 ### Minor Changes diff --git a/packages/server/package.json b/packages/server/package.json index f481e019e7..9483242b91 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,6 +1,6 @@ { "name": "@modelcontextprotocol/server", - "version": "2.0.0", + "version": "2.1.0", "description": "Model Context Protocol implementation for TypeScript - Server package", "license": "MIT", "author": "Anthropic, PBC (https://anthropic.com)", diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index 6c46671b50..1cabf8ed9b 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -598,6 +598,11 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions if (state.era === 'modern' && (await tryServeListen(message))) { return; } + if (isTornDown()) { + // Closed while entry listen routing was being checked (a + // buffered final message can race stdin EOF): stay closed. + return; + } state.instance.channel.deliver(message); return; } @@ -692,6 +697,11 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions if (await tryServeListen(message)) { return; } + if (isTornDown()) { + // Closed while entry listen routing was being checked (a + // buffered final message can race stdin EOF): stay closed. + return; + } state.instance.channel.deliver(message, { classification: opening.classification }); return; } diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index e8fda03257..4895359ab3 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -4,11 +4,27 @@ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-inter import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core-internal'; import { process } from '@modelcontextprotocol/server/_shims'; +/** + * Marks a stdout `'error'` listener that a closed transport left attached purely to + * swallow late write failures (see {@link StdioServerTransport.close}). A fresh + * transport taking over the same stream removes listeners carrying this tag in + * `start()`, so repeated create→close cycles on the process-global `process.stdout` + * don't accumulate listeners. + */ +const swallowsErrorsAfterClose = Symbol('swallowsErrorsAfterClose'); + /** * Server transport for stdio: this communicates with an MCP client by reading from the current process' `stdin` and writing to `stdout`. * * This transport is only available in Node.js environments. * + * When the client closes its end of the pipe (stdin reaches end-of-file), the transport + * closes itself and fires `onclose`, per the MCP stdio binding's guidance that servers + * should exit promptly when their standard input is closed. A server that holds no other + * keep-alive handles will then exit naturally. Requests still in flight when stdin ends are + * aborted and not answered; a client that expects responses keeps stdin open until it has + * read them. + * * @example * ```ts source="./stdio.examples.ts#StdioServerTransport_basicUsage" * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); @@ -55,11 +71,31 @@ export class StdioServerTransport implements Transport { this.onerror?.(error); }; _onstdouterror = (error: Error) => { + if (this._closed) { + // Swallow stdout errors that surface after close. A write accepted before + // close (`write()` returned true) can still fail asynchronously at the OS + // level — e.g. EPIPE when the client that hung up also stops reading our + // stdout. The transport is already closed, so there is nothing to do; but + // with no listener attached, that late 'error' event would crash the + // process with an uncaught exception — which is why close() leaves this + // listener attached. + return; + } this.onerror?.(error); this.close().catch(() => { // Ignore errors during close — we're already in an error path }); }; + _onstdinclose = () => { + // stdin reaching EOF (or being destroyed) means the client has hung up and no + // further input can ever arrive. The MCP stdio binding says servers should exit + // promptly when their standard input closes — this is the primary graceful + // shutdown signal, and on some platforms (e.g. Windows) the only reliable one. + // Close the transport so `onclose` fires and the process can exit naturally. + this.close().catch(() => { + // Ignore errors during close — nothing more can be read anyway + }); + }; /** * Starts listening for messages on `stdin`. @@ -72,8 +108,31 @@ export class StdioServerTransport implements Transport { } this._started = true; + // Remove swallow-only 'error' listeners left behind by previously closed + // transports on this stream (see close()). Our own listener covers the + // stream from here on, so the stale ones are no longer needed — without + // this sweep, repeated create→close cycles on the process-global + // process.stdout would accumulate listeners. + for (const listener of this._stdout.listeners('error')) { + if ((listener as { [swallowsErrorsAfterClose]?: boolean })[swallowsErrorsAfterClose]) { + this._stdout.off('error', listener as (error: Error) => void); + } + } + // A stream that already ended or was destroyed before start() ran has + // emitted its one 'end'/'close' event; the listeners below would never + // fire. This can happen with a custom stream (e.g. a net.Socket the + // peer reset during async setup) — treat it as the client having hung + // up. Deferred to the next event-loop turn (not a microtask) so it lands + // after the caller's start()/connect() continuation, exactly like a real + // 'end' would — an onclose assigned right after `await connect()` must + // still see it. + if (this._stdin.readableEnded || this._stdin.destroyed) { + setImmediate(this._onstdinclose); + } this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); + this._stdin.on('end', this._onstdinclose); + this._stdin.on('close', this._onstdinclose); this._stdout.on('error', this._onstdouterror); } @@ -101,7 +160,15 @@ export class StdioServerTransport implements Transport { // Remove our event listeners first this._stdin.off('data', this._ondata); this._stdin.off('error', this._onerror); - this._stdout.off('error', this._onstdouterror); + this._stdin.off('end', this._onstdinclose); + this._stdin.off('close', this._onstdinclose); + // Deliberately keep _onstdouterror attached (it is a no-op now that _closed + // is set): a write that was accepted before close may still be pending at + // the OS level and can fail after close (e.g. EPIPE when the client hangs + // up), and an 'error' event on a listener-less stream would crash the + // process. Tag it so the next transport to start on this stream can sweep + // it away instead of letting closed transports' listeners accumulate. + (this._onstdouterror as { [swallowsErrorsAfterClose]?: boolean })[swallowsErrorsAfterClose] = true; // Check if we were the only data listener const remainingDataListeners = this._stdin.listenerCount('data'); diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index 0a23f5afc4..eea38beb08 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -20,6 +20,8 @@ * - malformed and unsupported envelope claims are answered by the entry, * consistent with the HTTP entry's treatment, without pinning. */ +import { Readable, Writable } from 'node:stream'; + import type { JSONRPCErrorResponse, JSONRPCMessage, @@ -47,6 +49,7 @@ import type { McpServerFactory } from '../../src/server/createMcpHandler'; import { McpServer } from '../../src/server/mcp'; import type { ServeStdioOptions } from '../../src/server/serveStdio'; import { serveStdio } from '../../src/server/serveStdio'; +import { StdioServerTransport } from '../../src/server/stdio'; const MODERN = '2026-07-28'; @@ -811,6 +814,64 @@ describe('teardown', () => { expect(closed[0]).toBe(true); expect(peerClosed).toBe(true); }); + + it('a buffered final message racing stdin EOF on a modern-pinned connection is dropped cleanly (no TypeError through onerror)', async () => { + // Over the real StdioServerTransport: stdin 'end' fires on the next + // tick behind a final 'data' chunk, so the transport's onclose (which + // reassigns the entry state to closed) runs BEFORE the pump's + // microtask continuation inside processMessage resumes from + // `await tryServeListen(message)`. The continuation must re-check + // teardown instead of dereferencing the pinned instance. + const { factory, closed } = trackingFactory(); + + const stdin = new Readable({ read() {} }); + const outLines: string[] = []; + const stdout = new Writable({ + write(chunk: Buffer, _encoding, callback) { + outLines.push(chunk.toString()); + callback(); + } + }); + const transport = new StdioServerTransport(stdin, stdout); + + const errors: Error[] = []; + const handle = serveStdio(factory, { transport, onerror: error => void errors.push(error) }); + + const line = (message: JSONRPCMessage) => Buffer.from(`${JSON.stringify(message)}\n`); + + // Pin the modern era with a first enveloped request and wait for its answer. + stdin.emit('data', line({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } })); + while (outLines.length === 0) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + + // The race: a final enveloped request with stdin EOF right behind it. + // Emitted from a timer callback so the events originate from a + // macrotask exactly as real stream events do — there, the + // nextTick'd 'end' runs BEFORE the pump's promise continuation + // resumes inside processMessage (a test body itself is a microtask + // context, where the continuation would win and mask the race). + setTimeout(() => { + stdin.emit( + 'data', + line({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { text: 'late' }, _meta: envelope() } + }) + ); + process.nextTick(() => stdin.emit('end')); + }, 0); + await new Promise(resolve => setTimeout(resolve, 20)); + + // The connection tore down (EOF closed the pinned instance) and the + // raced message was dropped without any error surfacing to onerror. + expect(closed[0]).toBe(true); + expect(errors).toEqual([]); + + await handle.close(); + }); }); describe('legacy input_required shim through the stdio entry', () => { diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index fe79e3679c..4390360ceb 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -138,6 +138,189 @@ test('should not fire onclose twice when close() is called after stdout error', expect(closeCount).toBe(1); }); +test('should close and fire onclose when stdin ends (client hung up)', async () => { + // `autoDestroy: false, emitClose: false` so that pushing EOF emits only 'end', + // proving the 'end' listener works on its own (without relying on 'close'). + const endOnlyInput = new Readable({ read: () => {}, autoDestroy: false, emitClose: false }); + const server = new StdioServerTransport(endOnlyInput, output); + server.onerror = error => { + throw error; + }; + + let closeCount = 0; + const closed = new Promise(resolve => { + server.onclose = () => { + closeCount++; + resolve(); + }; + }); + + await server.start(); + endOnlyInput.push(null); // EOF — the client closed its end of the pipe + + await closed; + expect(closeCount).toBe(1); +}); + +test('should close and fire onclose when stdin closes', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = error => { + throw error; + }; + + let closeCount = 0; + const closed = new Promise(resolve => { + server.onclose = () => { + closeCount++; + resolve(); + }; + }); + + await server.start(); + input.destroy(); // emits 'close' + + await closed; + expect(closeCount).toBe(1); +}); + +test('should fire onclose when stdin was already destroyed before start()', async () => { + // A custom stream (e.g. a net.Socket the peer reset during async setup) can + // be dead before start() runs: its one 'close' event already fired, so a + // listener registered in start() would never see it. + input.destroy(); + await new Promise(resolve => { + input.once('close', resolve); + }); + expect(input.destroyed).toBe(true); + + const server = new StdioServerTransport(input, output); + server.onerror = error => { + throw error; + }; + + let closeCount = 0; + const closed = new Promise(resolve => { + server.onclose = () => { + closeCount++; + resolve(); + }; + }); + + await server.start(); + + await closed; + expect(closeCount).toBe(1); +}); + +test('should fire onclose when stdin had already ended before start()', async () => { + // `autoDestroy: false, emitClose: false` so consuming the stream to EOF + // leaves it ended but NOT destroyed — its one 'end' event fired before + // start(), so only the `readableEnded` check can catch this shape. + const endedInput = new Readable({ read: () => {}, autoDestroy: false, emitClose: false }); + endedInput.push(null); // EOF + endedInput.resume(); // flow, so 'end' actually fires + await new Promise(resolve => { + endedInput.once('end', resolve); + }); + expect(endedInput.readableEnded).toBe(true); + expect(endedInput.destroyed).toBe(false); + + const server = new StdioServerTransport(endedInput, output); + server.onerror = error => { + throw error; + }; + + let closeCount = 0; + const closed = new Promise(resolve => { + server.onclose = () => { + closeCount++; + resolve(); + }; + }); + + await server.start(); + + await closed; + expect(closeCount).toBe(1); +}); + +test('should fire onclose assigned after start() when stdin was already dead', async () => { + // The synthetic hangup for a pre-dead stream must land after the caller's + // start()/connect() continuation — exactly like a real 'end' — otherwise an + // onclose assigned right after `await server.connect(transport)` never runs. + input.destroy(); + await new Promise(resolve => { + input.once('close', resolve); + }); + + const server = new StdioServerTransport(input, output); + server.onerror = error => { + throw error; + }; + + await server.start(); + + let closeCount = 0; + const closed = new Promise(resolve => { + server.onclose = () => { + closeCount++; + resolve(); + }; + }); + + await closed; + expect(closeCount).toBe(1); +}); + +test('should not fire onclose twice when close() is called after stdin ends', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = error => { + throw error; + }; + + let closeCount = 0; + const closed = new Promise(resolve => { + server.onclose = () => { + closeCount++; + resolve(); + }; + }); + + await server.start(); + input.push(null); // EOF fires 'end', and stream teardown may fire 'close' too + + await closed; + await server.close(); + // Allow any late 'close' event from the stream teardown to be delivered + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(closeCount).toBe(1); +}); + +test('should still deliver messages that arrived before stdin ended', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = error => { + throw error; + }; + + const messages: JSONRPCMessage[] = []; + const closed = new Promise(resolve => { + server.onclose = () => resolve(); + }); + server.onmessage = message => { + messages.push(message); + }; + + const message: JSONRPCMessage = { jsonrpc: '2.0', id: 1, method: 'ping' }; + input.push(serializeMessage(message)); + input.push(null); // EOF right behind the message + + await server.start(); + await closed; + + expect(messages).toEqual([message]); +}); + test('should reject send() when stdout errors before drain', async () => { let completeWrite: ((error?: Error | null) => void) | undefined; const slowOutput = new Writable({ @@ -156,7 +339,63 @@ test('should reject send() when stdout errors before drain', async () => { await expect(sendPromise).rejects.toThrow('write EPIPE'); expect(slowOutput.listenerCount('drain')).toBe(0); - expect(slowOutput.listenerCount('error')).toBe(0); + // send()'s per-send listeners are cleaned up; only the transport's own 'error' + // listener remains — the stdout error triggered close(), which keeps it + // attached (as a no-op) to swallow late write failures. + expect(slowOutput.listenerCount('error')).toBe(1); +}); + +test('should swallow stdout errors that surface after close (late EPIPE from a pending write)', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = error => { + throw error; + }; + + const closed = new Promise(resolve => { + server.onclose = () => resolve(); + }); + + await server.start(); + + // Fast-path send: write() returns true, so the per-send 'error' listener is + // detached immediately — but the OS-level write may still be pending. + await server.send({ jsonrpc: '2.0', id: 1, method: 'ping' }); + + // The client hangs up: stdin EOF closes the transport. + input.push(null); + await closed; + + // The pending write now fails (e.g. EPIPE because the client's read end is + // gone). If stdout had no 'error' listener left, this emit would throw + // ERR_UNHANDLED_ERROR — in a real process, an uncaught exception and exit 1. + expect(() => output.emit('error', new Error('write EPIPE'))).not.toThrow(); +}); + +test('repeated create→close cycles on shared streams should not accumulate stdout listeners', async () => { + // _stdin/_stdout default to the process-global process.stdin/process.stdout, so + // any listener a closed transport leaves behind would pile up across transport + // lifecycles in one process (MaxListenersExceededWarning after 11 cycles). Use + // fake shared streams to model that without touching the real process streams. + const sharedInput = new Readable({ read: () => {} }); + const sharedOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + } + }); + + for (let cycle = 0; cycle < 2; cycle++) { + const server = new StdioServerTransport(sharedInput, sharedOutput); + await server.start(); + await server.close(); + + // Exactly one 'error' listener stays behind after each cycle — the closed + // transport's swallow-only listener — and it is swept when the next + // transport starts, so the count never grows across cycles. + expect(sharedOutput.listenerCount('error')).toBe(1); + } + + // The leftover listener still swallows late write failures from pending writes. + expect(() => sharedOutput.emit('error', new Error('write EPIPE'))).not.toThrow(); }); test('should reject send() after transport is closed', async () => { diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 3f3fe4b39b..480a13f769 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2340,7 +2340,7 @@ export const REQUIREMENTS: Record = { 'client-auth:token-endpoint:https-guard': { source: 'https://modelcontextprotocol.io/specification/draft/basic/authorization#refresh-token-grant', behavior: - "The token-exchange and refresh paths refuse to send credentials to a non-https token endpoint (localhost / 127.0.0.1 / ::1 exempt) by throwing InsecureTokenEndpointError, and auth()'s refresh branch surfaces it instead of falling through to a fresh /authorize redirect.", + "The token-exchange and refresh paths refuse to send credentials to a non-https token endpoint (localhost / *.localhost / 127.0.0.1 / ::1 exempt) by throwing InsecureTokenEndpointError, and auth()'s refresh branch surfaces it instead of falling through to a fresh /authorize redirect.", transports: ['streamableHttp'], addedInSpecVersion: '2026-07-28', note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.' diff --git a/test/integration/test/__fixtures__/serverThatHangs.ts b/test/integration/test/__fixtures__/serverThatHangs.ts index dbaf198974..39b177e8f3 100644 --- a/test/integration/test/__fixtures__/serverThatHangs.ts +++ b/test/integration/test/__fixtures__/serverThatHangs.ts @@ -30,10 +30,14 @@ transport.onclose = () => { }; const doNotExitImmediately = async (signal: NodeJS.Signals) => { - await server.sendLoggingMessage({ - level: 'debug', - data: `received signal ${signal}` - }); + // The transport closes itself when the client hangs up stdin, so this send may + // reject — ignore that; this fixture intentionally keeps hanging regardless. + await server + .sendLoggingMessage({ + level: 'debug', + data: `received signal ${signal}` + }) + .catch(() => {}); // Clear keepalive but delay exit to simulate slow shutdown clearInterval(keepAlive); setInterval(() => {}, 30_000); diff --git a/test/integration/test/__fixtures__/serverWithKeepAlive.ts b/test/integration/test/__fixtures__/serverWithKeepAlive.ts new file mode 100644 index 0000000000..2abdb6f718 --- /dev/null +++ b/test/integration/test/__fixtures__/serverWithKeepAlive.ts @@ -0,0 +1,22 @@ +import { setInterval } from 'node:timers'; + +import { McpServer } from '@modelcontextprotocol/server'; +import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'; + +const transport = new StdioServerTransport(); + +const server = new McpServer({ + name: 'server-with-keep-alive', + version: '1.0.0' +}); + +await server.connect(transport); + +// Simulates a real server holding a keep-alive handle (connection pool, file +// watcher, heartbeat timer, ...). A well-behaved server releases its handles +// when the connection closes, and the process then exits naturally. +const keepAlive = setInterval(() => {}, 60_000); + +server.server.onclose = () => { + clearInterval(keepAlive); +}; diff --git a/test/integration/test/processCleanup.test.ts b/test/integration/test/processCleanup.test.ts index 3554d99361..87b7ee91a2 100644 --- a/test/integration/test/processCleanup.test.ts +++ b/test/integration/test/processCleanup.test.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process'; import path from 'node:path'; import { Readable, Writable } from 'node:stream'; @@ -77,6 +78,50 @@ describe('Process cleanup', () => { expect(onCloseWasCalled).toBe(1); }); + it('server process should exit on its own when the client closes stdin', async () => { + // Regression test for zombie stdio servers: when the client drops its end of + // the pipe (window closed, session restarted) without sending any signal, the + // server must notice stdin EOF, close its transport, and fire `onclose` so the + // server can release its keep-alive handles and the process exits naturally. + const child = spawn('node', ['--import', 'tsx', 'serverWithKeepAlive.ts'], { + cwd: FIXTURES_DIR, + stdio: ['pipe', 'pipe', 'inherit'] + }); + + const exited = new Promise(resolve => { + child.on('exit', code => resolve(code)); + }); + + // Confirm the server is up by completing an initialize round-trip over raw stdio. + const initialized = new Promise(resolve => { + child.stdout.on('data', () => resolve()); + }); + child.stdin.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'stdin-eof-test', version: '1.0.0' } + } + }) + '\n' + ); + await initialized; + + // Hang up: close our end of the pipe without any signal. + child.stdin.end(); + + // The server must exit on its own — no SIGTERM/SIGKILL involved. Bound the + // wait so a regression fails the test instead of leaking the child. + const exitCode = await Promise.race([exited, new Promise<'zombie'>(resolve => setTimeout(() => resolve('zombie'), 8000))]); + if (exitCode === 'zombie') { + child.kill('SIGKILL'); + } + expect(exitCode).toBe(0); + }); + it('should exit cleanly for a server that hangs', async () => { const client = new Client({ name: 'test-client',