@modelcontextprotocol/client
2.3.1 · 5 Oct 2026Apache-2.04.7M / weekRepositoryHomepagenpmAtom feed
5 stable releases, newest first, by release line. Notes are quoted as the maintainers published them.
2.3.1
5 Oct 2026requireBearerAuthin@modelcontextprotocol/server-legacytakes the optionalexpectedResourcethat@modelcontextprotocol/server2.3.0 added: it accepts only tokens issued for this server (the token's audience). Off unless you set it.The npm pages of
@modelcontextprotocol/serverand@modelcontextprotocol/clientnow open with one note that links the documentation, the migration guide and the issue form.
2.3.0
2 Oct 2026One server per request.
Server.connect()now rejects while the instance is already connected, and a stateless Streamable HTTP transport handles one request. Create theMcpServerand the transport inside the request handler (or in thecreateMcpHandlerfactory) instead of sharing one instance across requests. Creating a server is cheap since #2889.Redirects stay on the same origin. The HTTP client transports now follow a redirect only when it stays on the same origin (same scheme, host and port; http to https on the same host is allowed). A deployment whose endpoint redirects to another host or port either configures the final URL or sets
redirectPolicy: 'follow'on the transport. In browsers, a redirected request fails unless that option is set.New options, both off unless you set them.
maxToolInputElementsonMcpServerlimits the number of array elements and object members in a tool call's arguments.expectedResourceonrequireBearerAuth/verifyBearerTokenaccepts only tokens issued for this server (the token's audience); with Express, upgrade@modelcontextprotocol/expresstogether with@modelcontextprotocol/server. (#2926, #2929)
Package Version @modelcontextprotocol/client2.3.0 @modelcontextprotocol/server2.3.0 @modelcontextprotocol/core2.3.0 @modelcontextprotocol/server-legacy2.3.0 @modelcontextprotocol/codemod2.3.0 @modelcontextprotocol/node2.1.1 @modelcontextprotocol/express,hono2.0.2 @modelcontextprotocol/fastify2.0.1 Upgrade notes
One server per request.
Server.connect()now rejects while the instance is already connected, and a stateless Streamable HTTP transport handles one request. Create theMcpServerand the transport inside the request handler (or in thecreateMcpHandlerfactory) instead of sharing one instance across requests. Creating a server is cheap since #2889.Redirects stay on the same origin. The HTTP client transports now follow a redirect only when it stays on the same origin (same scheme, host and port; http to https on the same host is allowed). A deployment whose endpoint redirects to another host or port either configures the final URL or sets
redirectPolicy: 'follow'on the transport. In browsers, a redirected request fails unless that option is set.New options, both off unless you set them.
maxToolInputElementsonMcpServerlimits the number of array elements and object members in a tool call's arguments.expectedResourceonrequireBearerAuth/verifyBearerTokenaccepts only tokens issued for this server (the token's audience); with Express, upgrade@modelcontextprotocol/expresstogether with@modelcontextprotocol/server. (#2926, #2929)prompts/getwithoutargumentsis validated as{}, astools/callalready is. A top-level.optional()or.default(...)on a prompt'sargsSchemano longer seesundefined.The client requires
eventsource-parser3.0.8 or later. It cuts the time and memory needed to receive a large message sent as a single SSE event: in our test a 100 MB tool result went from about a minute and 1.7 GB of peak memory to under a second and about 0.5 GB.
New
validateOriginHeaderand theallowedOriginsoption of the Express, Fastify and Hono app helpers accept<scheme>://*entries such asmoz-extension://*, so a server can admit MCP clients that run as a browser extension.tasks/getandtasks/cancelof the Tasks extension can be served and called on a 2026-07-28 connection.
Fixes
A large message received as a single SSE event over Streamable HTTP is fast again: a 50 MB tool result that took about 13 seconds arrives in under a second.
prompts/getwithoutargumentsno longer fails when every argument of the prompt is optional.SSEClientTransportrefreshes once after a 401 on connect instead of retrying without limit. (#2905, #2934)registerToolno longer converts tool schemas up front, so a server built per request stops converting every tool on every request.honois a regular dependency of@modelcontextprotocol/node, so installs with strict peer-dependency checking no longer fail.A
server/discoverprobe answered with an unusable 2xx reply now says so instead of reading like a network failure.McpServer.registerPrompt()types the callback correctly when noargsSchemais given.The
licensefield of the package manifests isApache-2.0.
2.2.0
28 Sep 2026- deprecated
Pass
expectedIssuerto the machine-to-machine OAuth providers. ConstructingClientCredentialsProvider,PrivateKeyJwtProvider,StaticPrivateKeyJwtProviderorCrossAppAccessProviderwithout it is deprecated and logs a warning. Set it to theissuerof the authorization server the credentials were registered with. fetchToken()checks which authorization server the client information belongs to. It throwsAuthorizationServerMismatchErrorbefore sending anything when the provider's client information is bound to a different authorization server.List calls return the whole list.
listTools(),listPrompts(),listResources()andlistResourceTemplates()called without a cursor now follownextCursoruntil the server stops sending one.listMaxPagesstill caps the walk.
Package Version @modelcontextprotocol/client2.2.0 @modelcontextprotocol/server2.2.0 @modelcontextprotocol/core2.2.0 @modelcontextprotocol/server-legacy2.2.0 @modelcontextprotocol/codemod2.2.0 @modelcontextprotocol/node,express,hono,fastifyunchanged Upgrade notes
- deprecated
Pass
expectedIssuerto the machine-to-machine OAuth providers. ConstructingClientCredentialsProvider,PrivateKeyJwtProvider,StaticPrivateKeyJwtProviderorCrossAppAccessProviderwithout it is deprecated and logs a warning. Set it to theissuerof the authorization server the credentials were registered with. fetchToken()checks which authorization server the client information belongs to. It throwsAuthorizationServerMismatchErrorbefore sending anything when the provider's client information is bound to a different authorization server.List calls return the whole list.
listTools(),listPrompts(),listResources()andlistResourceTemplates()called without a cursor now follownextCursoruntil the server stops sending one.listMaxPagesstill caps the walk.
Fixes
CommonJS TypeScript projects type-check again: the
josetypes used by the DPoP API are inlined into the declaration files (regression in 2.1.0).Client.listen()no longer lets a rejection escape as a process-level unhandled rejection, and no longer hangs when a send never settles._metais preserved oninput_requiredresults.Hostnames ending in
.localhostcount as loopback for OAuth token endpoints, so host-based multi-tenant local setups work.OAuthTokensSchemaandOAuthClientInformationSchemaaccept the optionalissuerfield, so a provider that reads its storage back through them keeps it.createMcpHandlerno longer overflows the stack when the factory returns the same server instance for more than one request. Returning a fresh instance per request is still required.createMcpHandlerends asubscriptions/listenstream right after the acknowledgement when it honored none of the requested notification types.Sending a notification on a closed connection no longer produces a briefly unhandled promise rejection.
The
v1-to-v2codemod keeps a file's leading comment block and directives above the rewritten imports.Corrected citations in the JSDoc for OAuth token endpoints and for the
registerClientdeprecation notice. (#2768, #2729)
Per-package changelogs
client · server · core · server-legacy · codemod
2.1.0
23 Sep 2026- deprecated
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(#2657). The previouserror.data.causeslot is still populated for compatibility but is deprecated and slated for removal; readerror.causeinstead. Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client.
Treat request id
0as 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.Absent is now the only value that means "no id".
Minor Changes
Add DPoP (RFC 9449 / SEP-1932) sender-constrained access token support to the client.
Opt in by implementing
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.DPoP is applied at the fetch layer: the transports wrap their resource-server
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
- deprecated
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(#2657). The previouserror.data.causeslot is still populated for compatibility but is deprecated and slated for removal; readerror.causeinstead. Treat request id
0as 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.A notification sent with
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".
On Windows, stdio servers spawned by
StdioClientTransportnow also inheritCOMSPEC,PATHEXT,PROGRAMDATA,PROGRAMFILES(X86),PROGRAMW6432, andWINDIR(added toDEFAULT_INHERITED_ENV_VARS). Programs a server launches can depend on them: PowerShell finds no native executables withoutPATHEXT, and Windows OpenSSH exits 255 withoutProgramData.Stop sending
notifications/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.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 #2208.Preserve the exact OAuth resource indicator from protected resource metadata when building authorization and token requests. Previously a pathless
resourcesuch 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 #1968.Let
saveTokensfailures surface after a successful token refresh. Inauth(), onetry
wrapped bothrefreshAuthorization()and theprovider.saveTokens()that persists its
result, and thecatchdeliberately 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, andauth()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, whereredirectToAuthorizationis typically a no-op, the fallthrough
is silent and the client is left with stale tokens and no indication of why.The
try/catchnow covers onlyrefreshAuthorization(). 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
ServerErroror an
unknown error still falls through to a new authorization flow, a non-ServerError
OAuthErroris still rethrown, andInsecureTokenEndpointErroris still surfaced. The
SEP-2352issuerstamp written with the refreshed tokens is unchanged.Those fallbacks no longer happen in silence, though. Both routes to an unexplained
re-authorization now emit aconsole.warnnaming the cause: the in-place fallthrough in
the refresh block, andauth()'s outer recovery forinvalid_grant,invalid_client,
andunauthorized_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 asinvalid_grant, which is precisely the state a dropped token set leaves
behind for the next call.Consumers whose
OAuthClientProvider.saveTokenscan reject should note thatauth()may
now reject where it previously returned'REDIRECT'— that rejection is the failure that
was being discarded.Emit and validate the
Mcp-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.
2.0.0
27 Jul 2026Export the
Protocolbase class andmergeCapabilitiesfrom the@modelcontextprotocol/clientand@modelcontextprotocol/serverpackage roots, restoring the v1 import for consumers that subclassProtocol(e.g. the MCP Apps SDK). The client and server packages each bundle their own compiled copy of the class, so import it from one package consistently within a process.The codemod now rewrites
ProtocolandmergeCapabilitiesimports fromshared/protocol.jsto the client or server package root, like the module's other symbols, instead of dropping them with an action-required marker.ConnectOptions.prioraccepts a cached era verdict — the new exported typePriorDiscovery.{ kind: 'modern', discover }adopts a previously obtainedDiscoverResultwith zero round trips;{ kind: 'legacy' }skips theserver/discoverprobe and runs the plaininitializehandshake directly, for servers known out-of-band to be legacy — without pinning the client tomode: 'legacy': stop supplying the verdict andconnect()falls back to the configuredversionNegotiationmode (under'auto', it re-probes and rediscovers an upgraded server). Freshness is the supplying host's responsibility — a stale legacy verdict succeeds silently against an upgraded server, so hosts must date cached legacy verdicts in their own storage and stop supplying them past their policy horizon. Persisted-blob plumbing is hardened:prior: nullis treated as absent, the modern arm'sdiscoverpayload is schema-validated before any connection state changes, and an unrecognized shape rejects with a typedSdkError(EraNegotiationFailed)instead of aTypeError.The response cache now stores results as JSON-serialized documents (serialize on write, parse on read) instead of live object graphs isolated with
structuredClone. Same mutation isolation, but no dependency on thestructuredCloneglobal — whose absence (jest+jsdom, Node < 17) previously made every cache write throw into the store-error swallow, silently disabling caching and output-schema lookups for the session. A value without a JSON representation now fails the write loudly to the error sink, and an undecodable document in an external store is reported, dropped, and read as a miss.Migration for custom
ResponseCacheStoreimplementations:CacheEntry.value(and theset()entry value) is nowstring— persist and return it verbatim,JSON.parseto inspect. Entries persisted by a previous SDK version fail decode once (reported, dropped) and are rewritten on the next fetch.
Minor Changes
Export the
Protocolbase class andmergeCapabilitiesfrom the@modelcontextprotocol/clientand@modelcontextprotocol/serverpackage roots, restoring the v1 import for consumers that subclassProtocol(e.g. the MCP Apps SDK). The client and server packages each bundle their own compiled copy of the class, so import it from one package consistently within a process.The codemod now rewrites
ProtocolandmergeCapabilitiesimports fromshared/protocol.jsto the client or server package root, like the module's other symbols, instead of dropping them with an action-required marker.ConnectOptions.prioraccepts a cached era verdict — the new exported typePriorDiscovery.{ kind: 'modern', discover }adopts a previously obtainedDiscoverResultwith zero round trips;{ kind: 'legacy' }skips theserver/discoverprobe and runs the plaininitializehandshake directly, for servers known out-of-band to be legacy — without pinning the client tomode: 'legacy': stop supplying the verdict andconnect()falls back to the configuredversionNegotiationmode (under'auto', it re-probes and rediscovers an upgraded server). Freshness is the supplying host's responsibility — a stale legacy verdict succeeds silently against an upgraded server, so hosts must date cached legacy verdicts in their own storage and stop supplying them past their policy horizon. Persisted-blob plumbing is hardened:prior: nullis treated as absent, the modern arm'sdiscoverpayload is schema-validated before any connection state changes, and an unrecognized shape rejects with a typedSdkError(EraNegotiationFailed)instead of aTypeError.The response cache now stores results as JSON-serialized documents (serialize on write, parse on read) instead of live object graphs isolated with
structuredClone. Same mutation isolation, but no dependency on thestructuredCloneglobal — whose absence (jest+jsdom, Node < 17) previously made every cache write throw into the store-error swallow, silently disabling caching and output-schema lookups for the session. A value without a JSON representation now fails the write loudly to the error sink, and an undecodable document in an external store is reported, dropped, and read as a miss.Migration for custom
ResponseCacheStoreimplementations:CacheEntry.value(and theset()entry value) is nowstring— persist and return it verbatim,JSON.parseto inspect. Entries persisted by a previous SDK version fail decode once (reported, dropped) and are rewritten on the next fetch.Move the schema source modules (spec schemas, OAuth schemas, protocol constants) into
@modelcontextprotocol/coreand resolve them from there as a regular runtime dependency instead of bundling a private copy into each package. An application importing more than one of the packages now evaluates a single shared schema graph with shared object identity.@modelcontextprotocol/coregains a./internalsubpath (SDK-internal contract; may change in any release) and the four packages now version together.Align the 2026-07-28 wire with the final revision (spec PR #3002):
serverInfomoves from theDiscoverResultbody to the result_meta, and the per-request envelope'sclientInfodemotes from required to SHOULD.Before this change the SDK shipped the pre-#3002 shape in both directions: the client hard-rejected a conforming server's
DiscoverResult(missing bodyserverInfofailed parse, so the probe misclassified the server as legacy and attempted aninitializehandshake against it — a hard connect failure against a modern-only server such as go-sdk v1.7.0-pre.3), and the server rejected conforming clients that omitclientInfo.Now:
The 2026 wire schemas are the final revision exactly: no body
serverInfoonDiscoverResult, envelopeclientInfooptional (a present-but-malformed value still fails validation).Servers stamp
_meta['io.modelcontextprotocol/serverInfo']on every 2026-era response (spec SHOULD; a handler-authored value wins, the 2025-era wire is untouched). This includes the entry-builtsubscriptions/listengraceful-close results — the spec'sSubscriptionsListenResultMetaextendsResultMetaObject.Clients keep sending
clientInfoand read server identity from the discover result's_metaonly. A server that stamps no identity is anonymous:getServerVersion()isundefinedand the response cache partitions under a per-connection surrogate. A malformed_metaserverInfo value is treated as absent on receive (the spec marks the field self-reported, unverified, and display-only).Breaking type changes:
DiscoverResultno longer declaresserverInfo;RequestMetaEnvelope'sclientInfois optional. New public constantSERVER_INFO_META_KEY('io.modelcontextprotocol/serverInfo').
Add
preloadSchemas(), an explicit opt-in to eager wire-schema construction, and call it automatically in the Cloudflare Workers builds. The wire schemas are built lazily by default, which is the right trade on process-per-invocation runtimes — but on isolate platforms that bill request CPU while module evaluation runs during isolate warm-up, laziness moves construction into the first request each fresh isolate serves. CallingpreloadSchemas()at module scope (it is synchronous and idempotent) moves that one-time cost back to module evaluation; the packages' workerd export condition now does this automatically, while the Node and browser builds stay lazy. The server package gains a dedicated browser shim for this (itsbrowsercondition previously reused the workerd shim), so browser bundles keep lazy construction.
Patch Changes
First beta release of SDK v2 with support for the MCP 2026-07-28 specification
revision. See the migration guides for upgrading from v1
(docs/migration/upgrade-to-v2.md) and adopting the 2026-07-28 revision
(docs/migration/support-2026-07-28.md).Restore the v1 parse tolerance for
CallToolResult.content: an inbound legacy-eratools/callresult withoutcontentdefaults to `` instead of failing validation. Deployed servers — accepted by SDK v1 for years — returnstructuredContent-only (or otherwise content-less) results, and the strict parse turned every such call into anINVALID_RESULTerror before application code could run.The silent-empty-success hazard the strictness guarded is preserved where it matters: the 2025 era's wire-seam schema refuses to default
contentfor a body carrying another result family's vocabulary (task,inputRequests,requestState— the era is frozen, so the list is complete), and the 2026-era wire schemas stay strict — modern-revision servers have no legacy excuse. Task interop through an explicit result schema is untouched (including bodies that also stamp a foreignresultType), and the server-side authoring normalization refuses the same foreign-family vocabulary.Server-side authoring is era-independent: a handler result without
content(dynamic/JS callers — the TypeScript surface requires it) is normalized tocontent:before era validation on every leg, reaching the wire spec-valid.Conscious call: the nested sampling
ToolResultContentSchemastays spec-strict — v1 had defaulted itscontenttoo, but it is params-side (tool results a caller authors into a sampling message), deliberately not restored.Fix the CommonJS
validators/ajvsubpath so reading the exportedAjvclass no longer throwsReferenceError: import_ajv is not defined. The subpath now re-exports the bundled provider's concreteAjvvalue in CJS output, matching the existing ESM behavior.Ship CommonJS builds alongside ESM. Each package now emits both
.mjs/.d.mts
and.cjs/.d.cts(via tsdownformat: ['esm', 'cjs']), and itsexportsmap
adds arequirecondition sorequire('@modelcontextprotocol/…')works from
CommonJS consumers. Output extensions are normalized across all packages
(@modelcontextprotocol/coremoves from.js/.d.tsto.mjs/.d.mts); the
public import paths are unchanged.POSTs whose
Content-Typemedia type is notapplication/jsonare now
rejected with415 Unsupported Media Type; the header is parsed instead of
substring-matched. Previously any value merely containing the substring
passed the check (for exampletext/plain; a=application/json), case
variants were wrongly rejected, and the 2026-07-28 entry did not inspectContent-Typeat all — requests with a missing or non-JSON header that used
to be served on that path now also answer 415. Values with parameters
(application/json; charset=utf-8, including malformed parameter sections
likeapplication/json;) continue to work. SDK clients always send the
correct header and are unaffected.The new
isJsonContentType(header)helper is exported for transport and
framework-adapter authors — custom entries composing the exported building
blocks (classifyInboundRequest,PerRequestHTTPServerTransport) must apply
it themselves. The hono adapter's JSON body pre-parse and the client's
response dispatch now use the same parsed-media-type comparison.instanceofon the SDK error classes (ProtocolErrorand its typed subclasses,SdkError/SdkHttpError,OAuthError, and the client'sSseError,UnauthorizedError, and OAuth-client-flow error family —OAuthClientFlowErrorand its subclasses) now works across separately bundled copies of the SDK. The classes match by a stable brand (viaSymbol.hasInstanceand a registry symbol) instead of prototype identity, so a process that uses both@modelcontextprotocol/clientand@modelcontextprotocol/server- a gateway, host, or in-process test - can check errors constructed by either package against the class re-exported by the other. Ordinary prototype-basedinstanceofis preserved as a fallback; user-defined subclasses keep plain prototype semantics. Notes: cross-bundle matching requires both copies to be at or after this release; brands assert identity, not field shape, across versions - keep reading fields defensively. As a side effect, a foreign-bundleSdkErrorused as an abort reason is now rethrown as-is instead of being wrapped as aRequestTimeout. Branded hierarchies additionally expose an explicit static guard,X.isInstance(value), that reads the same brand and narrows in TypeScript — an alternative for codebases that prefer predicate-style checks overinstanceof. Also:UnauthorizedErrornow setserror.nameto'UnauthorizedError'(previously'Error'), and per-package conformance tests enforce that every exported error class participates in branding. Version-negotiation probing now recognizesUnauthorizedError(previously a dead name-string check) and propagates it unchanged, soconnect()on an auth-gated server rejects with the originalUnauthorizedError(previously wrapped as thecauseof anSdkError(EraNegotiationFailed)) — runfinishAuth()and reconnect, and the retry probes with the token.The Streamable HTTP client transport no longer attaches a session ID to a POST containing an
initializerequest — a new session starts "without a session ID attached" (2025-11-25 transports §Session Management) — and it only captures themcp-session-idresponse header from a successful initialize response, since the spec assigns the session ID "at initialization time … on the HTTP response containing the InitializeResult". Previously the transport stored the header from any response, so a legacy server answering a protocol-version probe with an error that happened to carry a session ID would poison the fallback initialize, which then went out with a session ID it should not have had. A stale session ID from a previous connection is likewise no longer leaked onto the initialize handshake, and a successful initialize response that carries no session ID now clears any stale ID the transport was holding — clients include only an ID "returned by the server during initialization", so an ID the server never returned this session is outside the session model. Ignoringmcp-session-idheaders mid-session is the complement of the spec's one actual rotation mechanism: a server that wants a new session terminates the old one (it "MAY terminate the session at any time") and answers 404, after which the client "MUST start a new session by sending a new InitializeRequest without a session ID attached". Rotation exists as session replacement via 404 + re-initialize, never as a header swap on a live session, so a server that rotates per the spec's own flow is handled correctly by this transport.Construct the default Ajv validation engine lazily on first validation. Creating a
ClientorServerno longer pays the ajv + ajv-formats instantiation cost at startup when no JSON Schema validation ever runs.Build protocol-revision wire schemas lazily on first validation instead of at import. Each revision's schema set is now constructed by a module-level memoized factory, so importing the client or server package no longer pays the construction cost of both frozen wire-schema graphs up front. Method membership in the revision registries stays static, the schemas themselves are unchanged, and registry lookups keep returning reference-identical schema objects.
The version-negotiation probe no longer misclassifies auth-protected or
failing servers as legacy. Auth status is never era evidence: a 401 or 403
rejection of theserver/discoverprobe now surfaces as a typed
authorization failure — anSdkHttpErrorwith codeClientHttpAuthentication
(401) orClientHttpForbidden(403), carrying the HTTP status, reason
phrase, and response text — instead of triggering the legacyinitialize
fallback (which put a doomedinitializeon the wire) or, underpinmode,
the false "server did not offer pinned protocol version" diagnostic. The
codes are deliberately notEraNegotiationFailed, so era-recovery flows
keyed on that code cannot persist a verdict for an unauthorized exchange. A
5xx rejecting the probe is a server failure and now also rejects typed
(SdkHttpError(EraNegotiationFailed)) instead of demoting a mid-deploy
modern server to legacy — the legacy fallback now fires only on the 4xx
shapes the spec licenses.With an
authProvider, a401(and a403insufficient_scopechallenge) runs the transport's auth flow first — a plain403rejects the same as without a provider — and whatever
escapes it propagates unchanged, identity intact: the HTTP transports stamp
errors at their auth seams (thetoken()read,onUnauthorizedincluding
custom callbacks, the 403 step-up flow, and their own auth-failure
constructions), soUnauthorizedErrorforfinishAuth(), the flow's typed
failures (OAuthError,InsufficientScopeError, the
401-after-re-authentication diagnostic), and even an untypedTypeError
thrown inside the flow all reach the caller as thrown — never rewrapped,
never consumed by the probe's browser CORS heuristic as legacy-era evidence.Probe stdio servers on a disposable sibling process. Some stdio servers exit on any pre-
initializerequest (servers built on the official Rust SDK, rmcp, behave this way), so underversionNegotiation: { mode: 'auto' }theserver/discoverprobe previously killed the server andconnect()hard-failed. The probe now runs on a short-lived sibling spawned from the same parameters — its stderr is discarded and it is reaped once the era is known — and the caller's transport spawns exactly once, afterwards: a legacy verdict connects with the plaininitializehandshake (byte-identical tomode: 'legacy'), a modern verdict is adopted directly, and the session wire never carriesserver/discover. Closing the caller's transport during the probe abortsconnect()with the typedSdkError(EraNegotiationFailed)and the session child is never spawned. On HTTP — and on custom stdio-shaped transports, which probe in place — a mid-probe connection close keeps rejecting with the typed connect error, now naming the close in pin-mode and modern-only diagnostics.Version negotiation no longer discards transport handlers the caller set before
connect(). The probe window now saves any pre-setonmessage/onerror/onclose, forwards error and close events to them while the probe is in flight, and restores them when the window closes — soProtocol.connect()chains them exactly as it does on a plain connect. Previously, connecting withversionNegotiationsilently cleared pre-set handlers (e.g. anonerrorused to detect session-expiry auth failures), leaving them permanently detached for the life of the connection.Stop advertising validator provider classes from the root client/server type declarations. The provider classes remain available from the explicit validator subpaths.
The default validator now honors declared 2019-09 and draft-07/06 dialects instead of rejecting them: a schema stamped
"$schema": "http://json-schema.org/draft-07/schema#"(zod-to-json-schema's default output) validates with draft-07 semantics, and a 2019-09 stamp (zod-to-json-schema's2019-09/openAitargets) with 2019-09 semantics, on both the Ajv and Cloudflare Workers providers (with known engine differences documented in the migration guide). Schemas with no$schemastill validate as 2020-12, and unknown dialects still produce the typed error (now listing the supported dialects: 2020-12, 2019-09, draft-07, draft-06).