Version Packages - #2808
Version Packages#2808github-actions[bot] wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Beyond the inline findings, I also checked two adjacent release-mechanics concerns and ruled them out: the codemod's V2_PACKAGE_VERSIONS map (packages/codemod/src/versions.ts) is inlined from the workspace manifests at build time, so it will emit ^3.0.0 for node/express consistently with the manifests in this commit rather than a stale range; and the fastify adapter's dependency-only changelog section is accurate, since packages/middleware/fastify/src has no body-parsing path that #2698 would have touched.
Extended reasoning...
This is a Changesets "Version Packages" commit touching only CHANGELOG.md, package.json version fields, and deleted .changeset files; no source changes. Three inline findings already cover the substantive questions (middleware major bump stranding the #2698 hardening, missing prose docs for the new body/batch caps, and the lost PR attribution on the saveTokens entry). The note above records two further candidates that were investigated and found consistent with the manifests, so a human confirming the 3.0.0 middleware bump does not need to re-check them.
2 verified lower-impact observations (convention, logging or cleanup points) were not posted.
Findings marked 🟡 are optional suggestions and need no follow-up push.
| "name": "@modelcontextprotocol/hono", | ||
| "private": false, | ||
| "version": "2.0.0", | ||
| "version": "3.0.0", |
There was a problem hiding this comment.
🔴 Publishing @ modelcontextprotocol/hono, node and express as 3.0.0 strands the #2698 DoS hardening (4 MiB body cap, 413 before parse, Host/Origin validation moved ahead of the JSON parser) behind a major: every existing consumer on the documented ^2.0.0 range keeps 2.0.0 forever under npm update, dependabot minor/patch policies or lockfile refresh, because 2.0.0's peer ^2.0.0 still admits server 2.1.0 so nothing forces the upgrade. Fix: publish the middleware packages at 2.0.1 (set onlyUpdatePeerDependentsWhenOutOfRange: true or ___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH / hand-edit the version and CHANGELOG heading) so the patch reaches ^2.0.0 consumers automatically. [also at: packages/middleware/express/package.json:4 - The four middleware adapters (@ modelcontextprotocol/express, hono, node, fastify) are published as 3.0.0 although their changelogs list only patch changes: Changesets major-bumps every package with a workspace:^ peerDependency on @ modelcontextprotocol/server whenever server gets a minor, so…]
Extended reasoning...
Mechanism: .changeset/config.json has no onlyUpdatePeerDependentsWhenOutOfRange, so Changesets major-bumps every package whose peerDependency (@ modelcontextprotocol/server: workspace:^ at packages/middleware/hono/package.json:54, express:56, node:55, fastify:54) receives a minor. The consumed changeset request-body-size-limit.md marked node/hono/express as patch, and their CHANGELOG sections (packages/middleware/hono/CHANGELOG.md:3-32) are headed 'Patch Changes'. Consequence distinct from the already-posted 'spurious major' finding: the payload this patch carries is a denial-of-service hardening — createMcpHonoApp's parseJsonBody (packages/middleware/hono/src/hono.ts:80-137) now stops at 4 MiB and returns 413, and toNodeHandler (packages/middleware/node/src/toNodeHandler.ts:130-157, 294-295) rejects oversized bodies before allocation.…
Verification: normal — triggered for every existing consumer that installed the published @ modelcontextprotocol/{hono,node,express} 2.0.0 on a caret range and updates via npm update/dependabot minor-patch policy/lockfile refresh (the standard case), once this commit is merged and published. Mechanism verified from the diff itself. packages/middleware/hono/package.json:4, express:4, node:3, fastify:4 all…
| 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. |
There was a problem hiding this comment.
🟡 (optional) Publishing 2.1.0 ships a new default 4 MiB request-body cap (413) and a 100-message batch cap (400/-32600) to every server on ^2.0.0 via a routine minor upgrade, with no prose documentation anywhere: docs/ contains no mention of 413, maxRequestBodySize, or the batch bound, so an operator whose clients post large tool-call payloads (base64 images/files) gets 413s with no documented knob. Fix: before merging this release, add a migration/guide entry for the new limits and the maxRequestBodySize option (and the batch cap), covering WebStandardStreamableHTTPServerTransport, createMcpHandler, toNodeHandler and createMcpHonoApp.
Extended reasoning...
- This commit is the release that turns commit 7b781ed (fix(server): read HTTP request bodies with a size limit and bound JSON-RPC batch length #2698) into a published
@ modelcontextprotocol/server@ 2.1.0; consumers declaring^2.0.0receive it on their next install. 2.git show --stat 7b781edshows it touched only src/test/changeset files — nodocs/**.grep -rln '413\|Payload Too Large' docs/andgrep -rn maxRequestBodySize docs/both return nothing, so the only description of the new behaviour is the CHANGELOG text at packages/server/CHANGELOG.md:44 (and the middleware CHANGELOGs). 3. Population and rate: any server behind the SDK transports (WebStandardStreamableHTTPServerTransport,createMcpHandler,toNodeHandler,createMcpHonoApp) whose clients send bodies over 4 MiB or batches over 100 messages — e.g. tools/call carrying inline image/blob content. On the base (2.0.0) those requests succeeded; after this release they are answered 413 / 400 before dispatch. 4. CLAUDE.md and REVIEW.md require that behavior changes have prose docs and thatdocs/**/*.mdnot contradict the implementation; the dismissing finders argued this belongs to fix(server): read HTTP request bodies with a size limit and bound JSON-RPC batch length #2698, but…
Verification: nit — trigger: an operator on @ modelcontextprotocol/server@^2.0.0 (or the node/hono middleware) whose clients POST bodies over 4 MiB to a WebStandardStreamableHTTPServerTransport / createMcpHandler / toNodeHandler path, which previously read req.json() unbounded and after this release answers 413 (packages/server/src/server/streamableHttp.ts per git show 7b781ed: `const body = await…
| 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 |
There was a problem hiding this comment.
🟡 (optional) The 2.1.0 client changelog entry for the saveTokens fix is published with a bare commit link and no PR number or author, while its source commit 3924de9 has subject fix(client/auth): propagate saveTokens errors after refresh (#2053) — so the GitHub release notes and CHANGELOG lose the link to PR #2053 and the contributor credit that every sibling entry carries. Fix: the entry should read like its siblings, i.e. [#2053](.../pull/2053) [3924de9](...) Thanks [@ SAY-5]! - ...; regenerate the changelog (with a working GITHUB_TOKEN so changelog-github's commit->PR lookup succeeds) or hand-edit the entry before merge.
Extended reasoning...
git show 3924de9shows the commit subject ends in(#2053)and authorSai Asish Y, so the PR association exists on GitHub;@ changesets/changelog-githubnormally resolves it via the GitHub API and emits[#NNNN] [sha] Thanks [@ user]!(as it did for every other entry in this release). 2. packages/client/CHANGELOG.md:35 and core-internal's copy emit only[3924de9](commit) - Let saveTokens failures..., which means the API lookup silently failed (rate limit / token) for this one commit and the generator fell back to commit-only output. 3. Consequence after merge: the published CHANGELOG and the GitHub Release body (built from the same text) have no PR link and no contributor attribution for a credential-loss fix; the finder dismissed as cosmetic but it is a verifiably wrong release artifact and a silent generator failure the release process should not ship. Remedy: regenerate with a token that resolves the commit, or edit the entry to[#2053](https://github.com/modelcontextprotocol/typescript-sdk/pull/2053) [3924de9`](...) Thanks @ SAY-5! -…
Verification: nit. Triggering condition: whenever this release commit is merged and published as-is (the changelog text is the shipped artifact). Mechanism verified: /home/claude/typescript-sdk/packages/client/CHANGELOG.md:30 reads - [3924de9](https://github.com/modelcontextprotocol/typescript-sdk/commit/3924de99df834302d89f5997a1b64ca268282284) - Let saveTokens` failures surface after a successful token…
This PR was opened by the Changesets release GitHub action. When you're ready to do a release, you can merge this and publish to npm yourself or setup this action to publish automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.
Releases
@modelcontextprotocol/client@2.1.0
Minor Changes
dcc0102Thanks @gbshankar! - Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client.OAuthClientProvider.dpop()returning aDpopSession(new, along withgenerateDpopKeyPair,accessTokenHash,isDpopNonceChallenge).auth()/exchangeAuthorization/refreshAuthorization/fetchTokenthen sign a DPoP proof into token requests (retrying once on an authorization-serveruse_dpop_noncechallenge, with client authentication re-applied per attempt), andStreamableHTTPClientTransport,SSEClientTransportandwithOAuthpresent atoken_type: "DPoP"access token asAuthorization: DPoP <token>plus a fresh per-request proof, retry a resource-serveruse_dpop_noncechallenge once, and pick up aDPoP-Noncedelivered on any response. Tokens the AS issued asBearerare still presented as Bearer.fetch(including a caller-suppliedfetch/eventSourceInit.fetch) with the newwithDpopFromProvider(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 minimalAuthProvider); theAuthProviderinterface itself is unchanged.auth()now recovers frominvalid_dpop_proofon refresh (e.g. a refresh token bound to a key that is no longer held) by discarding the tokens and re-authorizing, likeinvalid_grant.OAuthErrorCodegainsInvalidDpopProofandUseDpopNonce;extractWWWAuthenticateParamsrecognizes theDPoPchallenge scheme;OAuthMetadataSchemagainsdpop_signing_alg_values_supported.Patch Changes
#2726
6fa4227Thanks @LuckTerence! -SdkErrorandSdkHttpErroraccept standardErrorOptionsas an optional fourth constructor argument and forward it toError, so a wrapped error is reachable through the standardError.causechain. Version-negotiation probe failures (SdkErrorCode.EraNegotiationFailed) now use it: the underlyingTypeError: fetch failedand the DNS or socket error beneath it surface viaerror.cause, so pino, Sentry, andutil.inspectrenderENOTFOUND/ECONNREFUSED/ETIMEDOUTinstead of stopping at theSdkError([v2] classifyNetworkError passes { cause } into SdkError's data slot, so the underlying network error never reaches Error.cause #2657). The previouserror.data.causeslot is still populated for compatibility but is deprecated and slated for removal; readerror.causeinstead.#2654
03842cdThanks @pshah19! - Treat request id0as a real id. Two guards tested aRequestIdfor truthiness, so the legal JSON-RPC ids0and''were read as absent. Id0is 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 firstsampling/createMessage,elicitation/create, orroots/lista server sends.notifications/cancelledcarrying id0was ignored, and the in-flight handler ran to completion with itsAbortSignalnever fired.relatedRequestId: 0wrongly passed the debounce gate (for methods opted intodebouncedNotificationMethods). 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
3e90449Thanks @KKonstantinov! - Stop sendingnotifications/cancelledfor theinitializehandshake. The spec is explicit that a client MUST NOT attempt to cancel itsinitializerequest, but the outbound cancel path fired for any in-flight request: aborting theAbortSignalpassed toconnect(), 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
b654261Thanks @sanjibani! -StreamableHTTPClientTransportandSSEClientTransportnow give their transport-managed headers precedence over same-named entries inrequestInit.headers:AuthorizationwhenauthProvideryields a token,mcp-protocol-version, and (Streamable HTTP)mcp-session-id. Header names compare case-insensitively and everyHeadersInitform is covered (plain object, tuple array,Headersinstance). Previously the caller-supplied value won, so a staticAuthorizationplaceholder (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; aHeadersinstance or lowercase key produced a combinedBearer <fresh>, Bearer <stale>value instead. A configuredAuthorizationis still sent while the provider has no token, and other configured headers pass through unchanged. Closes One line change to enable fallback authentication #2208.#2581
5119ee7Thanks @hugosmoreira! - Preserve the exact OAuth resource indicator from protected resource metadata when building authorization and token requests. Previously a pathlessresourcesuch ashttps://example.comwas normalized tohttps://example.com/viaURL.href, which breaks authorization servers that require theresourceparameter to match the published value exactly (Microsoft Entra ID rejects it withAADSTS9010010). The exported OAuth helpers (startAuthorization,exchangeAuthorization,refreshAuthorization,fetchToken,executeTokenRequest) now also accept astringforresource;selectResourceURLstill returns aURL, and a provider'svalidateResourceURLresult is used unchanged. Fixes OAuth resource indicator from protected resource metadata is normalized with a trailing slash #1968.3924de9- LetsaveTokensfailures surface after a successful token refresh. Inauth(), onetrywrapped both
refreshAuthorization()and theprovider.saveTokens()that persists itsresult, and the
catchdeliberately swallows anything that is not anOAuthError— plusServerError— 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 tostartAuthorization()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
redirectToAuthorizationis typically a no-op, the fallthroughis silent and the client is left with stale tokens and no indication of why.
The
try/catchnow covers onlyrefreshAuthorization(). Persisting the result happensafter 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
ServerErroror anunknown error still falls through to a new authorization flow, a non-
ServerErrorOAuthErroris still rethrown, andInsecureTokenEndpointErroris still surfaced. TheSEP-2352
issuerstamp 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.warnnaming the cause: the in-place fallthrough inthe refresh block, and
auth()'s outer recovery forinvalid_grant,invalid_client,and
unauthorized_client, which discards stored credentials and retries. The second onematters 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 leavesbehind for the next call.
Consumers whose
OAuthClientProvider.saveTokenscan reject should note thatauth()maynow reject where it previously returned
'REDIRECT'— that rejection is the failure thatwas being discarded.
#2613
70de0c8Thanks @jwcarman! - Emit and validate theMcp-Nameheader for tasks requests per SEP-2663's Streamable HTTP binding: the client transport now mirrorsparams.taskIdintoMcp-Nameontasks/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 sharedMCP_NAME_HEADER_SOURCEtable.On the server,
createMcpHandlernow answers a modern (2026-07-28)tasks/get/tasks/update/tasks/cancelPOST that omitsMcp-Name, or whose header disagrees withparams.taskId, with400/-32020(HeaderMismatch) at thestandard-header-validationrung, the same treatmenttools/call/prompts/get/resources/readalready 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]:@modelcontextprotocol/core@2.1.0
Minor Changes
dcc0102Thanks @gbshankar! - Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client.OAuthClientProvider.dpop()returning aDpopSession(new, along withgenerateDpopKeyPair,accessTokenHash,isDpopNonceChallenge).auth()/exchangeAuthorization/refreshAuthorization/fetchTokenthen sign a DPoP proof into token requests (retrying once on an authorization-serveruse_dpop_noncechallenge, with client authentication re-applied per attempt), andStreamableHTTPClientTransport,SSEClientTransportandwithOAuthpresent atoken_type: "DPoP"access token asAuthorization: DPoP <token>plus a fresh per-request proof, retry a resource-serveruse_dpop_noncechallenge once, and pick up aDPoP-Noncedelivered on any response. Tokens the AS issued asBearerare still presented as Bearer.fetch(including a caller-suppliedfetch/eventSourceInit.fetch) with the newwithDpopFromProvider(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 minimalAuthProvider); theAuthProviderinterface itself is unchanged.auth()now recovers frominvalid_dpop_proofon refresh (e.g. a refresh token bound to a key that is no longer held) by discarding the tokens and re-authorizing, likeinvalid_grant.OAuthErrorCodegainsInvalidDpopProofandUseDpopNonce;extractWWWAuthenticateParamsrecognizes theDPoPchallenge scheme;OAuthMetadataSchemagainsdpop_signing_alg_values_supported.@modelcontextprotocol/codemod@2.1.0
Patch Changes
5ecc791Thanks @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|serversubpath anywhere in a file, so a server path stored as data (example text, a log message, a config value) misclassified a client-only project asboth— rewriting shared type imports to@modelcontextprotocol/serverand 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, dynamicimport('...')(including webpack magic comments),require('...')/require.resolve('...'), and thevi./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.@modelcontextprotocol/express@3.0.0
Patch Changes
#2698
7b781edThanks @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, andcreateMcpHonoApp's JSON pre-parse — now stops at4 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 Largebefore anything is parsed.toWebRequest(when it reads the Node stream itself) now rejects once the body exceeds thelimit with an error whose
nameis'RequestBodyTooLargeError'andstatusis413, andtoNodeHandleranswers that with413; hand-wired callers oftoWebRequestshould handle therejection or pass a pre-parsed body, and
isLegacyRequestreports such a request as non-legacyso the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer
batch is answered
400/-32600and none of it is dispatched.The limit is configurable with a new
maxRequestBodySizeoption (bytes, defaultDEFAULT_MAX_REQUEST_BODY_SIZE= 4 MiB, exported from@modelcontextprotocol/server) onWebStandardStreamableHTTPServerTransportOptions,CreateMcpHandlerOptions(forwarded to itsstateless legacy leg;
isLegacyRequestandlegacyStatelessFallbacktake the same option),CreateMcpHonoAppOptions, andToNodeHandlerOptions/ToWebRequestOptions(the adapter'sbound applies before the handler's, so raise both). The bounded reader is exported as
readRequestBodyfor adapter authors. Hosts that pre-parse the body and pass it asparsedBodyskip the SDK's read and its size limit entirely; the batch bound applies either way.createMcpHonoAppandcreateMcpExpressAppnow run their Host/Origin validation before theJSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is
answered
403rather than400, and its body is not read.Updated dependencies [
6fa4227,03842cd,3e90449,7b781ed,75dc7ea,70de0c8]:@modelcontextprotocol/fastify@3.0.0
Patch Changes
6fa4227,03842cd,3e90449,7b781ed,75dc7ea,70de0c8]:@modelcontextprotocol/hono@3.0.0
Patch Changes
#2698
7b781edThanks @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, andcreateMcpHonoApp's JSON pre-parse — now stops at4 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 Largebefore anything is parsed.toWebRequest(when it reads the Node stream itself) now rejects once the body exceeds thelimit with an error whose
nameis'RequestBodyTooLargeError'andstatusis413, andtoNodeHandleranswers that with413; hand-wired callers oftoWebRequestshould handle therejection or pass a pre-parsed body, and
isLegacyRequestreports such a request as non-legacyso the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer
batch is answered
400/-32600and none of it is dispatched.The limit is configurable with a new
maxRequestBodySizeoption (bytes, defaultDEFAULT_MAX_REQUEST_BODY_SIZE= 4 MiB, exported from@modelcontextprotocol/server) onWebStandardStreamableHTTPServerTransportOptions,CreateMcpHandlerOptions(forwarded to itsstateless legacy leg;
isLegacyRequestandlegacyStatelessFallbacktake the same option),CreateMcpHonoAppOptions, andToNodeHandlerOptions/ToWebRequestOptions(the adapter'sbound applies before the handler's, so raise both). The bounded reader is exported as
readRequestBodyfor adapter authors. Hosts that pre-parse the body and pass it asparsedBodyskip the SDK's read and its size limit entirely; the batch bound applies either way.createMcpHonoAppandcreateMcpExpressAppnow run their Host/Origin validation before theJSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is
answered
403rather than400, and its body is not read.Updated dependencies [
6fa4227,03842cd,3e90449,7b781ed,75dc7ea,70de0c8]:@modelcontextprotocol/node@3.0.0
Patch Changes
#2698
7b781edThanks @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, andcreateMcpHonoApp's JSON pre-parse — now stops at4 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 Largebefore anything is parsed.toWebRequest(when it reads the Node stream itself) now rejects once the body exceeds thelimit with an error whose
nameis'RequestBodyTooLargeError'andstatusis413, andtoNodeHandleranswers that with413; hand-wired callers oftoWebRequestshould handle therejection or pass a pre-parsed body, and
isLegacyRequestreports such a request as non-legacyso the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer
batch is answered
400/-32600and none of it is dispatched.The limit is configurable with a new
maxRequestBodySizeoption (bytes, defaultDEFAULT_MAX_REQUEST_BODY_SIZE= 4 MiB, exported from@modelcontextprotocol/server) onWebStandardStreamableHTTPServerTransportOptions,CreateMcpHandlerOptions(forwarded to itsstateless legacy leg;
isLegacyRequestandlegacyStatelessFallbacktake the same option),CreateMcpHonoAppOptions, andToNodeHandlerOptions/ToWebRequestOptions(the adapter'sbound applies before the handler's, so raise both). The bounded reader is exported as
readRequestBodyfor adapter authors. Hosts that pre-parse the body and pass it asparsedBodyskip the SDK's read and its size limit entirely; the batch bound applies either way.createMcpHonoAppandcreateMcpExpressAppnow run their Host/Origin validation before theJSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is
answered
403rather than400, and its body is not read.Updated dependencies [
6fa4227,03842cd,3e90449,7b781ed,75dc7ea,70de0c8]:@modelcontextprotocol/server@2.1.0
Patch Changes
#2726
6fa4227Thanks @LuckTerence! -SdkErrorandSdkHttpErroraccept standardErrorOptionsas an optional fourth constructor argument and forward it toError, so a wrapped error is reachable through the standardError.causechain. Version-negotiation probe failures (SdkErrorCode.EraNegotiationFailed) now use it: the underlyingTypeError: fetch failedand the DNS or socket error beneath it surface viaerror.cause, so pino, Sentry, andutil.inspectrenderENOTFOUND/ECONNREFUSED/ETIMEDOUTinstead of stopping at theSdkError([v2] classifyNetworkError passes { cause } into SdkError's data slot, so the underlying network error never reaches Error.cause #2657). The previouserror.data.causeslot is still populated for compatibility but is deprecated and slated for removal; readerror.causeinstead.#2654
03842cdThanks @pshah19! - Treat request id0as a real id. Two guards tested aRequestIdfor truthiness, so the legal JSON-RPC ids0and''were read as absent. Id0is 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 firstsampling/createMessage,elicitation/create, orroots/lista server sends.notifications/cancelledcarrying id0was ignored, and the in-flight handler ran to completion with itsAbortSignalnever fired.relatedRequestId: 0wrongly passed the debounce gate (for methods opted intodebouncedNotificationMethods). 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
3e90449Thanks @KKonstantinov! - Stop sendingnotifications/cancelledfor theinitializehandshake. The spec is explicit that a client MUST NOT attempt to cancel itsinitializerequest, but the outbound cancel path fired for any in-flight request: aborting theAbortSignalpassed toconnect(), 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
7b781edThanks @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, andcreateMcpHonoApp's JSON pre-parse — now stops at4 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 Largebefore anything is parsed.toWebRequest(when it reads the Node stream itself) now rejects once the body exceeds thelimit with an error whose
nameis'RequestBodyTooLargeError'andstatusis413, andtoNodeHandleranswers that with413; hand-wired callers oftoWebRequestshould handle therejection or pass a pre-parsed body, and
isLegacyRequestreports such a request as non-legacyso the modern handler answers it. JSON-RPC batch arrays are limited to 100 messages; a longer
batch is answered
400/-32600and none of it is dispatched.The limit is configurable with a new
maxRequestBodySizeoption (bytes, defaultDEFAULT_MAX_REQUEST_BODY_SIZE= 4 MiB, exported from@modelcontextprotocol/server) onWebStandardStreamableHTTPServerTransportOptions,CreateMcpHandlerOptions(forwarded to itsstateless legacy leg;
isLegacyRequestandlegacyStatelessFallbacktake the same option),CreateMcpHonoAppOptions, andToNodeHandlerOptions/ToWebRequestOptions(the adapter'sbound applies before the handler's, so raise both). The bounded reader is exported as
readRequestBodyfor adapter authors. Hosts that pre-parse the body and pass it asparsedBodyskip the SDK's read and its size limit entirely; the batch bound applies either way.createMcpHonoAppandcreateMcpExpressAppnow run their Host/Origin validation before theJSON body parser, so a request from a disallowed Host or Origin with an invalid JSON body is
answered
403rather than400, and its body is not read.#2590
75dc7eaThanks @davidpavlovschi! - Reject a modern (2026-07-28) POST that omits the requiredMCP-Protocol-Versionheader.createMcpHandleraccepted a request whose body carried a valid per-request_metaenvelope but whose
MCP-Protocol-Versionheader was absent: the request was classifiedmodern, 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 forMcp-Nameon the methods that mirrorparams.name/params.uri) but not forMCP-Protocol-Version.Such a request is now refused with
400 Bad Requestand JSON-RPC-32020(
HeaderMismatch), matching the shape the sibling missing-header cells already emit andechoing the request id — per the Streamable HTTP spec, which requires the header on every
POST and lists a missing required standard header as a
HeaderMismatchfailure. Thespec's allowance to treat a header-less request as
2025-03-26is available only to aserver 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 isunconditional.
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 thatalready answers a missing
Mcp-Method. Legacy-era traffic is untouched, notificationsare unaffected, body-less
GET/DELETEsession operations are method-routed beforeany 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
70de0c8Thanks @jwcarman! - Emit and validate theMcp-Nameheader for tasks requests per SEP-2663's Streamable HTTP binding: the client transport now mirrorsparams.taskIdintoMcp-Nameontasks/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 sharedMCP_NAME_HEADER_SOURCEtable.On the server,
createMcpHandlernow answers a modern (2026-07-28)tasks/get/tasks/update/tasks/cancelPOST that omitsMcp-Name, or whose header disagrees withparams.taskId, with400/-32020(HeaderMismatch) at thestandard-header-validationrung, the same treatmenttools/call/prompts/get/resources/readalready 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]:@modelcontextprotocol/server-legacy@2.1.0
Patch Changes
dcc0102]:@modelcontextprotocol/core-internal@2.0.1
Patch Changes
#2726
6fa4227Thanks @LuckTerence! -SdkErrorandSdkHttpErroraccept standardErrorOptionsas an optional fourth constructor argument and forward it toError, so a wrapped error is reachable through the standardError.causechain. Version-negotiation probe failures (SdkErrorCode.EraNegotiationFailed) now use it: the underlyingTypeError: fetch failedand the DNS or socket error beneath it surface viaerror.cause, so pino, Sentry, andutil.inspectrenderENOTFOUND/ECONNREFUSED/ETIMEDOUTinstead of stopping at theSdkError([v2] classifyNetworkError passes { cause } into SdkError's data slot, so the underlying network error never reaches Error.cause #2657). The previouserror.data.causeslot is still populated for compatibility but is deprecated and slated for removal; readerror.causeinstead.#2654
03842cdThanks @pshah19! - Treat request id0as a real id. Two guards tested aRequestIdfor truthiness, so the legal JSON-RPC ids0and''were read as absent. Id0is 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 firstsampling/createMessage,elicitation/create, orroots/lista server sends.notifications/cancelledcarrying id0was ignored, and the in-flight handler ran to completion with itsAbortSignalnever fired.relatedRequestId: 0wrongly passed the debounce gate (for methods opted intodebouncedNotificationMethods). 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
3e90449Thanks @KKonstantinov! - Stop sendingnotifications/cancelledfor theinitializehandshake. The spec is explicit that a client MUST NOT attempt to cancel itsinitializerequest, but the outbound cancel path fired for any in-flight request: aborting theAbortSignalpassed toconnect(), 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
75dc7eaThanks @davidpavlovschi! - Reject a modern (2026-07-28) POST that omits the requiredMCP-Protocol-Versionheader.createMcpHandleraccepted a request whose body carried a valid per-request_metaenvelope but whose
MCP-Protocol-Versionheader was absent: the request was classifiedmodern, 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 forMcp-Nameon the methods that mirrorparams.name/params.uri) but not forMCP-Protocol-Version.Such a request is now refused with
400 Bad Requestand JSON-RPC-32020(
HeaderMismatch), matching the shape the sibling missing-header cells already emit andechoing the request id — per the Streamable HTTP spec, which requires the header on every
POST and lists a missing required standard header as a
HeaderMismatchfailure. Thespec's allowance to treat a header-less request as
2025-03-26is available only to aserver 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 isunconditional.
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 thatalready answers a missing
Mcp-Method. Legacy-era traffic is untouched, notificationsare unaffected, body-less
GET/DELETEsession operations are method-routed beforeany 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
70de0c8Thanks @jwcarman! - Emit and validate theMcp-Nameheader for tasks requests per SEP-2663's Streamable HTTP binding: the client transport now mirrorsparams.taskIdintoMcp-Nameontasks/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 sharedMCP_NAME_HEADER_SOURCEtable.On the server,
createMcpHandlernow answers a modern (2026-07-28)tasks/get/tasks/update/tasks/cancelPOST that omitsMcp-Name, or whose header disagrees withparams.taskId, with400/-32020(HeaderMismatch) at thestandard-header-validationrung, the same treatmenttools/call/prompts/get/resources/readalready 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]: