Minor Changes
-
#2501
1480241Thanks @felixweinberger! - Export theProtocolbase 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. -
#2511
f60dff0Thanks @felixweinberger! -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. -
#2468
5db6e38Thanks @felixweinberger! - The response cache now stores results as JSON-serialized documents (serialize on write, parse on read) instead of live object graphs isolated withstructuredClone. 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. -
#2477
8e1d2e9Thanks @felixweinberger! - 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. -
#2513
f413763Thanks @felixweinberger! - 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').
- The 2026 wire schemas are the final revision exactly: no body
-
#2483
3f07a32Thanks @felixweinberger! - AddpreloadSchemas(), 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
-
#2402
a400259Thanks @felixweinberger! - 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). -
#2456
44797d7Thanks @felixweinberger! - Restore the v1 parse tolerance forCallToolResult.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. -
#2431
1b90c96Thanks @morluto! - Fix the CommonJSvalidators/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. -
#2405
f172626Thanks @mattzcarey! - 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. -
#2441
561c6d8Thanks @felixweinberger! - POSTs whoseContent-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 inspect
Content-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. -
#2384
ce2f65dThanks @felixweinberger! -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. -
#2469
9b41b56Thanks @felixweinberger! - The Streamable HTTP client transport no longer attaches a session ID to a POST containing aninitializerequest — 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. -
#2458
7c49b47Thanks @felixweinberger! - Construct the default Ajv validation engine lazily on first validation. Creating aClientorServerno longer pays the ajv + ajv-formats instantiation cost at startup when no JSON Schema validation ever runs. -
#2476
e0a0ab7Thanks @felixweinberger! - 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. -
#2564
faa7e2bThanks @felixweinberger! - 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. -
#2514
6fe1963Thanks @felixweinberger! - 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. -
#2455
cc70c5eThanks @felixweinberger! - Version negotiation no longer discards transport handlers the caller set beforeconnect(). 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. -
#2425
e8de519Thanks @Sehlani042! - Stop advertising validator provider classes from the root client/server type declarations. The provider classes remain available from the explicit validator subpaths. -
#2534
f130e1aThanks @felixweinberger! - 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). -
Updated dependencies [
a400259,44797d7,f172626,8e1d2e9,f413763]:- @modelcontextprotocol/core@2.0.0