diff --git a/.changeset/beta-release.md b/.changeset/beta-release.md
deleted file mode 100644
index 26c711a910..0000000000
--- a/.changeset/beta-release.md
+++ /dev/null
@@ -1,16 +0,0 @@
----
-'@modelcontextprotocol/server': patch
-'@modelcontextprotocol/client': patch
-'@modelcontextprotocol/core': patch
-'@modelcontextprotocol/server-legacy': patch
-'@modelcontextprotocol/codemod': patch
-'@modelcontextprotocol/express': patch
-'@modelcontextprotocol/hono': patch
-'@modelcontextprotocol/fastify': patch
-'@modelcontextprotocol/node': patch
----
-
-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`).
diff --git a/.changeset/calltoolresult-content-default.md b/.changeset/calltoolresult-content-default.md
deleted file mode 100644
index 9d0daeebbf..0000000000
--- a/.changeset/calltoolresult-content-default.md
+++ /dev/null
@@ -1,14 +0,0 @@
----
-'@modelcontextprotocol/core-internal': patch
-'@modelcontextprotocol/client': patch
-'@modelcontextprotocol/core': patch
-'@modelcontextprotocol/server': patch
----
-
-Restore the v1 parse tolerance for `CallToolResult.content`: an inbound legacy-era `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error 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 `content` for 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 foreign `resultType`), 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 to `content: []` before era validation on every leg, reaching the wire spec-valid.
-
-Conscious call: the nested sampling `ToolResultContentSchema` stays spec-strict — v1 had defaulted its `content` too, but it is params-side (tool results a caller authors into a sampling message), deliberately not restored.
diff --git a/.changeset/cjs-ajv-validator-subpath.md b/.changeset/cjs-ajv-validator-subpath.md
deleted file mode 100644
index ac4c48c0b6..0000000000
--- a/.changeset/cjs-ajv-validator-subpath.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@modelcontextprotocol/server': patch
-'@modelcontextprotocol/client': patch
----
-
-Fix the CommonJS `validators/ajv` subpath so reading the exported `Ajv` class no longer throws `ReferenceError: import_ajv is not defined`. The subpath now re-exports the bundled provider's concrete `Ajv` value in CJS output, matching the existing ESM behavior.
diff --git a/.changeset/cjs-support-v2-packages.md b/.changeset/cjs-support-v2-packages.md
deleted file mode 100644
index 3541e0122b..0000000000
--- a/.changeset/cjs-support-v2-packages.md
+++ /dev/null
@@ -1,18 +0,0 @@
----
-'@modelcontextprotocol/server': patch
-'@modelcontextprotocol/client': patch
-'@modelcontextprotocol/core': patch
-'@modelcontextprotocol/server-legacy': patch
-'@modelcontextprotocol/codemod': patch
-'@modelcontextprotocol/express': patch
-'@modelcontextprotocol/hono': patch
-'@modelcontextprotocol/fastify': patch
-'@modelcontextprotocol/node': patch
----
-
-Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
-and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
-adds a `require` condition so `require('@modelcontextprotocol/…')` works from
-CommonJS consumers. Output extensions are normalized across all packages
-(`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
-public import paths are unchanged.
diff --git a/.changeset/codemod-fixed-group.md b/.changeset/codemod-fixed-group.md
deleted file mode 100644
index ede27ed7ee..0000000000
--- a/.changeset/codemod-fixed-group.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/codemod': patch
----
-
-Version the codemod together with the core SDK packages, matching the migration guide's shared-version guarantee.
diff --git a/.changeset/codemod-iterations-5.md b/.changeset/codemod-iterations-5.md
deleted file mode 100644
index fe6f5e8499..0000000000
--- a/.changeset/codemod-iterations-5.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/codemod': patch
----
-
-v1-to-v2 migration fixes from continued real-world migrations (codemod iterations 5).
diff --git a/.changeset/codemod-versions-from-manifests.md b/.changeset/codemod-versions-from-manifests.md
deleted file mode 100644
index b0383703ad..0000000000
--- a/.changeset/codemod-versions-from-manifests.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/codemod': patch
----
-
-Read the v2 package versions the codemod writes into migrated `package.json` files directly from the workspace manifests at build time, replacing the committed generated `versions.ts` (which went stale after every release and made source builds write outdated versions).
diff --git a/.changeset/content-type-media-type-validation.md b/.changeset/content-type-media-type-validation.md
deleted file mode 100644
index cdef983ece..0000000000
--- a/.changeset/content-type-media-type-validation.md
+++ /dev/null
@@ -1,23 +0,0 @@
----
-'@modelcontextprotocol/server': patch
-'@modelcontextprotocol/client': patch
-'@modelcontextprotocol/hono': patch
-'@modelcontextprotocol/node': patch
----
-
-POSTs whose `Content-Type` media type is not `application/json` are now
-rejected with `415 Unsupported Media Type`; the header is parsed instead of
-substring-matched. Previously any value merely containing the substring
-passed the check (for example `text/plain; a=application/json`), case
-variants were wrongly rejected, and the 2026-07-28 entry did not inspect
-`Content-Type` at 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
-like `application/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.
diff --git a/.changeset/cross-bundle-error-instanceof.md b/.changeset/cross-bundle-error-instanceof.md
deleted file mode 100644
index 940ca66efc..0000000000
--- a/.changeset/cross-bundle-error-instanceof.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@modelcontextprotocol/client': patch
-'@modelcontextprotocol/server': patch
----
-
-`instanceof` on the SDK error classes (`ProtocolError` and its typed subclasses, `SdkError`/`SdkHttpError`, `OAuthError`, and the client's `SseError`, `UnauthorizedError`, and OAuth-client-flow error family — `OAuthClientFlowError` and its subclasses) now works across separately bundled copies of the SDK. The classes match by a stable brand (via `Symbol.hasInstance` and a registry symbol) instead of prototype identity, so a process that uses both `@modelcontextprotocol/client` and `@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-based `instanceof` is 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-bundle `SdkError` used as an abort reason is now rethrown as-is instead of being wrapped as a `RequestTimeout`. 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 over `instanceof`. Also: `UnauthorizedError` now sets `error.name` to `'UnauthorizedError'` (previously `'Error'`), and per-package conformance tests enforce that every exported error class participates in branding. Version-negotiation probing now recognizes `UnauthorizedError` (previously a dead name-string check) and propagates it unchanged, so `connect()` on an auth-gated server rejects with the original `UnauthorizedError` (previously wrapped as the `cause` of an `SdkError(EraNegotiationFailed)`) — run `finishAuth()` and reconnect, and the retry probes with the token.
diff --git a/.changeset/examples-protected-wiring.md b/.changeset/examples-protected-wiring.md
deleted file mode 100644
index dce27b8666..0000000000
--- a/.changeset/examples-protected-wiring.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/node': patch
----
-
-Document composing the host and origin validation guards in front of `toNodeHandler` for hand-wired `node:http` servers, matching the protected wiring the examples and serving guide now demonstrate.
diff --git a/.changeset/export-protocol-class.md b/.changeset/export-protocol-class.md
deleted file mode 100644
index 0cb11195b0..0000000000
--- a/.changeset/export-protocol-class.md
+++ /dev/null
@@ -1,10 +0,0 @@
----
-'@modelcontextprotocol/core-internal': minor
-'@modelcontextprotocol/client': minor
-'@modelcontextprotocol/server': minor
-'@modelcontextprotocol/codemod': patch
----
-
-Export the `Protocol` base class and `mergeCapabilities` from the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` package roots, restoring the v1 import for consumers that subclass `Protocol` (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 `Protocol` and `mergeCapabilities` imports from `shared/protocol.js` to the client or server package root, like the module's other symbols, instead of dropping them with an action-required marker.
diff --git a/.changeset/initialize-session-hygiene.md b/.changeset/initialize-session-hygiene.md
deleted file mode 100644
index b12e3c514f..0000000000
--- a/.changeset/initialize-session-hygiene.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/client': patch
----
-
-The Streamable HTTP client transport no longer attaches a session ID to a POST containing an `initialize` request — a new session starts "without a session ID attached" (2025-11-25 transports §Session Management) — and it only captures the `mcp-session-id` response 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. Ignoring `mcp-session-id` headers 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.
diff --git a/.changeset/lazy-ajv-engine.md b/.changeset/lazy-ajv-engine.md
deleted file mode 100644
index 12ce0852ba..0000000000
--- a/.changeset/lazy-ajv-engine.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@modelcontextprotocol/client': patch
-'@modelcontextprotocol/server': patch
----
-
-Construct the default Ajv validation engine lazily on first validation. Creating a `Client` or `Server` no longer pays the ajv + ajv-formats instantiation cost at startup when no JSON Schema validation ever runs.
diff --git a/.changeset/lazy-era-wire-schemas.md b/.changeset/lazy-era-wire-schemas.md
deleted file mode 100644
index b49b5ea659..0000000000
--- a/.changeset/lazy-era-wire-schemas.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-'@modelcontextprotocol/client': patch
-'@modelcontextprotocol/server': patch
-'@modelcontextprotocol/server-legacy': patch
----
-
-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.
diff --git a/.changeset/malformed-resource-uri-invalid-params.md b/.changeset/malformed-resource-uri-invalid-params.md
deleted file mode 100644
index 565de8bad2..0000000000
--- a/.changeset/malformed-resource-uri-invalid-params.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/server': patch
----
-
-Return JSON-RPC Invalid Params with the original URI and an `invalid_uri` reason when `resources/read` receives a syntactically malformed URI.
diff --git a/.changeset/post-dispatch-32021-http-400.md b/.changeset/post-dispatch-32021-http-400.md
deleted file mode 100644
index 5f51fe0d62..0000000000
--- a/.changeset/post-dispatch-32021-http-400.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@modelcontextprotocol/server': patch
-'@modelcontextprotocol/core-internal': patch
----
-
-Return HTTP 400 for a `MissingRequiredClientCapabilityError` (`-32021`) produced after dispatch. The spec mandates `400 Bad Request` for this error with no condition on where it arose, but only the pre-dispatch capability gate honored that; the post-handler emission — the `input_required` gate rejecting an embedded request whose required capability the caller did not declare — surfaced in-band on HTTP 200. The JSON-RPC error body is unchanged, every other error code (including a handler relaying a downstream peer's `-32020`/`-32022`) keeps the origin-keyed in-band behavior, and the mapping only applies while the response is uncommitted: an exchange that already streamed — or one hosted with `responseMode: 'sse'`, which opens its stream at dispatch end — keeps its committed 200 and carries the error in-stream.
diff --git a/.changeset/pre.json b/.changeset/pre.json
deleted file mode 100644
index 7e6e1951e2..0000000000
--- a/.changeset/pre.json
+++ /dev/null
@@ -1,57 +0,0 @@
-{
- "mode": "pre",
- "tag": "beta",
- "initialVersions": {
- "@modelcontextprotocol/eslint-config": "2.0.0",
- "@modelcontextprotocol/tsconfig": "2.0.0",
- "@modelcontextprotocol/vitest-config": "2.0.0",
- "@modelcontextprotocol/examples": "2.0.0-alpha.0",
- "@mcp-examples/client-quickstart": "2.0.0-alpha.0",
- "@mcp-examples/server-quickstart": "2.0.0-alpha.0",
- "@mcp-examples/shared": "2.0.0-alpha.0",
- "@modelcontextprotocol/client": "2.0.0-alpha.4",
- "@modelcontextprotocol/codemod": "2.0.0-alpha.2",
- "@modelcontextprotocol/core": "2.0.0-alpha.2",
- "@modelcontextprotocol/core-internal": "2.0.0-alpha.3",
- "@modelcontextprotocol/express": "2.0.0-alpha.4",
- "@modelcontextprotocol/fastify": "2.0.0-alpha.4",
- "@modelcontextprotocol/hono": "2.0.0-alpha.4",
- "@modelcontextprotocol/node": "2.0.0-alpha.4",
- "@modelcontextprotocol/server": "2.0.0-alpha.4",
- "@modelcontextprotocol/server-legacy": "2.0.0-alpha.4",
- "@modelcontextprotocol/test-conformance": "2.0.0-alpha.1",
- "@modelcontextprotocol/test-e2e": "2.0.0-alpha.1",
- "@modelcontextprotocol/test-helpers": "2.0.0-alpha.0",
- "@modelcontextprotocol/test-integration": "2.0.0-alpha.1"
- },
- "changesets": [
- "beta-release",
- "calltoolresult-content-default",
- "cjs-ajv-validator-subpath",
- "cjs-support-v2-packages",
- "codemod-fixed-group",
- "codemod-iterations-5",
- "codemod-versions-from-manifests",
- "content-type-media-type-validation",
- "cross-bundle-error-instanceof",
- "examples-protected-wiring",
- "export-protocol-class",
- "initialize-session-hygiene",
- "lazy-ajv-engine",
- "lazy-era-wire-schemas",
- "malformed-resource-uri-invalid-params",
- "post-dispatch-32021-http-400",
- "prior-legacy-verdict",
- "probe-close-handling",
- "probe-window-handler-restore",
- "response-cache-document-codec",
- "schemas-source-home",
- "silent-validators-wave",
- "spec-3002-servinfo-meta",
- "standard-header-ows",
- "standard-schema-elicitation",
- "web-standard-bearer-auth",
- "web-standard-oauth-metadata",
- "workerd-schema-preload"
- ]
-}
diff --git a/.changeset/prior-legacy-verdict.md b/.changeset/prior-legacy-verdict.md
deleted file mode 100644
index 0a1b7b0f73..0000000000
--- a/.changeset/prior-legacy-verdict.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/client': minor
----
-
-`ConnectOptions.prior` accepts a cached era verdict — the new exported type `PriorDiscovery`. `{ kind: 'modern', discover }` adopts a previously obtained `DiscoverResult` with zero round trips; `{ kind: 'legacy' }` skips the `server/discover` probe and runs the plain `initialize` handshake directly, for servers known out-of-band to be legacy — without pinning the client to `mode: 'legacy'`: stop supplying the verdict and `connect()` falls back to the configured `versionNegotiation` mode (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: null` is treated as absent, the modern arm's `discover` payload is schema-validated before any connection state changes, and an unrecognized shape rejects with a typed `SdkError(EraNegotiationFailed)` instead of a `TypeError`.
diff --git a/.changeset/probe-close-handling.md b/.changeset/probe-close-handling.md
deleted file mode 100644
index 5bf48d3f73..0000000000
--- a/.changeset/probe-close-handling.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/client': patch
----
-
-Probe stdio servers on a disposable sibling process. Some stdio servers exit on any pre-`initialize` request (servers built on the official Rust SDK, rmcp, behave this way), so under `versionNegotiation: { mode: 'auto' }` the `server/discover` probe previously killed the server and `connect()` 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 plain `initialize` handshake (byte-identical to `mode: 'legacy'`), a modern verdict is adopted directly, and the session wire never carries `server/discover`. Closing the caller's transport during the probe aborts `connect()` with the typed `SdkError(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.
diff --git a/.changeset/probe-window-handler-restore.md b/.changeset/probe-window-handler-restore.md
deleted file mode 100644
index 6256ba05e6..0000000000
--- a/.changeset/probe-window-handler-restore.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/client': patch
----
-
-Version negotiation no longer discards transport handlers the caller set before `connect()`. The probe window now saves any pre-set `onmessage`/`onerror`/`onclose`, forwards error and close events to them while the probe is in flight, and restores them when the window closes — so `Protocol.connect()` chains them exactly as it does on a plain connect. Previously, connecting with `versionNegotiation` silently cleared pre-set handlers (e.g. an `onerror` used to detect session-expiry auth failures), leaving them permanently detached for the life of the connection.
diff --git a/.changeset/response-cache-document-codec.md b/.changeset/response-cache-document-codec.md
deleted file mode 100644
index 3584eaa04d..0000000000
--- a/.changeset/response-cache-document-codec.md
+++ /dev/null
@@ -1,7 +0,0 @@
----
-'@modelcontextprotocol/client': minor
----
-
-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 the `structuredClone` global — 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 `ResponseCacheStore` implementations: `CacheEntry.value` (and the `set()` entry value) is now `string` — persist and return it verbatim, `JSON.parse` to inspect. Entries persisted by a previous SDK version fail decode once (reported, dropped) and are rewritten on the next fetch.
diff --git a/.changeset/schemas-source-home.md b/.changeset/schemas-source-home.md
deleted file mode 100644
index d08753f8ec..0000000000
--- a/.changeset/schemas-source-home.md
+++ /dev/null
@@ -1,8 +0,0 @@
----
-'@modelcontextprotocol/core': minor
-'@modelcontextprotocol/client': minor
-'@modelcontextprotocol/server': minor
-'@modelcontextprotocol/server-legacy': minor
----
-
-Move the schema source modules (spec schemas, OAuth schemas, protocol constants) into `@modelcontextprotocol/core` and 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/core` gains a `./internal` subpath (SDK-internal contract; may change in any release) and the four packages now version together.
diff --git a/.changeset/silent-validators-wave.md b/.changeset/silent-validators-wave.md
deleted file mode 100644
index 7394c4c1ea..0000000000
--- a/.changeset/silent-validators-wave.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@modelcontextprotocol/client': patch
-'@modelcontextprotocol/server': patch
----
-
-Stop advertising validator provider classes from the root client/server type declarations. The provider classes remain available from the explicit validator subpaths.
diff --git a/.changeset/spec-3002-servinfo-meta.md b/.changeset/spec-3002-servinfo-meta.md
deleted file mode 100644
index b4eba489b4..0000000000
--- a/.changeset/spec-3002-servinfo-meta.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@modelcontextprotocol/core-internal': minor
-'@modelcontextprotocol/core': minor
-'@modelcontextprotocol/client': minor
-'@modelcontextprotocol/server': minor
----
-
-Align the 2026-07-28 wire with the final revision (spec PR #3002): `serverInfo` moves from the `DiscoverResult` body to the result `_meta`, and the per-request envelope's `clientInfo` demotes 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 body `serverInfo` failed parse, so the probe misclassified the server as legacy and attempted an `initialize` handshake 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 omit `clientInfo`.
-
-Now:
-
-- The 2026 wire schemas are the final revision exactly: no body `serverInfo` on `DiscoverResult`, envelope `clientInfo` optional (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-built `subscriptions/listen` graceful-close results — the spec's `SubscriptionsListenResultMeta` extends `ResultMetaObject`.
-- Clients keep sending `clientInfo` and read server identity from the discover result's `_meta` only. A server that stamps no identity is anonymous: `getServerVersion()` is `undefined` and the response cache partitions under a per-connection surrogate. A malformed `_meta` serverInfo value is treated as absent on receive (the spec marks the field self-reported, unverified, and display-only).
-- Breaking type changes: `DiscoverResult` no longer declares `serverInfo`; `RequestMetaEnvelope`'s `clientInfo` is optional. New public constant `SERVER_INFO_META_KEY` (`'io.modelcontextprotocol/serverInfo'`).
diff --git a/.changeset/standard-header-ows.md b/.changeset/standard-header-ows.md
deleted file mode 100644
index 4426652246..0000000000
--- a/.changeset/standard-header-ows.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@modelcontextprotocol/server': patch
-'@modelcontextprotocol/core-internal': patch
----
-
-Strip RFC 9110 optional whitespace around inbound `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` values before classifying and validating modern HTTP requests. This keeps valid requests portable across Fetch runtimes that expose raw leading or trailing SP/HTAB through `Headers.get()`.
diff --git a/.changeset/standard-schema-elicitation.md b/.changeset/standard-schema-elicitation.md
deleted file mode 100644
index bf584e5569..0000000000
--- a/.changeset/standard-schema-elicitation.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@modelcontextprotocol/core-internal': minor
-'@modelcontextprotocol/server': minor
----
-
-Allow `inputRequired.elicit()` to accept a Standard Schema such as a Zod object for `requestedSchema`. The builder converts it to MCP's restricted form-elicitation JSON Schema, while the same schema can validate and type the response through `acceptedContent()` on handler re-entry. Zod formats mapping to `email`, `uri`, `date`, and `date-time` are supported. Shapes the restricted schema cannot express reject before anything is sent — nested objects, `.regex()` and customized zod format patterns, exclusive number bounds (`.positive()`/`.gt()`), literal unions (use `z.enum` or `z.literal(['a', 'b'])`), and non-spec root keywords like `z.strictObject()`'s `additionalProperties`.
diff --git a/.changeset/streamable-http-sse-keepalive.md b/.changeset/streamable-http-sse-keepalive.md
deleted file mode 100644
index 705fa5f8f4..0000000000
--- a/.changeset/streamable-http-sse-keepalive.md
+++ /dev/null
@@ -1,5 +0,0 @@
----
-'@modelcontextprotocol/server': minor
----
-
-Add configurable SSE keep-alive comment frames to Streamable HTTP transports and apply `createMcpHandler`'s existing `keepAliveMs` option to every HTTP SSE stream it serves.
diff --git a/.changeset/validator-dialect-dispatch.md b/.changeset/validator-dialect-dispatch.md
deleted file mode 100644
index e715eb4f49..0000000000
--- a/.changeset/validator-dialect-dispatch.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@modelcontextprotocol/server': patch
-'@modelcontextprotocol/client': patch
----
-
-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's `2019-09`/`openAi` targets) with 2019-09 semantics, on both the Ajv and Cloudflare Workers providers (with known engine differences documented in the migration guide). Schemas with no `$schema` still 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).
diff --git a/.changeset/web-standard-bearer-auth.md b/.changeset/web-standard-bearer-auth.md
deleted file mode 100644
index b6bd738d4b..0000000000
--- a/.changeset/web-standard-bearer-auth.md
+++ /dev/null
@@ -1,15 +0,0 @@
----
-'@modelcontextprotocol/server': minor
-'@modelcontextprotocol/express': patch
-'@modelcontextprotocol/codemod': patch
----
-
-Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`:
-`requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare
-Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and
-`bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined
-here. The Express middleware adapts the same core and is unchanged in
-behavior, except that `WWW-Authenticate` challenge values are now RFC 7235
-quoted-string sanitized (quotes and backslashes escaped, control and
-non-ASCII characters replaced); `@modelcontextprotocol/express` re-exports
-`OAuthTokenVerifier` as before.
diff --git a/.changeset/web-standard-oauth-metadata.md b/.changeset/web-standard-oauth-metadata.md
deleted file mode 100644
index 32b960fa44..0000000000
--- a/.changeset/web-standard-oauth-metadata.md
+++ /dev/null
@@ -1,17 +0,0 @@
----
-'@modelcontextprotocol/server': minor
-'@modelcontextprotocol/express': patch
----
-
-Add runtime-neutral OAuth discovery serving to `@modelcontextprotocol/server`:
-`oauthMetadataResponse` serves the RFC 9728 Protected Resource Metadata and
-RFC 8414 Authorization Server metadata documents from web-standard
-`fetch(request)` hosts, built on the exported
-`buildOAuthProtectedResourceMetadata`, with
-`getOAuthProtectedResourceMetadataUrl` now defined here. The Express metadata
-router adapts the same core and is unchanged in behavior; the insecure-issuer
-escape hatch is an explicit `dangerouslyAllowInsecureIssuerUrl` option in the
-neutral core instead of a module-scope environment read. The web-standard
-matcher validates lazily so unmatched traffic always falls through, tolerates
-a trailing slash, supports HEAD, and marks reflected CORS preflights with
-`Vary`.
diff --git a/.changeset/workerd-schema-preload.md b/.changeset/workerd-schema-preload.md
deleted file mode 100644
index c0fc376c28..0000000000
--- a/.changeset/workerd-schema-preload.md
+++ /dev/null
@@ -1,6 +0,0 @@
----
-'@modelcontextprotocol/client': minor
-'@modelcontextprotocol/server': minor
----
-
-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. Calling `preloadSchemas()` 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 (its `browser` condition previously reused the workerd shim), so browser bundles keep lazy construction.
diff --git a/.github/ISSUE_TEMPLATE/v2-feedback.yml b/.github/ISSUE_TEMPLATE/v2-feedback.yml
index 0a91acbf70..9218a1646e 100644
--- a/.github/ISSUE_TEMPLATE/v2-feedback.yml
+++ b/.github/ISSUE_TEMPLATE/v2-feedback.yml
@@ -6,7 +6,7 @@ body:
- type: markdown
attributes:
value: |
- Thanks for trying v2. Anything that broke, surprised you, or slowed you down is useful — API feedback is explicitly welcome while v2 is in beta.
+ Thanks for trying v2. Anything that broke, surprised you, or slowed you down is useful — API feedback is explicitly welcome while v2 settles.
Docs: https://ts.sdk.modelcontextprotocol.io/v2/ · Migration from v1: https://ts.sdk.modelcontextprotocol.io/v2/migration/
- type: textarea
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d3d64c4819..a3a01784ce 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -51,7 +51,7 @@ By the time you open a PR, the "what" and "why" should already be settled in an
This repository has two main branches:
-- **`main`** – v2 of the SDK (currently in development). This is a monorepo with split packages.
+- **`main`** – v2 of the SDK (the stable release line). This is a monorepo with split packages.
- **`v1.x`** – stable v1 release. Bug fixes and patches for v1 should target this branch.
**Which branch should I use as a base?**
diff --git a/README.md b/README.md
index 22404e5f68..6d5e2328ef 100644
--- a/README.md
+++ b/README.md
@@ -2,15 +2,15 @@
> [!IMPORTANT]
-> **This is the `main` branch — v2 of the SDK, now in beta** (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`), implementing the [2026-07-28 MCP spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/).
+> **This is the `main` branch — v2 of the SDK** (`@modelcontextprotocol/server`, `@modelcontextprotocol/client`), implementing the [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28).
>
> **Have feedback? Please [open a v2 issue](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml)** — it is the most useful thing you can do for the SDK right now. The [v2 documentation](https://ts.sdk.modelcontextprotocol.io/v2/) starts with a ten-minute server tutorial.
>
-> We expect a stable release alongside the full release of the 2026-07-28 spec on July 28, 2026. Until then, **v1.x remains the supported release for production**; it keeps receiving bug fixes and security updates for at least 6 months after v2 ships. v1 documentation: [ts.sdk.modelcontextprotocol.io](https://ts.sdk.modelcontextprotocol.io/) · v2: [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/).
+> **v2 is the stable release line**, released alongside the 2026-07-28 spec. v1.x continues to receive bug fixes and security updates for at least 6 months after v2's release. v1 documentation: [ts.sdk.modelcontextprotocol.io](https://ts.sdk.modelcontextprotocol.io/) · v2: [`/v2/`](https://ts.sdk.modelcontextprotocol.io/v2/).
> [!WARNING]
-> **We're limiting pull requests to 1 per new contributor while we land the [2026-07-28 spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/) implementation.**
+> **We're limiting pull requests to 1 per new contributor while v2 settles after the [2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28) release.**
>
> [Issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml) are the most useful feedback right now — we'll reopen PRs as v2 stabilizes.
@@ -38,7 +38,7 @@ This repository contains the TypeScript SDK implementation of the MCP specificat
- MCP **server** libraries (tools/resources/prompts, Streamable HTTP, stdio, auth helpers)
- MCP **client** libraries (transports, high-level helpers, OAuth helpers)
-- Optional **middleware packages** for specific runtimes/frameworks (Express, Hono, Node.js HTTP)
+- Optional **middleware packages** for specific runtimes/frameworks (Express, Fastify, Hono, Node.js HTTP)
- Runnable **examples** (under [`examples/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples))
## Packages
@@ -58,6 +58,7 @@ They are intentionally thin adapters: they should not introduce new MCP function
- **`@modelcontextprotocol/node`**: Node.js Streamable HTTP transport wrapper for `IncomingMessage` / `ServerResponse`
- **`@modelcontextprotocol/express`**: Express helpers (app defaults + Host header validation)
+- **`@modelcontextprotocol/fastify`**: Fastify helpers (app defaults + Host header validation)
- **`@modelcontextprotocol/hono`**: Hono helpers (app defaults + JSON body parsing hook + Host header validation)
## Installation
@@ -84,7 +85,7 @@ deno add npm:@modelcontextprotocol/client
### Optional middleware packages
-The SDK also publishes optional “middleware” packages that help you **wire MCP into a specific runtime or web framework** (for example Express, Hono, or Node.js `http`).
+The SDK also publishes optional “middleware” packages that help you **wire MCP into a specific runtime or web framework** (for example Express, Fastify, Hono, or Node.js `http`).
These packages are intentionally thin adapters and should not introduce additional MCP features or business logic. See [`packages/middleware/README.md`](packages/middleware/README.md) for details.
@@ -95,6 +96,9 @@ npm install @modelcontextprotocol/node
# Express integration:
npm install @modelcontextprotocol/express express
+# Fastify integration:
+npm install @modelcontextprotocol/fastify fastify
+
# Hono integration:
npm install @modelcontextprotocol/hono hono
```
diff --git a/docs/.vitepress/llms.ts b/docs/.vitepress/llms.ts
index af186fbac9..6db2c592dc 100644
--- a/docs/.vitepress/llms.ts
+++ b/docs/.vitepress/llms.ts
@@ -172,7 +172,7 @@ export function generateLlmsArtifacts(docsDir: string, outDir: string, site: str
const header = [
'# MCP TypeScript SDK',
'',
- '> The official TypeScript SDK for the Model Context Protocol (MCP): build MCP servers and clients on Node.js, Bun, Deno, and Workers. This is the v2 beta documentation, tracking the 2026-07-28 spec revision.',
+ '> The official TypeScript SDK for the Model Context Protocol (MCP): build MCP servers and clients on Node.js, Bun, Deno, and Workers. This is the v2 documentation, covering the 2026-07-28 spec revision.',
'',
'Every page below is also served as plain markdown at its `.md` URL — fetch any page directly.',
''
diff --git a/docs/.vitepress/theme/Banner.vue b/docs/.vitepress/theme/Banner.vue
index 2edd0abdf4..256b9c3d6e 100644
--- a/docs/.vitepress/theme/Banner.vue
+++ b/docs/.vitepress/theme/Banner.vue
@@ -1,6 +1,6 @@
- This is the documentation for the v2 beta — looking for the
+ This is the documentation for v2 — looking for the
v1 documentation?
diff --git a/docs/advanced/gateway.md b/docs/advanced/gateway.md
index 708b7278dc..1aad441dc3 100644
--- a/docs/advanced/gateway.md
+++ b/docs/advanced/gateway.md
@@ -151,6 +151,8 @@ re-probed: 2026-07-28
Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read.
+The `EraNegotiationFailed` filter above is deliberate and safe against auth walls: a `401`/`403` rejecting the probe carries `ClientHttpAuthentication`/`ClientHttpForbidden` instead, so an unauthorized exchange re-throws out of this recovery path and can never be persisted as an era verdict.
+
## Skip the probe for a known-legacy server
When out-of-band metadata already says the server is pre-2026 — a registry entry, an earlier connection's outcome — an `'auto'`-mode probe is a round trip that fails on every single connect. Supply the negative verdict instead: `PriorDiscovery`'s `{ kind: 'legacy' }` arm skips the probe and goes straight to the `initialize` handshake.
diff --git a/docs/clients/oauth.md b/docs/clients/oauth.md
index f30dcd9258..f713c2e04d 100644
--- a/docs/clients/oauth.md
+++ b/docs/clients/oauth.md
@@ -29,7 +29,7 @@ try {
When the server requires authorization and the provider has no token, the SDK runs discovery against the server, registers (or looks up) your OAuth client, calls the provider's `redirectToAuthorization(url)`, and `connect()` throws `UnauthorizedError`. The end user finishes signing in out of band; your callback endpoint picks the flow back up below.
::: info
-With protocol-version negotiation in play, the connect-time 401 can also surface as an `SdkError` carrying the `UnauthorizedError` at `error.data.cause` — see [Protocol versions](../protocol-versions.md).
+With protocol-version negotiation in play (`versionNegotiation: { mode: 'auto' }` or a pin), the connect-time `UnauthorizedError` propagates unchanged from `connect()` — the same `instanceof` check works in every mode (older releases wrapped it as an `SdkError` with the error at `error.data.cause`). See [Protocol versions](../protocol-versions.md).
:::
## Implement OAuthClientProvider
diff --git a/docs/clients/roots.md b/docs/clients/roots.md
index d725241d86..cfe3972ef6 100644
--- a/docs/clients/roots.md
+++ b/docs/clients/roots.md
@@ -4,7 +4,7 @@ shape: how-to
# Provide roots
::: warning Deprecated — SEP-2577
-Pass paths through tool arguments, resource URIs, or host configuration instead. **Roots** are deprecated as of protocol version 2026-07-28 (SEP-2577) and stay functional on 2025-era connections for at least twelve months — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated).
+Pass paths through tool arguments, resource URIs, or host configuration instead. **Roots** are deprecated as of protocol version 2026-07-28 (SEP-2577) and stay functional on 2025-era connections for at least twelve months — see the [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated).
:::
## Migrate away first
diff --git a/docs/index.md b/docs/index.md
index 29f929b0fd..2f22bf138e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,8 +4,8 @@ shape: landing
# MCP TypeScript SDK
-::: info v2 beta
-This is the documentation for **v2** of the SDK, currently in **beta**: the API is settling but can still change before the stable release alongside the [2026-07-28 spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/). [Tell us what you find](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml) — and if you need the stable v1, its documentation is at [ts.sdk.modelcontextprotocol.io](https://ts.sdk.modelcontextprotocol.io/).
+::: info v2
+This is the documentation for **v2** of the SDK, the stable release line implementing the [2026-07-28 spec](https://modelcontextprotocol.io/specification/2026-07-28). [Report issues](https://github.com/modelcontextprotocol/typescript-sdk/issues/new/choose) — and if you need v1, its documentation is at [ts.sdk.modelcontextprotocol.io](https://ts.sdk.modelcontextprotocol.io/).
:::
The **Model Context Protocol** (MCP) is an open standard that connects AI applications to the systems where your data and tools live. You write a **server** that exposes tools, resources, and prompts; any MCP **host** — Claude Code, VS Code, Cursor, your own application — connects to it and lets a model use them. The protocol is defined by [the MCP specification](https://modelcontextprotocol.io/specification/latest); this SDK is its TypeScript implementation, on Node.js, Bun, and Deno.
diff --git a/docs/migration/index.md b/docs/migration/index.md
index 95444e11b4..9da2a935b1 100644
--- a/docs/migration/index.md
+++ b/docs/migration/index.md
@@ -19,7 +19,7 @@ v2 packages (`@modelcontextprotocol/client`, `@modelcontextprotocol/server`, …
Start by running the codemod:
```bash
-npx @modelcontextprotocol/codemod@beta v1-to-v2 .
+npx @modelcontextprotocol/codemod@latest v1-to-v2 .
```
Run it at the package root (`.`) — real projects import the SDK from `test/`,
diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md
index 154a11b1d5..13e869c0f2 100644
--- a/docs/migration/support-2026-07-28.md
+++ b/docs/migration/support-2026-07-28.md
@@ -9,11 +9,6 @@ protocol revision — and for code written against an earlier **v2 alpha** that
wire-only members directly. If you are on `@modelcontextprotocol/sdk` (v1.x), start with
[upgrade-to-v2.md](./upgrade-to-v2.md) instead.
-> **Schema artifact:** until the revision is finalized, the spec repository publishes
-> the 2026-07-28 schema under `schema/draft/` — there is no `schema/2026-07-28/`
-> directory yet. Tooling that vendors per-revision schema artifacts should track
-> `draft/` and note the divergence.
-
Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed
`Client` / `Server` / `McpServer` keeps speaking the 2025-era protocol it was written
for. Serving or speaking 2026-07-28 is always an explicit opt-in via one of the entries
@@ -76,7 +71,15 @@ infrastructure problems. Anything the probe does not positively recognize as mod
falls back to the legacy era — provided the supported-versions list still contains a
2025-era revision; with a modern-only list `connect()` rejects with
`SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect
-error. Probe timeouts are **transport-aware**: on **stdio** a server that does not
+error. Auth statuses are another exception: an HTTP `401` or `403` rejecting the probe
+is never era evidence — `connect()` rejects with a typed authorization failure (an
+`SdkHttpError` with code `ClientHttpAuthentication`/`ClientHttpForbidden` naming the
+status, or the transport auth flow's own error propagated unchanged) instead of
+falling back — see [Protocol versions](../protocol-versions.md). This holds even for
+a front that answers the probe `403` but would pass `initialize`: relying on a legacy
+fallback there is a deliberate non-goal — fix the auth wall (or pass credentials),
+because auth status never selects an era. A `5xx` on the probe is a server failure,
+also never era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)`. Probe timeouts are **transport-aware**: on **stdio** a server that does not
answer within `timeoutMs` is treated as legacy and the client falls back to `initialize`
(some legacy servers never respond to unknown pre-`initialize`
requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` —
@@ -366,7 +369,7 @@ tightening that **does** affect v1 code (custom-handler `_meta` passthrough,
`CallToolResult.content` keeps its v1 default on the legacy era (2026-07-28
connections require it explicitly).
-> **If you were on a v2 alpha:** the 2026-07-28 draft error codes were renumbered:
+> **If you were on a v2 alpha:** the 2026-07-28 error codes were renumbered:
> `HeaderMismatch` `-32001`→`-32020`, `MissingRequiredClientCapability` `-32003`→`-32021`,
> `UnsupportedProtocolVersion` `-32004`→`-32022`. No v1.x impact (these codes never
> existed in v1); v2-alpha code that hard-coded the old literals must update — prefer
diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md
index b88c6f032e..19f4127733 100644
--- a/docs/migration/upgrade-to-v2.md
+++ b/docs/migration/upgrade-to-v2.md
@@ -19,7 +19,7 @@ If you are already on v2 and want to adopt the **2026-07-28 protocol revision**,
both `import` and `require('@modelcontextprotocol/…')` resolve natively.
2. **Run the codemod.**
```bash
- npx @modelcontextprotocol/codemod@beta v1-to-v2 .
+ npx @modelcontextprotocol/codemod@latest v1-to-v2 .
```
Run it at the **package root** (`.`), not `./src` — it also rewrites `package.json`,
and real projects import the SDK from `test/`, `scripts/`, and fixtures too.
@@ -233,10 +233,9 @@ quote-anchored pattern misses silently — match either quote. The build layout
changed: v2 emits `.mjs`/`.cjs` siblings in a flat `dist/`, so v1's `/dist/cjs/` ↔
`/dist/esm/` flavor-pair path swaps have no equivalent.
-#### Registry availability during the beta
+#### Registry availability
-All v2 packages are published on the public npm registry. Two notes for the beta
-window:
+All v2 packages are published on the public npm registry. Two notes:
- As of `2.0.0-beta.1` all v2 packages share one version number (earlier alphas
did not). The codemod writes ranges that match what is published, so prefer its
@@ -1151,11 +1150,11 @@ try {
}
```
-One qualification: this direct `instanceof` check applies under the default `'legacy'`
-version negotiation. Under the probing modes (`versionNegotiation: { mode: 'auto' }`,
-with or without a pin) the connect-time 401 currently surfaces wrapped as
-`SdkError(SdkErrorCode.EraNegotiationFailed)` with the `UnauthorizedError` at
-`error.data.cause` — unwrap before the check, as shown in the
+This direct `instanceof` check works in every version-negotiation mode: under the
+probing modes (`versionNegotiation: { mode: 'auto' }`, with or without a pin) the
+connect-time `UnauthorizedError` also propagates unchanged from `connect()`. Older
+releases wrapped it as `SdkError(SdkErrorCode.EraNegotiationFailed)` with the error at
+`error.data.cause` — that unwrap is no longer needed. See the
[client OAuth guide](../clients/oauth.md).
#### `auth()` options are now `AuthOptions`
diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md
index a304f4b604..ba1338b0d2 100644
--- a/docs/protocol-versions.md
+++ b/docs/protocol-versions.md
@@ -101,6 +101,8 @@ const cli = new Client(
A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize`; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers.
+Auth statuses are not era evidence either. An HTTP `401` or `403` rejecting the probe surfaces as a typed authorization failure, never the legacy fallback — and never as `EraNegotiationFailed`, so era-recovery flows keyed on that code (the [gateway guide](./advanced/gateway.md) recipe) cannot consume an auth wall. A plain `401` or `403` — with or without an `authProvider` — rejects `connect()` with an `SdkHttpError` carrying the status: code `ClientHttpAuthentication` for `401`, `ClientHttpForbidden` for `403`. One 403 shape is different: a `WWW-Authenticate` challenge with `error="insufficient_scope"` enters the Streamable HTTP transport's step-up flow regardless of provider, and with none it rejects with the flow's typed `InsufficientScopeError`. With a provider, a `401` (and a `403` `insufficient_scope` challenge) runs the auth flow first, and whatever escapes it reaches you as thrown, identity intact — the transport stamps errors at its auth seams (the `token()` read, `onUnauthorized` including custom callbacks, step-up), so `UnauthorizedError` for `finishAuth()`, the flow's typed failures (`OAuthError`, `InsufficientScopeError`, the 401-after-re-authentication diagnostic), and even an untyped crash inside a callback all propagate unchanged. A `5xx` rejecting the probe is a server failure, not era evidence: `connect()` rejects with `SdkHttpError(EraNegotiationFailed)` naming the status. These status-keyed rows read the transport's typed HTTP rejections — the SDK's Streamable HTTP transport surfaces them with the status attached; the legacy SSE client transport reports a non-2xx POST as a generic error, so over SSE a probe rejection surfaces as the generic `Version negotiation probe failed` connect error instead. Auth settles first, era second: a `401` never decides the era — the auth wall answers before the MCP layer ever sees `server/discover` — and the post-auth re-probe supplies the real era evidence.
+
On the SDK's own stdio transport (exactly `StdioClientTransport` — subclasses, like custom stdio-shaped transports, probe in place) the probe runs on a short-lived **sibling process** spawned from the same parameters — some stdio servers exit on any pre-`initialize` request (servers built on the official Rust SDK, rmcp, behave this way), so the probe must not spend the caller's one child process. The sibling is invisible infrastructure: its stderr is discarded and it is reaped once the era is known; the caller's transport spawns exactly once, afterwards, and its wire never carries `server/discover`. A child that exits on the probe is simply a legacy server (its exit must close the child's stdio pipes to register — an exit hidden behind a helper process holding them open falls to the probe-timeout path). Closing the caller's transport during the probe aborts `connect()` with a typed `SdkError(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 rejects with the same typed error as any probe transport failure.
The client's `supportedProtocolVersions` option shapes the probe: its 2026+ entries are the versions the probe offers, and the legacy fallback stays available only while the list keeps a pre-2026 entry. A list with no pre-2026 entry removes the fallback — against a 2025-only server, `connect()` rejects with `SdkError(EraNegotiationFailed)`.
diff --git a/docs/servers/logging-progress-cancellation.md b/docs/servers/logging-progress-cancellation.md
index bfc61db1bf..95be3c0627 100644
--- a/docs/servers/logging-progress-cancellation.md
+++ b/docs/servers/logging-progress-cancellation.md
@@ -74,7 +74,7 @@ Only the result comes back:
## Log to the client
::: warning Deprecated — SEP-2577
-Log to `stderr` (stdio servers) or use OpenTelemetry instead. **MCP logging** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated).
+Log to `stderr` (stdio servers) or use OpenTelemetry instead. **MCP logging** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated).
:::
Declare the `logging` capability when you construct the server.
diff --git a/docs/servers/sampling.md b/docs/servers/sampling.md
index 86f97ae569..9ed62e31b1 100644
--- a/docs/servers/sampling.md
+++ b/docs/servers/sampling.md
@@ -4,7 +4,7 @@ shape: how-to
# Sampling
::: warning Deprecated — SEP-2577
-Call your LLM provider's API directly from your server instead. **Sampling** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional on 2025-era connections for at least twelve months — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated).
+Call your LLM provider's API directly from your server instead. **Sampling** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional on 2025-era connections for at least twelve months — see the [deprecated features registry](https://modelcontextprotocol.io/specification/2026-07-28/deprecated).
:::
## Replace sampling with a direct provider call
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index bf96270c36..d9e6e7824d 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -75,6 +75,9 @@ With the global in place the [client OAuth](./clients/oauth.md) flows run unchan
- `the connection closed during the server/discover probe (this transport probed in place — the disposable sibling probe requires the SDK's base StdioClientTransport)` — a subclass of `StdioClientTransport`, or a custom stdio-shaped transport, probed in place and met a server that exits on any pre-`initialize` request: use the base `StdioClientTransport` (which probes on a disposable sibling), or `mode: 'legacy'`.
- `the transport was closed during the server/discover probe` — the caller closed the transport while the probe was in flight; the connect aborted deliberately and the session child was never spawned.
- `Version negotiation probe failed: ...` — the probe hit a transport failure (network outage, HTTP connection drop): fix connectivity and retry.
+- `the server answered the probe with HTTP 5xx` — the server or a proxy in front of it failed (mid-deploy, crashed backend); not era evidence, so no legacy fallback is attempted: retry once the deployment is healthy.
+
+A `401`/`403` probe rejection is **not** this code — see the next section.
The pinned shape — `transport` here reaches a server still on the 2025 revisions ([Test a server](./testing.md) shows the in-memory wiring these outputs come from):
@@ -112,6 +115,12 @@ legacy
Keep `{ pin }` where a legacy connection is unacceptable and a hard failure is the behavior you want. [Protocol versions](./protocol-versions.md) defines the eras and what each negotiation mode offers.
+## `Version negotiation failed: the server requires authorization (HTTP 401)`
+
+`SdkHttpError` with code `CLIENT_HTTP_AUTHENTICATION` (`error.status === 401`): the negotiation probe hit an auth wall and no `authProvider` is configured. Auth status is never protocol-era evidence, so `connect()` fails typed instead of guessing an era. Pass an `authProvider` — `{ token: async () => myApiKey }` for bearer tokens, or an `OAuthClientProvider` for OAuth flows (the connect-time `UnauthorizedError` → `finishAuth()` → reconnect cycle then works in every negotiation mode).
+
+The `403` sibling — `Version negotiation failed: the server denied access (HTTP 403)`, code `CLIENT_HTTP_FORBIDDEN` — means the server refused the request outright (IP allowlist, org policy, revoked key). Fix access; this is not a protocol mismatch. (A `403` whose `WWW-Authenticate` challenge carries `error="insufficient_scope"` instead rejects with `InsufficientScopeError` — request the challenged scope.)
+
## `SdkError: METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION`
You sent a spec method the negotiated protocol era does not define. The SDK raises this locally — nothing reached the transport — and the message names the method, the negotiated revision, and the era-appropriate replacement.
diff --git a/docs/v1/.vitepress/theme/Banner.vue b/docs/v1/.vitepress/theme/Banner.vue
index 65c8329691..a61e2c97c2 100644
--- a/docs/v1/.vitepress/theme/Banner.vue
+++ b/docs/v1/.vitepress/theme/Banner.vue
@@ -1,6 +1,6 @@
diff --git a/examples/cli-client/host/host.ts b/examples/cli-client/host/host.ts
index 49165c9554..702f648032 100644
--- a/examples/cli-client/host/host.ts
+++ b/examples/cli-client/host/host.ts
@@ -17,7 +17,6 @@ import {
Client,
LOG_LEVEL_META_KEY,
ProtocolError,
- SdkError,
StreamableHTTPClientTransport,
SUPPORTED_PROTOCOL_VERSIONS,
UnauthorizedError
@@ -25,7 +24,6 @@ import {
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import type { ChatMessage, ContentPart, GenerateResult, LLMProvider, ToolCall, ToolDefinition } from '../providers/provider';
-import { isRecord } from '../providers/provider';
import { completeAuthorizationWithBrowser, createOAuthProvider, findCallbackPort, isSafeBrowserUrl } from './auth';
import type { CliClientConfig, ServerConfig } from './config';
import { isHttpServer } from './config';
@@ -100,16 +98,6 @@ export function resolveVersionOptions(legacy: boolean, protocolVersion?: string)
return { versionNegotiation: { mode: { pin: protocolVersion } } };
}
-function unwrapUnauthorized(error: unknown): UnauthorizedError | undefined {
- if (error instanceof UnauthorizedError) return error;
- // Under versionNegotiation 'auto', a connect-time 401 surfaces as
- // SdkError(EraNegotiationFailed) with the UnauthorizedError in error.data.cause.
- if (error instanceof SdkError && isRecord(error.data) && error.data.cause instanceof UnauthorizedError) {
- return error.data.cause;
- }
- return undefined;
-}
-
function samplingContentToParts(content: CreateMessageRequest['params']['messages'][number]['content']): ContentPart[] {
const blocks = Array.isArray(content) ? content : [content];
const parts: ContentPart[] = [];
@@ -488,7 +476,9 @@ export class McpHost {
try {
await client.connect(httpTransport);
} catch (error) {
- if (!unwrapUnauthorized(error)) throw error;
+ // A connect-time 401 propagates UnauthorizedError unchanged
+ // in every negotiation mode, including the probing ones.
+ if (!(error instanceof UnauthorizedError)) throw error;
const finishTransport = httpTransport;
const authorized = await completeAuthorizationWithBrowser({
serverName: name,
diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md
index ecb40fc2a9..5f3dea3b2d 100644
--- a/packages/client/CHANGELOG.md
+++ b/packages/client/CHANGELOG.md
@@ -1,5 +1,118 @@
# @modelcontextprotocol/client
+## 2.0.0
+
+### Minor Changes
+
+- [#2501](https://github.com/modelcontextprotocol/typescript-sdk/pull/2501) [`1480241`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1480241e2a2a7f0ceee8e7723b2adcf88579bb36) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Export the `Protocol` base class and `mergeCapabilities` from the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` package roots, restoring the v1 import for consumers that subclass `Protocol` (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 `Protocol` and `mergeCapabilities` imports from `shared/protocol.js` to the client or server package root, like the module's other symbols, instead of dropping them with an action-required marker.
+
+- [#2511](https://github.com/modelcontextprotocol/typescript-sdk/pull/2511) [`f60dff0`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f60dff0674954ab516739f21ad9905349c8e9249) Thanks [@felixweinberger](https://github.com/felixweinberger)! - `ConnectOptions.prior` accepts a cached era verdict — the new exported type `PriorDiscovery`. `{ kind: 'modern', discover }` adopts a previously obtained `DiscoverResult` with zero round trips; `{ kind: 'legacy' }` skips the `server/discover` probe and runs the plain `initialize` handshake directly, for servers known out-of-band to be legacy — without pinning the client to `mode: 'legacy'`: stop supplying the verdict and `connect()` falls back to the configured `versionNegotiation` mode (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: null` is treated as absent, the modern arm's `discover` payload is schema-validated before any connection state changes, and an unrecognized shape rejects with a typed `SdkError(EraNegotiationFailed)` instead of a `TypeError`.
+
+- [#2468](https://github.com/modelcontextprotocol/typescript-sdk/pull/2468) [`5db6e38`](https://github.com/modelcontextprotocol/typescript-sdk/commit/5db6e38f2bdeb5052f608121e9a2679ff8742af2) Thanks [@felixweinberger](https://github.com/felixweinberger)! - 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 the `structuredClone` global — 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 `ResponseCacheStore` implementations: `CacheEntry.value` (and the `set()` entry value) is now `string` — persist and return it verbatim, `JSON.parse` to inspect. Entries persisted by a previous SDK version fail decode once (reported, dropped) and are rewritten on the next fetch.
+
+- [#2477](https://github.com/modelcontextprotocol/typescript-sdk/pull/2477) [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Move the schema source modules (spec schemas, OAuth schemas, protocol constants) into `@modelcontextprotocol/core` and 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/core` gains a `./internal` subpath (SDK-internal contract; may change in any release) and the four packages now version together.
+
+- [#2513](https://github.com/modelcontextprotocol/typescript-sdk/pull/2513) [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Align the 2026-07-28 wire with the final revision (spec PR #3002): `serverInfo` moves from the `DiscoverResult` body to the result `_meta`, and the per-request envelope's `clientInfo` demotes 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 body `serverInfo` failed parse, so the probe misclassified the server as legacy and attempted an `initialize` handshake 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 omit `clientInfo`.
+
+ Now:
+ - The 2026 wire schemas are the final revision exactly: no body `serverInfo` on `DiscoverResult`, envelope `clientInfo` optional (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-built `subscriptions/listen` graceful-close results — the spec's `SubscriptionsListenResultMeta` extends `ResultMetaObject`.
+ - Clients keep sending `clientInfo` and read server identity from the discover result's `_meta` only. A server that stamps no identity is anonymous: `getServerVersion()` is `undefined` and the response cache partitions under a per-connection surrogate. A malformed `_meta` serverInfo value is treated as absent on receive (the spec marks the field self-reported, unverified, and display-only).
+ - Breaking type changes: `DiscoverResult` no longer declares `serverInfo`; `RequestMetaEnvelope`'s `clientInfo` is optional. New public constant `SERVER_INFO_META_KEY` (`'io.modelcontextprotocol/serverInfo'`).
+
+- [#2483](https://github.com/modelcontextprotocol/typescript-sdk/pull/2483) [`3f07a32`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3f07a325c6741b2374ce2255846dfa0c25f74d03) Thanks [@felixweinberger](https://github.com/felixweinberger)! - 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. Calling `preloadSchemas()` 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 (its `browser` condition previously reused the workerd shim), so browser bundles keep lazy construction.
+
+### Patch Changes
+
+- [#2402](https://github.com/modelcontextprotocol/typescript-sdk/pull/2402) [`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a) Thanks [@felixweinberger](https://github.com/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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2456) [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Restore the v1 parse tolerance for `CallToolResult.content`: an inbound legacy-era `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error 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 `content` for 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 foreign `resultType`), 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 to `content: []` before era validation on every leg, reaching the wire spec-valid.
+
+ Conscious call: the nested sampling `ToolResultContentSchema` stays spec-strict — v1 had defaulted its `content` too, but it is params-side (tool results a caller authors into a sampling message), deliberately not restored.
+
+- [#2431](https://github.com/modelcontextprotocol/typescript-sdk/pull/2431) [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df) Thanks [@morluto](https://github.com/morluto)! - Fix the CommonJS `validators/ajv` subpath so reading the exported `Ajv` class no longer throws `ReferenceError: import_ajv is not defined`. The subpath now re-exports the bundled provider's concrete `Ajv` value in CJS output, matching the existing ESM behavior.
+
+- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
+ and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
+ adds a `require` condition so `require('@modelcontextprotocol/…')` works from
+ CommonJS consumers. Output extensions are normalized across all packages
+ (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
+ public import paths are unchanged.
+
+- [#2441](https://github.com/modelcontextprotocol/typescript-sdk/pull/2441) [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9) Thanks [@felixweinberger](https://github.com/felixweinberger)! - POSTs whose `Content-Type` media type is not `application/json` are now
+ rejected with `415 Unsupported Media Type`; the header is parsed instead of
+ substring-matched. Previously any value merely containing the substring
+ passed the check (for example `text/plain; a=application/json`), case
+ variants were wrongly rejected, and the 2026-07-28 entry did not inspect
+ `Content-Type` at 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
+ like `application/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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2384) [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e) Thanks [@felixweinberger](https://github.com/felixweinberger)! - `instanceof` on the SDK error classes (`ProtocolError` and its typed subclasses, `SdkError`/`SdkHttpError`, `OAuthError`, and the client's `SseError`, `UnauthorizedError`, and OAuth-client-flow error family — `OAuthClientFlowError` and its subclasses) now works across separately bundled copies of the SDK. The classes match by a stable brand (via `Symbol.hasInstance` and a registry symbol) instead of prototype identity, so a process that uses both `@modelcontextprotocol/client` and `@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-based `instanceof` is 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-bundle `SdkError` used as an abort reason is now rethrown as-is instead of being wrapped as a `RequestTimeout`. 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 over `instanceof`. Also: `UnauthorizedError` now sets `error.name` to `'UnauthorizedError'` (previously `'Error'`), and per-package conformance tests enforce that every exported error class participates in branding. Version-negotiation probing now recognizes `UnauthorizedError` (previously a dead name-string check) and propagates it unchanged, so `connect()` on an auth-gated server rejects with the original `UnauthorizedError` (previously wrapped as the `cause` of an `SdkError(EraNegotiationFailed)`) — run `finishAuth()` and reconnect, and the retry probes with the token.
+
+- [#2469](https://github.com/modelcontextprotocol/typescript-sdk/pull/2469) [`9b41b56`](https://github.com/modelcontextprotocol/typescript-sdk/commit/9b41b5685ded29c0afc194bbd91bb1902bee6f84) Thanks [@felixweinberger](https://github.com/felixweinberger)! - The Streamable HTTP client transport no longer attaches a session ID to a POST containing an `initialize` request — a new session starts "without a session ID attached" (2025-11-25 transports §Session Management) — and it only captures the `mcp-session-id` response 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. Ignoring `mcp-session-id` headers 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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2458) [`7c49b47`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7c49b47fb3a58b51cec8fd0b337f656515f1a2b7) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Construct the default Ajv validation engine lazily on first validation. Creating a `Client` or `Server` no longer pays the ajv + ajv-formats instantiation cost at startup when no JSON Schema validation ever runs.
+
+- [#2476](https://github.com/modelcontextprotocol/typescript-sdk/pull/2476) [`e0a0ab7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e0a0ab74d9baed74572c9f435313fb6daef1b989) Thanks [@felixweinberger](https://github.com/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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2564) [`faa7e2b`](https://github.com/modelcontextprotocol/typescript-sdk/commit/faa7e2b47090219060c0699bfe49ebe011516bce) Thanks [@felixweinberger](https://github.com/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 the `server/discover` probe now surfaces as a typed
+ authorization failure — an `SdkHttpError` with code `ClientHttpAuthentication`
+ (401) or `ClientHttpForbidden` (403), carrying the HTTP status, reason
+ phrase, and response text — instead of triggering the legacy `initialize`
+ fallback (which put a doomed `initialize` on the wire) or, under `pin` mode,
+ the false "server did not offer pinned protocol version" diagnostic. The
+ codes are deliberately not `EraNegotiationFailed`, 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`, a `401` (and a `403` `insufficient_scope` challenge) runs the transport's auth flow first — a plain `403` rejects the same as without a provider — and whatever
+ escapes it propagates unchanged, identity intact: the HTTP transports stamp
+ errors at their auth seams (the `token()` read, `onUnauthorized` including
+ custom callbacks, the 403 step-up flow, and their own auth-failure
+ constructions), so `UnauthorizedError` for `finishAuth()`, the flow's typed
+ failures (`OAuthError`, `InsufficientScopeError`, the
+ 401-after-re-authentication diagnostic), and even an untyped `TypeError`
+ 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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2514) [`6fe1963`](https://github.com/modelcontextprotocol/typescript-sdk/commit/6fe196310ddba95a9f9655facb6b7c1ab503d346) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Probe stdio servers on a disposable sibling process. Some stdio servers exit on any pre-`initialize` request (servers built on the official Rust SDK, rmcp, behave this way), so under `versionNegotiation: { mode: 'auto' }` the `server/discover` probe previously killed the server and `connect()` 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 plain `initialize` handshake (byte-identical to `mode: 'legacy'`), a modern verdict is adopted directly, and the session wire never carries `server/discover`. Closing the caller's transport during the probe aborts `connect()` with the typed `SdkError(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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2455) [`cc70c5e`](https://github.com/modelcontextprotocol/typescript-sdk/commit/cc70c5e6a9f9b1c15dcba0bdd019a479b81375de) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Version negotiation no longer discards transport handlers the caller set before `connect()`. The probe window now saves any pre-set `onmessage`/`onerror`/`onclose`, forwards error and close events to them while the probe is in flight, and restores them when the window closes — so `Protocol.connect()` chains them exactly as it does on a plain connect. Previously, connecting with `versionNegotiation` silently cleared pre-set handlers (e.g. an `onerror` used to detect session-expiry auth failures), leaving them permanently detached for the life of the connection.
+
+- [#2425](https://github.com/modelcontextprotocol/typescript-sdk/pull/2425) [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091) Thanks [@Sehlani042](https://github.com/Sehlani042)! - Stop advertising validator provider classes from the root client/server type declarations. The provider classes remain available from the explicit validator subpaths.
+
+- [#2534](https://github.com/modelcontextprotocol/typescript-sdk/pull/2534) [`f130e1a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f130e1ac5fcab34bd568f59907e0f2ce1d22074c) Thanks [@felixweinberger](https://github.com/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's `2019-09`/`openAi` targets) with 2019-09 semantics, on both the Ajv and Cloudflare Workers providers (with known engine differences documented in the migration guide). Schemas with no `$schema` still 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`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a), [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4), [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313)]:
+ - @modelcontextprotocol/core@2.0.0
+
## 2.0.0-beta.5
### Minor Changes
diff --git a/packages/client/README.md b/packages/client/README.md
index c9cbc1bcf8..b142797ef1 100644
--- a/packages/client/README.md
+++ b/packages/client/README.md
@@ -4,7 +4,7 @@ The MCP (Model Context Protocol) TypeScript client SDK. Build MCP clients that c
> [!WARNING]
-> **This is a beta release.** The API surface is settling but breaking changes remain possible until v2 stabilizes. Please try it and open issues — feedback during the beta directly shapes the stable release.
+> **v2 is the stable release line**, implementing the [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). Migrating from v1? Start with the [migration guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/).
> [!NOTE]
@@ -13,7 +13,7 @@ The MCP (Model Context Protocol) TypeScript client SDK. Build MCP clients that c
## Install
```bash
-npm install @modelcontextprotocol/client@beta
+npm install @modelcontextprotocol/client
```
TypeScript ≥6.0 no longer auto-includes `@types/*` — add `"types": ["node"]` to your `tsconfig.json` `compilerOptions` (the published `.d.mts` references `Buffer`).
diff --git a/packages/client/package.json b/packages/client/package.json
index 8e23ca819f..3ebfde64d3 100644
--- a/packages/client/package.json
+++ b/packages/client/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/client",
- "version": "2.0.0-beta.5",
+ "version": "2.0.0",
"description": "Model Context Protocol implementation for TypeScript - Client package",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts
index f01ba75698..073b42ee0c 100644
--- a/packages/client/src/client/authExtensions.ts
+++ b/packages/client/src/client/authExtensions.ts
@@ -572,7 +572,7 @@ export interface CrossAppAccessProviderOptions {
* a callback function that you provide. This allows flexibility in how you obtain and
* cache ID Tokens from the IdP.
*
- * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx
+ * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx
*
* @example
* ```ts
diff --git a/packages/client/src/client/authSeam.ts b/packages/client/src/client/authSeam.ts
new file mode 100644
index 0000000000..fc93fd4522
--- /dev/null
+++ b/packages/client/src/client/authSeam.ts
@@ -0,0 +1,40 @@
+/**
+ * Auth-seam provenance stamp (internal — not part of the public API).
+ *
+ * Errors escaping a transport auth seam — the `authProvider.token()` read,
+ * an `onUnauthorized` invocation (SDK adapter or custom user callback), the
+ * 403 step-up flow, the transport's own auth-failure constructions — are
+ * stamped at the throw boundary. The version-negotiation probe routes stamped
+ * errors as auth outcomes; provenance is recorded where it is known, never
+ * reconstructed from error types downstream.
+ *
+ * `Symbol.for` uses the global symbol registry, so the stamp survives a
+ * duplicated SDK copy in one process (bundler double-install, version skew)
+ * by design: both copies resolve the same symbol.
+ */
+const AUTH_SEAM = Symbol.for('mcp.authSeamEscape');
+
+/**
+ * Stamp `error` as an auth-seam escape and return it — identity-preserving
+ * (the same object flows on, `instanceof` and `.cause` chains intact). A
+ * frozen/sealed object is returned unstamped rather than replaced: identity
+ * outranks provenance. Primitive throws cannot carry the stamp.
+ */
+export function markAuthSeamEscape(error: T): T {
+ if ((typeof error === 'object' && error !== null) || typeof error === 'function') {
+ try {
+ Object.defineProperty(error, AUTH_SEAM, { value: true, configurable: true });
+ } catch {
+ // Frozen/sealed: leave unstamped.
+ }
+ }
+ return error;
+}
+
+/** Whether `error` escaped through a transport auth seam. */
+export function isAuthSeamEscape(error: unknown): boolean {
+ return (
+ ((typeof error === 'object' && error !== null) || typeof error === 'function') &&
+ (error as Record)[AUTH_SEAM] === true
+ );
+}
diff --git a/packages/client/src/client/crossAppAccess.ts b/packages/client/src/client/crossAppAccess.ts
index a7703dbf15..49f010d626 100644
--- a/packages/client/src/client/crossAppAccess.ts
+++ b/packages/client/src/client/crossAppAccess.ts
@@ -4,7 +4,7 @@
* Provides standalone functions for RFC 8693 Token Exchange and RFC 7523 JWT Authorization Grant
* flows as specified in the Enterprise Managed Authorization specification (SEP-990).
*
- * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/enterprise-managed-authorization.mdx
+ * @see https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/stable/enterprise-managed-authorization.mdx
* @module
*/
diff --git a/packages/client/src/client/probeClassifier.ts b/packages/client/src/client/probeClassifier.ts
index 863770acaf..e23d011a2a 100644
--- a/packages/client/src/client/probeClassifier.ts
+++ b/packages/client/src/client/probeClassifier.ts
@@ -7,8 +7,9 @@
* sibling), or a typed connect error.
*
* The classifier is deliberately conservative: anything it does not positively
- * recognize as modern resolves to the legacy fallback, and a network outage is a
- * typed connect error, never an era verdict. The verdicts apply to the
+ * recognize as modern resolves to the legacy fallback, and a network outage or
+ * an auth-status rejection (HTTP 401/403) is a typed connect error, never an
+ * era verdict. The verdicts apply to the
* negotiation phase only — an established modern connection is never silently
* demoted to `initialize` by a later failure.
*/
@@ -19,6 +20,7 @@ import {
modernProtocolVersions,
SdkError,
SdkErrorCode,
+ SdkHttpError,
UnsupportedProtocolVersionError
} from '@modelcontextprotocol/core-internal';
@@ -46,10 +48,10 @@ export type ProbeOutcome =
| { kind: 'result'; result: unknown }
/** Answered with a JSON-RPC error (any HTTP status, including 200-bodied errors and stdio in-band errors). */
| { kind: 'rpc-error'; code: number; message: string; data?: unknown }
- /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text, when available. */
- | { kind: 'http-error'; status: number; body?: string }
+ /** The HTTP layer rejected the probe POST (non-2xx); `body` is the raw response text and `statusText` the HTTP reason phrase, when available. */
+ | { kind: 'http-error'; status: number; body?: string; statusText?: string }
| { kind: 'network-error'; error: unknown }
- /** The transport's auth flow challenged during the probe send (`UnauthorizedError`). */
+ /** The transport's auth flow challenged or failed during the probe send — an error stamped at a transport auth seam, or an `UnauthorizedError` (the foreign-transport contract). `error` propagates unchanged. */
| { kind: 'auth-required'; error: Error }
/** The transport reported close while the probe awaited its reply. */
| { kind: 'closed' }
@@ -241,21 +243,70 @@ function classifyRpcError(outcome: { code: number; message: string; data?: unkno
return { kind: 'legacy' };
}
-function classifyHttpError(outcome: { status: number; body?: string }, context: ProbeClassifierContext): ProbeVerdict {
+function classifyHttpError(outcome: { status: number; body?: string; statusText?: string }, context: ProbeClassifierContext): ProbeVerdict {
+ // Auth statuses are never era evidence, and a 401/403 body is the auth
+ // layer's, not the server/discover handler's — this row ranks above the
+ // JSON-RPC body parse. Parallel to the auth-required row: a typed failure
+ // naming the status (response text on `data.text`), never a legacy
+ // fallback. The codes are the auth ones, NOT EraNegotiationFailed: hosts
+ // key era-recovery recipes (e.g. the gateway guide's cached-verdict flow)
+ // on EraNegotiationFailed, and an auth wall must never enter era recovery
+ // — persisting a legacy verdict for it would recreate the silent-legacy
+ // bug at the fleet level.
+ if (outcome.status === 401 || outcome.status === 403) {
+ const isDenial = outcome.status === 403;
+ return {
+ kind: 'error',
+ error: new SdkHttpError(
+ isDenial ? SdkErrorCode.ClientHttpForbidden : SdkErrorCode.ClientHttpAuthentication,
+ `Version negotiation failed: ${isDenial ? 'the server denied access (HTTP 403)' : 'the server requires authorization (HTTP 401)'}`,
+ {
+ status: outcome.status,
+ statusText: outcome.statusText,
+ text: outcome.body
+ }
+ )
+ };
+ }
+ if (outcome.status >= 500) {
+ // A server failure is not era evidence, whatever the body says — this
+ // row too ranks above the JSON-RPC body parse (a 5xx body is the
+ // infrastructure's: a mid-deploy gateway's JSON error page, a crashed
+ // handler's -32603, neither a server/discover verdict). The spec keys
+ // the HTTP legacy signal to client-error rejections (a 2025 server
+ // answers the unknown probe 4xx), never to 5xx.
+ return {
+ kind: 'error',
+ error: new SdkHttpError(
+ SdkErrorCode.EraNegotiationFailed,
+ `Version negotiation failed: the server answered the probe with HTTP ${outcome.status}`,
+ {
+ status: outcome.status,
+ statusText: outcome.statusText,
+ text: outcome.body
+ }
+ )
+ };
+ }
// HTTP-rejected probes carry their JSON-RPC error in the response body — classify it like an in-band error.
const rpcError = parseJsonRpcErrorBody(outcome.body);
if (rpcError !== undefined) {
return classifyRpcError(rpcError, context);
}
- // Unparseable or unrecognized HTTP rejection: conservative legacy fallback.
+ // Unparseable or unrecognized 4xx rejection: conservative legacy fallback.
+ // With the auth and 5xx rows above, this row's legacy set now equals the
+ // spec-licensed set exactly — the 4xx a deployed 2025 server answers a
+ // request it does not recognize.
return { kind: 'legacy' };
}
function classifyNetworkError(error: unknown, context: ProbeClassifierContext): ProbeVerdict {
if (context.environment === 'browser' && isOpaqueFetchTypeError(error)) {
- // A browser CORS-preflight rejection against a deployed 2025 server is an
- // opaque TypeError; the legacy fallback carries no custom headers (no
- // preflight), so it can proceed where the probe could not.
+ // A browser CORS-preflight rejection against a deployed 2025 server is
+ // an opaque TypeError. The legacy fallback also preflights (its JSON
+ // Content-Type is non-simple), but carries only the pre-2026 headers a
+ // deployed server's CORS allowlist already covers — so it can proceed
+ // where the probe's 2026 headers could not.
return { kind: 'legacy' };
}
return {
diff --git a/packages/client/src/client/sse.ts b/packages/client/src/client/sse.ts
index 1c81928f10..0cc77d8f4f 100644
--- a/packages/client/src/client/sse.ts
+++ b/packages/client/src/client/sse.ts
@@ -23,6 +23,7 @@ import {
} from './auth';
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced in JSDoc {@linkcode}
import type { IssuerMismatchError } from './authErrors';
+import { markAuthSeamEscape } from './authSeam';
export class SseError extends Error {
static {
@@ -160,7 +161,14 @@ export class SSEClientTransport implements Transport {
private async _commonHeaders(): Promise {
const headers: RequestInit['headers'] & Record = {};
- const token = await this._authProvider?.token();
+ let token: string | undefined;
+ try {
+ token = await this._authProvider?.token();
+ } catch (error) {
+ // Auth-seam stamp: a throwing token() is an auth failure, never a
+ // network failure.
+ throw markAuthSeamEscape(error);
+ }
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
@@ -212,15 +220,18 @@ export class SSEClientTransport implements Transport {
this._authProvider.onUnauthorized({ response, serverUrl: this._url, fetchFn: this._fetchWithInit }).then(
// onUnauthorized succeeded → retry fresh. Its onerror handles its own onerror?.() + reject.
() => this._startOrAuth().then(resolve, reject),
- // onUnauthorized failed → not yet reported.
- error => {
- this.onerror?.(error);
+ // onUnauthorized failed → not yet reported. Auth-seam
+ // stamp: covers the SDK's OAuth flow and custom
+ // callbacks alike.
+ (error: unknown) => {
+ markAuthSeamEscape(error);
+ this.onerror?.(error as Error);
reject(error);
}
);
return;
}
- const error = new UnauthorizedError();
+ const error = markAuthSeamEscape(new UnauthorizedError());
reject(error);
this.onerror?.(error);
return;
@@ -362,23 +373,31 @@ export class SSEClientTransport implements Transport {
}
if (this._authProvider.onUnauthorized && !isAuthRetry) {
- await this._authProvider.onUnauthorized({
- response,
- serverUrl: this._url,
- fetchFn: this._fetchWithInit
- });
+ try {
+ await this._authProvider.onUnauthorized({
+ response,
+ serverUrl: this._url,
+ fetchFn: this._fetchWithInit
+ });
+ } catch (error) {
+ // Auth-seam stamp: covers the SDK's OAuth flow and
+ // custom onUnauthorized callbacks alike.
+ throw markAuthSeamEscape(error);
+ }
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
return this._send(message, true);
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
- throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
- status: 401,
- statusText: response.statusText
- });
+ throw markAuthSeamEscape(
+ new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
+ status: 401,
+ statusText: response.statusText
+ })
+ );
}
- throw new UnauthorizedError();
+ throw markAuthSeamEscape(new UnauthorizedError());
}
const text = await response.text?.().catch(() => null);
diff --git a/packages/client/src/client/streamableHttp.ts b/packages/client/src/client/streamableHttp.ts
index 9067fd1ec4..ace0663158 100644
--- a/packages/client/src/client/streamableHttp.ts
+++ b/packages/client/src/client/streamableHttp.ts
@@ -34,10 +34,14 @@ import {
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- referenced via {@linkcode} in finishAuth JSDoc
import type { IssuerMismatchError } from './authErrors';
import { InsufficientScopeError } from './authErrors';
+import { markAuthSeamEscape } from './authSeam';
/** Default cap on step-up re-authorization retries within a single send/stream-open. */
const DEFAULT_MAX_STEP_UP_RETRIES = 1;
+/** The parsed 403 `insufficient_scope` challenge handed to the step-up flow. */
+type StepUpChallenge = { scope?: string; resourceMetadataUrl?: URL; errorDescription?: string; statusText?: string; text?: string | null };
+
// Default reconnection options for StreamableHTTP connections
const DEFAULT_STREAMABLE_HTTP_RECONNECTION_OPTIONS: StreamableHTTPReconnectionOptions = {
initialReconnectionDelay: 1000,
@@ -367,10 +371,18 @@ export class StreamableHTTPClientTransport implements Transport {
* `_startOrAuthSse` path so both apply the same `'throw'` short-circuit,
* the same superset-gated refresh bypass, and the same retry cap.
*/
- private async _stepUpAuthorize(
- challenge: { scope?: string; resourceMetadataUrl?: URL; errorDescription?: string; statusText?: string; text?: string | null },
- stepUpRetries: number
- ): Promise<'AUTHORIZED' | 'REDIRECT'> {
+ private async _stepUpAuthorize(challenge: StepUpChallenge, stepUpRetries: number): Promise<'AUTHORIZED' | 'REDIRECT'> {
+ // Auth-seam stamp, method-level: covers the InsufficientScopeError
+ // throws, the retry-limit SdkHttpError, the tokens() read, and every
+ // auth() escape (typed or not).
+ try {
+ return await this._stepUpAuthorizeInner(challenge, stepUpRetries);
+ } catch (error) {
+ throw markAuthSeamEscape(error);
+ }
+ }
+
+ private async _stepUpAuthorizeInner(challenge: StepUpChallenge, stepUpRetries: number): Promise<'AUTHORIZED' | 'REDIRECT'> {
if (this._onInsufficientScope === 'throw') {
throw new InsufficientScopeError({
requiredScope: challenge.scope,
@@ -422,7 +434,14 @@ export class StreamableHTTPClientTransport implements Transport {
private async _commonHeaders(): Promise {
const headers: RequestInit['headers'] & Record = {};
- const token = await this._authProvider?.token();
+ let token: string | undefined;
+ try {
+ token = await this._authProvider?.token();
+ } catch (error) {
+ // Auth-seam stamp: a throwing token() is an auth failure, never a
+ // network failure.
+ throw markAuthSeamEscape(error);
+ }
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
@@ -543,23 +562,31 @@ export class StreamableHTTPClientTransport implements Transport {
}
if (this._authProvider.onUnauthorized && !isAuthRetry) {
- await this._authProvider.onUnauthorized({
- response,
- serverUrl: this._url,
- fetchFn: this._fetchWithInit
- });
+ try {
+ await this._authProvider.onUnauthorized({
+ response,
+ serverUrl: this._url,
+ fetchFn: this._fetchWithInit
+ });
+ } catch (error) {
+ // Auth-seam stamp: covers the SDK's OAuth flow and
+ // custom onUnauthorized callbacks alike.
+ throw markAuthSeamEscape(error);
+ }
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
return this._startOrAuthSse(options, true, stepUpRetries);
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
- throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
- status: 401,
- statusText: response.statusText
- });
+ throw markAuthSeamEscape(
+ new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
+ status: 401,
+ statusText: response.statusText
+ })
+ );
}
- throw new UnauthorizedError();
+ throw markAuthSeamEscape(new UnauthorizedError());
}
if (response.status === 403) {
@@ -571,7 +598,7 @@ export class StreamableHTTPClientTransport implements Transport {
stepUpRetries
);
if (result !== 'AUTHORIZED') {
- throw new UnauthorizedError();
+ throw markAuthSeamEscape(new UnauthorizedError());
}
return this._startOrAuthSse(options, isAuthRetry, stepUpRetries + 1);
}
@@ -997,23 +1024,31 @@ export class StreamableHTTPClientTransport implements Transport {
}
if (this._authProvider.onUnauthorized && !isAuthRetry) {
- await this._authProvider.onUnauthorized({
- response,
- serverUrl: this._url,
- fetchFn: this._fetchWithInit
- });
+ try {
+ await this._authProvider.onUnauthorized({
+ response,
+ serverUrl: this._url,
+ fetchFn: this._fetchWithInit
+ });
+ } catch (error) {
+ // Auth-seam stamp: covers the SDK's OAuth flow and
+ // custom onUnauthorized callbacks alike.
+ throw markAuthSeamEscape(error);
+ }
await response.text?.().catch(() => {});
// Purposely _not_ awaited, so we don't call onerror twice
return this._send(message, options, true, stepUpRetries);
}
await response.text?.().catch(() => {});
if (isAuthRetry) {
- throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
- status: 401,
- statusText: response.statusText
- });
+ throw markAuthSeamEscape(
+ new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
+ status: 401,
+ statusText: response.statusText
+ })
+ );
}
- throw new UnauthorizedError();
+ throw markAuthSeamEscape(new UnauthorizedError());
}
const text = await response.text?.().catch(() => null);
@@ -1027,7 +1062,7 @@ export class StreamableHTTPClientTransport implements Transport {
stepUpRetries
);
if (result !== 'AUTHORIZED') {
- throw new UnauthorizedError();
+ throw markAuthSeamEscape(new UnauthorizedError());
}
return this._send(message, options, isAuthRetry, stepUpRetries + 1);
}
diff --git a/packages/client/src/client/versionNegotiation.ts b/packages/client/src/client/versionNegotiation.ts
index 818ca2bb0d..9b89d962ba 100644
--- a/packages/client/src/client/versionNegotiation.ts
+++ b/packages/client/src/client/versionNegotiation.ts
@@ -26,6 +26,7 @@ import {
} from '@modelcontextprotocol/core-internal';
import { UnauthorizedError } from './auth';
+import { isAuthSeamEscape } from './authSeam';
import type { ProbeEnvironment, ProbeOutcome, ProbeTransportKind, ProbeVerdict } from './probeClassifier';
import { classifyProbeOutcome } from './probeClassifier';
@@ -381,23 +382,33 @@ function normalizeReply(reply: RawProbeReply, timeoutMs: number): ProbeOutcome {
}
case 'send-error': {
const error = reply.error;
- if (error instanceof SdkHttpError) {
- const text = (error.data as { text?: unknown } | undefined)?.text;
- return { kind: 'http-error', status: error.data.status, body: typeof text === 'string' ? text : undefined };
- }
- const isUnauthorized =
+ const isAuthOutcome =
+ // Provenance recorded at the throw boundary: the transport
+ // stamps every error escaping one of its auth seams (the
+ // token() read, onUnauthorized invocations — SDK flow and
+ // custom callbacks alike — the 403 step-up method, and its
+ // own auth-failure constructions). Never reconstructed here
+ // from error types.
+ isAuthSeamEscape(error) ||
+ // The published contract for foreign transports: an
+ // UnauthorizedError by brand, or by name for one thrown by a
+ // differently bundled SDK copy at a skewed version or an auth
+ // middleware's own class.
error instanceof UnauthorizedError ||
- // Name fallback for auth errors the brand cannot reach: an
- // UnauthorizedError from a differently bundled SDK copy at a
- // skewed version, or an auth middleware's own class.
(error instanceof Error && error.name === 'UnauthorizedError');
- if (isUnauthorized) {
- // Auth-gated server. (The pre-branding name-string check alone
- // could never fire for the SDK's own class — it did not set
- // `.name` — so these send failures fell through to the generic
- // network-error wrap.)
+ if (isAuthOutcome) {
+ // Auth-gated server: propagate unchanged.
return { kind: 'auth-required', error: error as Error };
}
+ if (error instanceof SdkHttpError) {
+ const text = (error.data as { text?: unknown } | undefined)?.text;
+ return {
+ kind: 'http-error',
+ status: error.data.status,
+ body: typeof text === 'string' ? text : undefined,
+ statusText: error.data.statusText
+ };
+ }
return { kind: 'network-error', error };
}
case 'closed': {
diff --git a/packages/client/test/client/probeAuthSeam.test.ts b/packages/client/test/client/probeAuthSeam.test.ts
new file mode 100644
index 0000000000..a347d71cd5
--- /dev/null
+++ b/packages/client/test/client/probeAuthSeam.test.ts
@@ -0,0 +1,288 @@
+/**
+ * Fault injection per transport auth seam, adapted from the PR #2564
+ * neutral-review measurement harness: each scenario drives negotiateEra()
+ * with a REAL StreamableHTTPClientTransport and a mocked fetch, so the full
+ * transport 401/403 handling and auth() flow execute for real.
+ *
+ * The invariant under test: any error escaping a stamped auth seam reaches
+ * the caller IDENTITY-PRESERVED (same object; instanceof and diagnostics
+ * intact) as an auth outcome — never a legacy verdict (the probe's browser
+ * CORS row must not consume auth-flow TypeErrors), never a rewrap. Errors
+ * thrown outside the stamped seams keep their pre-existing behavior.
+ */
+import { OAuthError, SdkError, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal';
+import { describe, expect, test } from 'vitest';
+
+import type { AuthProvider, OAuthClientProvider } from '../../src/client/auth';
+import { UnauthorizedError } from '../../src/client/auth';
+import { StreamableHTTPClientTransport } from '../../src/client/streamableHttp';
+import { negotiateEra } from '../../src/client/versionNegotiation';
+
+const SERVER = 'https://server.example.com/mcp';
+const AS = 'https://auth.example.com';
+
+const AS_METADATA = {
+ issuer: AS,
+ authorization_endpoint: `${AS}/authorize`,
+ token_endpoint: `${AS}/token`,
+ registration_endpoint: `${AS}/register`,
+ response_types_supported: ['code'],
+ code_challenge_methods_supported: ['S256']
+};
+
+function freshProvider(): OAuthClientProvider {
+ return {
+ get redirectUrl() {
+ return 'http://localhost:3000/callback';
+ },
+ get clientMetadata() {
+ return { redirect_uris: ['http://localhost:3000/callback'] };
+ },
+ clientInformation: () => undefined,
+ saveClientInformation: () => {},
+ tokens: () => undefined,
+ saveTokens: () => {},
+ redirectToAuthorization: () => {},
+ saveCodeVerifier: () => {},
+ codeVerifier: () => 'verifier'
+ };
+}
+
+/** fetch mock: 401 wall on the MCP endpoint, real discovery, DCR delegated. */
+function authWallFetch(onRegister: () => Promise): typeof fetch {
+ return (async (input: string | URL | Request, _init?: RequestInit): Promise => {
+ const url = new URL(String(input instanceof Request ? input.url : input));
+ if (url.href === SERVER || url.pathname === '/mcp') {
+ return new Response('Unauthorized', {
+ status: 401,
+ headers: {
+ 'WWW-Authenticate': `Bearer resource_metadata="https://server.example.com/.well-known/oauth-protected-resource/mcp"`
+ }
+ });
+ }
+ if (url.pathname.includes('oauth-protected-resource')) {
+ return Response.json({ resource: SERVER, authorization_servers: [AS] });
+ }
+ if (url.pathname.includes('oauth-authorization-server') || url.pathname.includes('openid-configuration')) {
+ return Response.json(AS_METADATA);
+ }
+ if (url.pathname === '/register') {
+ return onRegister();
+ }
+ return new Response('Not Found', { status: 404 });
+ }) as typeof fetch;
+}
+
+async function runProbe(transport: StreamableHTTPClientTransport, environment: 'node' | 'browser') {
+ return negotiateEra(
+ { kind: 'auto', modernVersions: ['2026-07-28'], fallbackAvailable: true, probe: {} },
+ {
+ transport,
+ clientInfo: { name: 'seam-test', version: '0' },
+ capabilities: {},
+ environment,
+ transportKind: 'http',
+ defaultTimeoutMs: 3000
+ }
+ ).then(
+ result => ({ settled: 'resolved' as const, result, error: undefined as unknown }),
+ (error: unknown) => ({ settled: 'rejected' as const, result: undefined, error })
+ );
+}
+
+describe('stamped-seam fault injection (identity-preserving auth outcomes, never legacy)', () => {
+ test('DCR POST throws TypeError inside the SDK auth flow — browser: the raw TypeError propagates, no CORS-legacy verdict', async () => {
+ const dcrError = new TypeError('Failed to fetch');
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
+ authProvider: freshProvider(),
+ fetch: authWallFetch(() => Promise.reject(dcrError))
+ });
+ const out = await runProbe(transport, 'browser');
+ expect(out.settled).toBe('rejected');
+ expect(out.error).toBe(dcrError);
+ });
+
+ test('DCR POST throws TypeError — node: same raw propagation, no network-error rewrap', async () => {
+ const dcrError = new TypeError('Failed to fetch');
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
+ authProvider: freshProvider(),
+ fetch: authWallFetch(() => Promise.reject(dcrError))
+ });
+ const out = await runProbe(transport, 'node');
+ expect(out.settled).toBe('rejected');
+ expect(out.error).toBe(dcrError);
+ });
+
+ test("401 persists after re-auth: the transport's ClientHttpAuthentication diagnostic reaches the caller intact", async () => {
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
+ authProvider: {
+ token: async () => 'stale-token',
+ onUnauthorized: async () => {}
+ },
+ fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
+ });
+ const out = await runProbe(transport, 'node');
+ expect(out.settled).toBe('rejected');
+ expect(out.error).toBeInstanceOf(SdkHttpError);
+ expect((out.error as SdkHttpError).code).toBe(SdkErrorCode.ClientHttpAuthentication);
+ expect((out.error as Error).message).toContain('after re-authentication');
+ });
+
+ test('403 insufficient_scope with no provider to drive step-up: the raw InsufficientScopeError propagates', async () => {
+ const fetchMock = (async (input: string | URL | Request): Promise => {
+ const url = new URL(String(input instanceof Request ? input.url : input));
+ if (url.href === SERVER || url.pathname === '/mcp') {
+ return new Response('Forbidden', {
+ status: 403,
+ headers: { 'WWW-Authenticate': 'Bearer error="insufficient_scope", scope="mcp:tools"' }
+ });
+ }
+ return new Response('Not Found', { status: 404 });
+ }) as typeof fetch;
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), { fetch: fetchMock });
+ const out = await runProbe(transport, 'node');
+ expect(out.settled).toBe('rejected');
+ expect((out.error as Error).name).toBe('InsufficientScopeError');
+ expect((out.error as Error).message).toContain('mcp:tools');
+ });
+
+ test('user callback (clientInformation) throws a custom class inside auth(): the raw instance propagates — instanceof works', async () => {
+ class UserDbError extends Error {
+ override readonly name = 'UserDbError';
+ }
+ const userError = new UserDbError('user db exploded');
+ const provider = freshProvider();
+ provider.clientInformation = () => {
+ throw userError;
+ };
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
+ authProvider: provider,
+ fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
+ });
+ const out = await runProbe(transport, 'node');
+ expect(out.settled).toBe('rejected');
+ expect(out.error).toBe(userError);
+ expect(out.error).toBeInstanceOf(UserDbError);
+ });
+
+ test('token endpoint rejects with a non-recoverable OAuthError: the raw OAuthError (code intact) propagates', async () => {
+ const fetchMock = (async (input: string | URL | Request): Promise => {
+ const url = new URL(String(input instanceof Request ? input.url : input));
+ if (url.href === SERVER || url.pathname === '/mcp') {
+ return new Response('Unauthorized', {
+ status: 401,
+ headers: {
+ 'WWW-Authenticate': `Bearer resource_metadata="https://server.example.com/.well-known/oauth-protected-resource/mcp"`
+ }
+ });
+ }
+ if (url.pathname.includes('oauth-protected-resource')) {
+ return Response.json({ resource: SERVER, authorization_servers: [AS] });
+ }
+ if (url.pathname.includes('oauth-authorization-server') || url.pathname.includes('openid-configuration')) {
+ return Response.json(AS_METADATA);
+ }
+ if (url.pathname === '/token') {
+ return Response.json({ error: 'invalid_scope', error_description: 'scope not granted' }, { status: 400 });
+ }
+ return new Response('Not Found', { status: 404 });
+ }) as typeof fetch;
+ const provider = freshProvider();
+ provider.clientInformation = () => ({ client_id: 'abc', issuer: AS });
+ provider.tokens = () => ({ access_token: 'tok', refresh_token: 'rt', token_type: 'Bearer', issuer: AS });
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), { authProvider: provider, fetch: fetchMock });
+ const out = await runProbe(transport, 'node');
+ expect(out.settled).toBe('rejected');
+ expect(out.error).toBeInstanceOf(OAuthError);
+ expect((out.error as OAuthError).code).toBe('invalid_scope');
+ });
+
+ test('healthy flow ending in REDIRECT: UnauthorizedError propagates (the finishAuth contract)', async () => {
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
+ authProvider: freshProvider(),
+ fetch: authWallFetch(() =>
+ Promise.resolve(Response.json({ client_id: 'abc', redirect_uris: ['http://localhost:3000/callback'] }, { status: 201 }))
+ )
+ });
+ const out = await runProbe(transport, 'node');
+ expect(out.settled).toBe('rejected');
+ expect(out.error).toBeInstanceOf(UnauthorizedError);
+ });
+
+ test('R6: token() throws at the _commonHeaders read — browser: raw TypeError propagates, never the CORS-legacy verdict', async () => {
+ const tokenError = new TypeError("Cannot read properties of undefined (reading 'access_token')");
+ const provider: AuthProvider = {
+ token: () => {
+ throw tokenError;
+ }
+ };
+ let fetched = 0;
+ const fetchMock = (async (): Promise => {
+ fetched++;
+ return new Response(null, { status: 500 });
+ }) as typeof fetch;
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), { authProvider: provider, fetch: fetchMock });
+ const out = await runProbe(transport, 'browser');
+ expect(out.settled).toBe('rejected');
+ expect(out.error).toBe(tokenError);
+ // The failure happened before any network I/O — nothing was fetched,
+ // and in particular no legacy initialize followed.
+ expect(fetched).toBe(0);
+ });
+
+ test('B12: a CUSTOM onUnauthorized callback throws TypeError — browser: raw propagation, never the CORS-legacy verdict', async () => {
+ const cbError = new TypeError('custom re-auth crashed');
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
+ authProvider: {
+ token: async () => undefined,
+ onUnauthorized: async () => {
+ throw cbError;
+ }
+ },
+ fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
+ });
+ const out = await runProbe(transport, 'browser');
+ expect(out.settled).toBe('rejected');
+ expect(out.error).toBe(cbError);
+ });
+
+ test('B11 regression pin: on the NON-probe path a throwing custom onUnauthorized propagates the raw instance from send()', async () => {
+ class CallbackError extends Error {
+ override readonly name = 'CallbackError';
+ }
+ const cbError = new CallbackError('boom');
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), {
+ authProvider: {
+ token: async () => undefined,
+ onUnauthorized: async () => {
+ throw cbError;
+ }
+ },
+ fetch: authWallFetch(() => Promise.resolve(new Response(null, { status: 500 })))
+ });
+ await transport.start();
+ const rejection = await transport
+ .send({ jsonrpc: '2.0', id: 1, method: 'tools/list' })
+ .then(() => {
+ throw new Error('send unexpectedly resolved');
+ })
+ .catch((e: unknown) => e);
+ // Identity preserved: the stamp is a non-enumerable marker, not a wrap.
+ expect(rejection).toBe(cbError);
+ expect(rejection).toBeInstanceOf(CallbackError);
+ await transport.close();
+ });
+
+ test('control: an error thrown outside every auth seam (the probe POST itself failing) stays a typed negotiation error', async () => {
+ const netError = new Error('socket hang up');
+ const fetchMock = (async (): Promise => {
+ throw netError;
+ }) as typeof fetch;
+ const transport = new StreamableHTTPClientTransport(new URL(SERVER), { fetch: fetchMock });
+ const out = await runProbe(transport, 'node');
+ expect(out.settled).toBe('rejected');
+ expect(out.error).toBeInstanceOf(SdkError);
+ expect((out.error as SdkError).code).toBe(SdkErrorCode.EraNegotiationFailed);
+ expect(((out.error as SdkError).data as { cause?: unknown }).cause).toBe(netError);
+ });
+});
diff --git a/packages/client/test/client/probeFixtureCorpus.test.ts b/packages/client/test/client/probeFixtureCorpus.test.ts
index d74ed0b5ad..8dd2eefb4f 100644
--- a/packages/client/test/client/probeFixtureCorpus.test.ts
+++ b/packages/client/test/client/probeFixtureCorpus.test.ts
@@ -19,7 +19,7 @@
* probe wire shape (string id, `server/discover` first, never a real request).
*/
import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-internal';
-import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
+import { LATEST_PROTOCOL_VERSION, PROTOCOL_VERSION_META_KEY, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal';
import { describe, expect, it } from 'vitest';
import { Client } from '../../src/client/client';
@@ -161,6 +161,56 @@ const CORPUS: CorpusRow[] = [
outcome: { kind: 'result', result: { content: [{ type: 'text', text: `supportedVersions: ["${MODERN}"]` }] } },
expected: 'legacy'
},
+ // --- Auth statuses are never era evidence (#2561): an auth-protected
+ // server is not a legacy server, whatever the body says — typed failure,
+ // never initialize (fallbackAvailable is true in every row here).
+ {
+ name: 'auth: HTTP 401 challenge (WWW-Authenticate rides the header; body carries the OAuth error JSON) → typed auth failure, never legacy',
+ outcome: { kind: 'http-error', status: 401, body: '{"error":"invalid_token","error_description":"Missing bearer token"}' },
+ expected: 'error'
+ },
+ {
+ name: 'auth: bare HTTP 401 (no body) → typed auth failure, never legacy',
+ outcome: { kind: 'http-error', status: 401 },
+ expected: 'error'
+ },
+ {
+ name: 'auth: bare HTTP 403 denial (no body) → typed auth failure, never legacy',
+ outcome: { kind: 'http-error', status: 403 },
+ expected: 'error'
+ },
+ {
+ name: 'auth: a 401 whose body parses as a JSON-RPC error is still an auth failure — the auth layer wrote that body, not server/discover',
+ outcome: { kind: 'http-error', status: 401, body: DEPLOYED_SESSION_REQUIRED_BODY },
+ expected: 'error'
+ },
+ // --- Server failures are never era evidence: the spec keys the HTTP
+ // legacy signal to a 4xx rejection, so a 5xx (mid-deploy proxy, crashed
+ // backend) rejects typed instead of demoting a modern server to legacy.
+ {
+ name: '5xx: HTTP 503 with an HTML error page (mid-deploy modern server) → typed error, never legacy',
+ outcome: { kind: 'http-error', status: 503, body: 'Service Unavailable' },
+ expected: 'error'
+ },
+ {
+ name: '5xx: bare HTTP 500 (no body) → typed error, never legacy',
+ outcome: { kind: 'http-error', status: 500 },
+ expected: 'error'
+ },
+ {
+ name: '5xx: HTTP 502 with a JSON (but not JSON-RPC) gateway body → typed error, never legacy',
+ outcome: { kind: 'http-error', status: 502, body: '{"message":"upstream connect error"}' },
+ expected: 'error'
+ },
+ {
+ name: '5xx: HTTP 500 whose body parses as a JSON-RPC error (-32603 from a crashed handler) is still a server failure — 5xx ranks above the body parse',
+ outcome: {
+ kind: 'http-error',
+ status: 500,
+ body: JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32_603, message: 'Internal error' } })
+ },
+ expected: 'error'
+ },
// --- Q12 transport-aware timeout rows (stdio falls back, HTTP stays a typed error).
{
name: 'timeout on stdio → legacy fallback (the stdio backward-compatibility rule)',
@@ -209,6 +259,40 @@ describe('T9/T11 merged probe fixture corpus (probe classifier)', () => {
});
}
+ it('the 401/403 typed failures carry the auth codes (never EraNegotiationFailed — auth walls must not enter era-recovery flows) plus status, reason phrase, and response text', () => {
+ for (const [status, code, statusText, body] of [
+ [401, SdkErrorCode.ClientHttpAuthentication, 'Unauthorized', '{"error":"invalid_token"}'],
+ [403, SdkErrorCode.ClientHttpForbidden, 'Forbidden', 'nope']
+ ] as const) {
+ const verdict = classifyProbeOutcome({ kind: 'http-error', status, statusText, body }, baseContext);
+ expect(verdict.kind).toBe('error');
+ if (verdict.kind === 'error') {
+ expect(verdict.error).toBeInstanceOf(SdkHttpError);
+ const error = verdict.error as SdkHttpError;
+ expect(error.code).toBe(code);
+ expect(error.status).toBe(status);
+ expect(error.statusText).toBe(statusText);
+ expect(error.data.text).toBe(body);
+ expect(error.message).toContain(String(status));
+ }
+ }
+ });
+
+ it('the 5xx typed failure is EraNegotiationFailed (a genuine negotiation failure) carrying the status', () => {
+ const verdict = classifyProbeOutcome(
+ { kind: 'http-error', status: 503, statusText: 'Service Unavailable', body: 'down' },
+ baseContext
+ );
+ expect(verdict.kind).toBe('error');
+ if (verdict.kind === 'error') {
+ expect(verdict.error).toBeInstanceOf(SdkHttpError);
+ const error = verdict.error as SdkHttpError;
+ expect(error.code).toBe(SdkErrorCode.EraNegotiationFailed);
+ expect(error.status).toBe(503);
+ expect(error.message).toContain('503');
+ }
+ });
+
it('a DiscoverResult with a mutual version is the only result shape that yields a modern verdict', () => {
const verdict = classifyProbeOutcome(
{
diff --git a/packages/client/test/client/versionNegotiation.test.ts b/packages/client/test/client/versionNegotiation.test.ts
index e197344b90..60c9a2aaba 100644
--- a/packages/client/test/client/versionNegotiation.test.ts
+++ b/packages/client/test/client/versionNegotiation.test.ts
@@ -11,14 +11,18 @@
import type { JSONRPCMessage, JSONRPCRequest, Transport } from '@modelcontextprotocol/core-internal';
import {
isJSONRPCRequest,
+ OAuthError,
PROTOCOL_VERSION_META_KEY,
SdkError,
SdkErrorCode,
+ SdkHttpError,
UnsupportedProtocolVersionError
} from '@modelcontextprotocol/core-internal';
import { describe, expect, test } from 'vitest';
import { UnauthorizedError } from '../../src/client/auth';
+import { InsufficientScopeError } from '../../src/client/authErrors';
+import { markAuthSeamEscape } from '../../src/client/authSeam';
import { Client } from '../../src/client/client';
import type { StreamableHTTPClientTransportOptions } from '../../src/client/streamableHttp';
import type { StdioServerParameters } from '../../src/client/stdio';
@@ -1103,6 +1107,53 @@ describe('probe send-error classification', () => {
expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false);
});
+ // Any error stamped at a transport auth seam propagates unchanged —
+ // identity preserved (same object, instanceof and diagnostics intact),
+ // never a legacy fallback, never a rewrap. Provenance comes from the
+ // stamp, not the error type: the rows deliberately span typed SDK
+ // errors, foreign classes, and a bare TypeError.
+ const seamEscapes: Array<[string, () => Error]> = [
+ [
+ "the transport's post-re-auth 401 (SdkHttpError ClientHttpAuthentication)",
+ () =>
+ new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, 'Server returned 401 after re-authentication', {
+ status: 401,
+ statusText: 'Unauthorized'
+ })
+ ],
+ [
+ 'an InsufficientScopeError from a step-up challenge with no provider',
+ () => new InsufficientScopeError({ requiredScope: 'mcp:tools' })
+ ],
+ ['an OAuthError (invalid_grant on a revoked refresh token)', () => new OAuthError('invalid_grant', 'Refresh token was revoked')],
+ ['a bare TypeError from a DCR sub-fetch inside the auth flow', () => new TypeError('Failed to fetch')],
+ [
+ 'a custom error class thrown by a user onUnauthorized callback',
+ () => {
+ class UserDbError extends Error {
+ override readonly name = 'UserDbError';
+ }
+ return new UserDbError('user db exploded');
+ }
+ ]
+ ];
+ for (const [label, make] of seamEscapes) {
+ test(`stamped seam escape propagates unchanged: ${label}`, async () => {
+ const reason = markAuthSeamEscape(make());
+ const transport = new AuthGatedTransport(reason);
+ const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
+
+ const rejection = await client.connect(transport).then(
+ () => {
+ throw new Error('connect unexpectedly resolved');
+ },
+ (e: unknown) => e
+ );
+ expect(rejection).toBe(reason);
+ expect(requests(transport.sent).some(r => r.method === 'initialize')).toBe(false);
+ });
+ }
+
test('a plain send failure stays a typed negotiation error — no fallback runs', async () => {
const transport = new AuthGatedTransport(new Error('connection refused'));
const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
diff --git a/packages/codemod/CHANGELOG.md b/packages/codemod/CHANGELOG.md
index 1e76d334bd..3506a1e5f9 100644
--- a/packages/codemod/CHANGELOG.md
+++ b/packages/codemod/CHANGELOG.md
@@ -1,5 +1,41 @@
# @modelcontextprotocol/codemod
+## 2.0.0
+
+### Patch Changes
+
+- [#2402](https://github.com/modelcontextprotocol/typescript-sdk/pull/2402) [`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a) Thanks [@felixweinberger](https://github.com/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`).
+
+- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
+ and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
+ adds a `require` condition so `require('@modelcontextprotocol/…')` works from
+ CommonJS consumers. Output extensions are normalized across all packages
+ (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
+ public import paths are unchanged.
+
+- [#2486](https://github.com/modelcontextprotocol/typescript-sdk/pull/2486) [`ee8267a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ee8267af4a55efda3cebdb35a1ebb1d797e4235d) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Version the codemod together with the core SDK packages, matching the migration guide's shared-version guarantee.
+
+- [#2412](https://github.com/modelcontextprotocol/typescript-sdk/pull/2412) [`ef120b2`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ef120b2be0c3c3d80468c3d4a9f79be30bb0c0a3) Thanks [@felixweinberger](https://github.com/felixweinberger)! - v1-to-v2 migration fixes from continued real-world migrations (codemod iterations 5).
+
+- [#2419](https://github.com/modelcontextprotocol/typescript-sdk/pull/2419) [`79dc162`](https://github.com/modelcontextprotocol/typescript-sdk/commit/79dc162efcb4e1f7b820bfb6068906483cf71ec7) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Read the v2 package versions the codemod writes into migrated `package.json` files directly from the workspace manifests at build time, replacing the committed generated `versions.ts` (which went stale after every release and made source builds write outdated versions).
+
+- [#2501](https://github.com/modelcontextprotocol/typescript-sdk/pull/2501) [`1480241`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1480241e2a2a7f0ceee8e7723b2adcf88579bb36) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Export the `Protocol` base class and `mergeCapabilities` from the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` package roots, restoring the v1 import for consumers that subclass `Protocol` (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 `Protocol` and `mergeCapabilities` imports from `shared/protocol.js` to the client or server package root, like the module's other symbols, instead of dropping them with an action-required marker.
+
+- [#2420](https://github.com/modelcontextprotocol/typescript-sdk/pull/2420) [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`:
+ `requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare
+ Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and
+ `bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined
+ here. The Express middleware adapts the same core and is unchanged in
+ behavior, except that `WWW-Authenticate` challenge values are now RFC 7235
+ quoted-string sanitized (quotes and backslashes escaped, control and
+ non-ASCII characters replaced); `@modelcontextprotocol/express` re-exports
+ `OAuthTokenVerifier` as before.
+
## 2.0.0-beta.5
### Patch Changes
diff --git a/packages/codemod/README.md b/packages/codemod/README.md
index 33aaf88a81..05e1fb29cd 100644
--- a/packages/codemod/README.md
+++ b/packages/codemod/README.md
@@ -5,10 +5,10 @@ Codemods for migrating MCP TypeScript SDK code between major versions.
## Usage
```bash
-npx @modelcontextprotocol/codemod@beta v1-to-v2 .
+npx @modelcontextprotocol/codemod@latest v1-to-v2 .
# or a single source file (manifest changes are reported, not applied)
-npx @modelcontextprotocol/codemod@beta v1-to-v2 src/server.ts
+npx @modelcontextprotocol/codemod@latest v1-to-v2 src/server.ts
```
The codemod rewrites TypeScript and JavaScript source files
diff --git a/packages/codemod/package.json b/packages/codemod/package.json
index 5cbaf4c826..09cabe0bcf 100644
--- a/packages/codemod/package.json
+++ b/packages/codemod/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/codemod",
- "version": "2.0.0-beta.5",
+ "version": "2.0.0",
"description": "Codemod to migrate MCP TypeScript SDK code from v1 to v2",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/packages/core-internal/CHANGELOG.md b/packages/core-internal/CHANGELOG.md
index 7e24d8ccdc..450c293bf3 100644
--- a/packages/core-internal/CHANGELOG.md
+++ b/packages/core-internal/CHANGELOG.md
@@ -1,5 +1,42 @@
# @modelcontextprotocol/core-internal
+## 2.0.0
+
+### Minor Changes
+
+- [#2501](https://github.com/modelcontextprotocol/typescript-sdk/pull/2501) [`1480241`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1480241e2a2a7f0ceee8e7723b2adcf88579bb36) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Export the `Protocol` base class and `mergeCapabilities` from the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` package roots, restoring the v1 import for consumers that subclass `Protocol` (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 `Protocol` and `mergeCapabilities` imports from `shared/protocol.js` to the client or server package root, like the module's other symbols, instead of dropping them with an action-required marker.
+
+- [#2513](https://github.com/modelcontextprotocol/typescript-sdk/pull/2513) [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Align the 2026-07-28 wire with the final revision (spec PR #3002): `serverInfo` moves from the `DiscoverResult` body to the result `_meta`, and the per-request envelope's `clientInfo` demotes 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 body `serverInfo` failed parse, so the probe misclassified the server as legacy and attempted an `initialize` handshake 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 omit `clientInfo`.
+
+ Now:
+ - The 2026 wire schemas are the final revision exactly: no body `serverInfo` on `DiscoverResult`, envelope `clientInfo` optional (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-built `subscriptions/listen` graceful-close results — the spec's `SubscriptionsListenResultMeta` extends `ResultMetaObject`.
+ - Clients keep sending `clientInfo` and read server identity from the discover result's `_meta` only. A server that stamps no identity is anonymous: `getServerVersion()` is `undefined` and the response cache partitions under a per-connection surrogate. A malformed `_meta` serverInfo value is treated as absent on receive (the spec marks the field self-reported, unverified, and display-only).
+ - Breaking type changes: `DiscoverResult` no longer declares `serverInfo`; `RequestMetaEnvelope`'s `clientInfo` is optional. New public constant `SERVER_INFO_META_KEY` (`'io.modelcontextprotocol/serverInfo'`).
+
+- [#2369](https://github.com/modelcontextprotocol/typescript-sdk/pull/2369) [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Allow `inputRequired.elicit()` to accept a Standard Schema such as a Zod object for `requestedSchema`. The builder converts it to MCP's restricted form-elicitation JSON Schema, while the same schema can validate and type the response through `acceptedContent()` on handler re-entry. Zod formats mapping to `email`, `uri`, `date`, and `date-time` are supported. Shapes the restricted schema cannot express reject before anything is sent — nested objects, `.regex()` and customized zod format patterns, exclusive number bounds (`.positive()`/`.gt()`), literal unions (use `z.enum` or `z.literal(['a', 'b'])`), and non-spec root keywords like `z.strictObject()`'s `additionalProperties`.
+
+### Patch Changes
+
+- [#2456](https://github.com/modelcontextprotocol/typescript-sdk/pull/2456) [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Restore the v1 parse tolerance for `CallToolResult.content`: an inbound legacy-era `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error 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 `content` for 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 foreign `resultType`), 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 to `content: []` before era validation on every leg, reaching the wire spec-valid.
+
+ Conscious call: the nested sampling `ToolResultContentSchema` stays spec-strict — v1 had defaulted its `content` too, but it is params-side (tool results a caller authors into a sampling message), deliberately not restored.
+
+- [#2399](https://github.com/modelcontextprotocol/typescript-sdk/pull/2399) [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Return HTTP 400 for a `MissingRequiredClientCapabilityError` (`-32021`) produced after dispatch. The spec mandates `400 Bad Request` for this error with no condition on where it arose, but only the pre-dispatch capability gate honored that; the post-handler emission — the `input_required` gate rejecting an embedded request whose required capability the caller did not declare — surfaced in-band on HTTP 200. The JSON-RPC error body is unchanged, every other error code (including a handler relaying a downstream peer's `-32020`/`-32022`) keeps the origin-keyed in-band behavior, and the mapping only applies while the response is uncommitted: an exchange that already streamed — or one hosted with `responseMode: 'sse'`, which opens its stream at dispatch end — keeps its committed 200 and carries the error in-stream.
+
+- [#2453](https://github.com/modelcontextprotocol/typescript-sdk/pull/2453) [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Strip RFC 9110 optional whitespace around inbound `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` values before classifying and validating modern HTTP requests. This keeps valid requests portable across Fetch runtimes that expose raw leading or trailing SP/HTAB through `Headers.get()`.
+
+- Updated dependencies [[`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a), [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4), [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313)]:
+ - @modelcontextprotocol/core@2.0.0
+
## 2.0.0-beta.4
### Minor Changes
diff --git a/packages/core-internal/package.json b/packages/core-internal/package.json
index 0440f98fda..44a4be1cd3 100644
--- a/packages/core-internal/package.json
+++ b/packages/core-internal/package.json
@@ -1,7 +1,7 @@
{
"name": "@modelcontextprotocol/core-internal",
"private": true,
- "version": "2.0.0-beta.4",
+ "version": "2.0.0",
"description": "Model Context Protocol implementation for TypeScript - Core package",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/packages/core-internal/src/errors/sdkErrors.ts b/packages/core-internal/src/errors/sdkErrors.ts
index 21f42f6ef8..0bc8f9a1ad 100644
--- a/packages/core-internal/src/errors/sdkErrors.ts
+++ b/packages/core-internal/src/errors/sdkErrors.ts
@@ -65,16 +65,34 @@ export enum SdkErrorCode {
/**
* Protocol-era negotiation at connect time failed without producing either a
* usable modern (2026-07-28+) era or a definitive legacy fallback signal —
- * e.g. the negotiation mode forbids falling back (`pin`), or the probe hit a
- * network failure (a typed connect error, never an era verdict).
+ * e.g. the negotiation mode forbids falling back (`pin`), the probe hit a
+ * network failure, or the server answered the probe with a 5xx (a typed
+ * connect error, never an era verdict).
*
- * Negotiation-phase only: this code is never used once an era is established.
+ * Negotiation-phase only: this code is never used once an era is
+ * established. Auth walls never carry it: a 401/403 rejecting the probe
+ * uses {@linkcode ClientHttpAuthentication} / {@linkcode ClientHttpForbidden}
+ * instead, so era-recovery flows keyed on this code (e.g. cached-verdict
+ * gateways) can never persist a verdict for an unauthorized exchange.
*/
EraNegotiationFailed = 'ERA_NEGOTIATION_FAILED',
// Transport errors
ClientHttpNotImplemented = 'CLIENT_HTTP_NOT_IMPLEMENTED',
+ /**
+ * HTTP 401 authentication failure: the transport's re-auth retry still got
+ * 401 (`Server returned 401 after re-authentication`), or the version
+ * negotiation probe was rejected 401 with no `authProvider` configured
+ * (`Version negotiation failed: the server requires authorization (HTTP 401)`).
+ * Carried on an {@linkcode SdkHttpError} with `status: 401`.
+ */
ClientHttpAuthentication = 'CLIENT_HTTP_AUTHENTICATION',
+ /**
+ * HTTP 403 denial: the step-up re-authorization retry limit was reached,
+ * or the version negotiation probe was rejected 403
+ * (`Version negotiation failed: the server denied access (HTTP 403)`).
+ * Carried on an {@linkcode SdkHttpError} with `status: 403`.
+ */
ClientHttpForbidden = 'CLIENT_HTTP_FORBIDDEN',
ClientHttpUnexpectedContent = 'CLIENT_HTTP_UNEXPECTED_CONTENT',
ClientHttpFailedToOpenStream = 'CLIENT_HTTP_FAILED_TO_OPEN_STREAM',
diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md
index 34b27a6f9d..d109bed7a6 100644
--- a/packages/core/CHANGELOG.md
+++ b/packages/core/CHANGELOG.md
@@ -1,5 +1,43 @@
# @modelcontextprotocol/core
+## 2.0.0
+
+### Minor Changes
+
+- [#2477](https://github.com/modelcontextprotocol/typescript-sdk/pull/2477) [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Move the schema source modules (spec schemas, OAuth schemas, protocol constants) into `@modelcontextprotocol/core` and 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/core` gains a `./internal` subpath (SDK-internal contract; may change in any release) and the four packages now version together.
+
+- [#2513](https://github.com/modelcontextprotocol/typescript-sdk/pull/2513) [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Align the 2026-07-28 wire with the final revision (spec PR #3002): `serverInfo` moves from the `DiscoverResult` body to the result `_meta`, and the per-request envelope's `clientInfo` demotes 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 body `serverInfo` failed parse, so the probe misclassified the server as legacy and attempted an `initialize` handshake 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 omit `clientInfo`.
+
+ Now:
+ - The 2026 wire schemas are the final revision exactly: no body `serverInfo` on `DiscoverResult`, envelope `clientInfo` optional (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-built `subscriptions/listen` graceful-close results — the spec's `SubscriptionsListenResultMeta` extends `ResultMetaObject`.
+ - Clients keep sending `clientInfo` and read server identity from the discover result's `_meta` only. A server that stamps no identity is anonymous: `getServerVersion()` is `undefined` and the response cache partitions under a per-connection surrogate. A malformed `_meta` serverInfo value is treated as absent on receive (the spec marks the field self-reported, unverified, and display-only).
+ - Breaking type changes: `DiscoverResult` no longer declares `serverInfo`; `RequestMetaEnvelope`'s `clientInfo` is optional. New public constant `SERVER_INFO_META_KEY` (`'io.modelcontextprotocol/serverInfo'`).
+
+### Patch Changes
+
+- [#2402](https://github.com/modelcontextprotocol/typescript-sdk/pull/2402) [`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a) Thanks [@felixweinberger](https://github.com/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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2456) [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Restore the v1 parse tolerance for `CallToolResult.content`: an inbound legacy-era `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error 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 `content` for 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 foreign `resultType`), 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 to `content: []` before era validation on every leg, reaching the wire spec-valid.
+
+ Conscious call: the nested sampling `ToolResultContentSchema` stays spec-strict — v1 had defaulted its `content` too, but it is params-side (tool results a caller authors into a sampling message), deliberately not restored.
+
+- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
+ and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
+ adds a `require` condition so `require('@modelcontextprotocol/…')` works from
+ CommonJS consumers. Output extensions are normalized across all packages
+ (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
+ public import paths are unchanged.
+
## 2.0.0-beta.5
### Minor Changes
diff --git a/packages/core/package.json b/packages/core/package.json
index ec24da58e5..0e02ff8b1e 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/core",
- "version": "2.0.0-beta.5",
+ "version": "2.0.0",
"description": "Model Context Protocol for TypeScript — public Zod schemas (spec + OAuth/OpenID)",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts
index c44ee17d2c..3a7dd5b56d 100644
--- a/packages/core/src/schemas.ts
+++ b/packages/core/src/schemas.ts
@@ -49,7 +49,7 @@ export const RequestMetaSchema = z.looseObject({
*/
export const BaseRequestParamsSchema = z.object({
/**
- * See [General fields: `_meta`](/specification/draft/basic/index#meta) for notes on `_meta` usage.
+ * See [General fields: `_meta`](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta) for notes on `_meta` usage.
*/
_meta: RequestMetaSchema.optional()
});
@@ -78,7 +78,7 @@ export const RequestSchema = z.object({
export const NotificationsParamsSchema = z.object({
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: RequestMetaSchema.optional()
@@ -113,7 +113,7 @@ export const ResultMetaObjectSchema = z.looseObject({
export const ResultSchema = z.looseObject({
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: ResultMetaObjectSchema.optional()
@@ -695,7 +695,7 @@ export const ResourceContentsSchema = z.object({
*/
mimeType: z.optional(z.string()),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
@@ -795,7 +795,7 @@ export const ResourceSchema = z.object({
annotations: AnnotationsSchema.optional(),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.optional(z.looseObject({}))
@@ -830,7 +830,7 @@ export const ResourceTemplateSchema = z.object({
annotations: AnnotationsSchema.optional(),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.optional(z.looseObject({}))
@@ -1054,7 +1054,7 @@ export const PromptSchema = z.object({
*/
arguments: z.optional(z.array(PromptArgumentSchema)),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.optional(z.looseObject({}))
@@ -1111,7 +1111,7 @@ export const TextContentSchema = z.object({
annotations: AnnotationsSchema.optional(),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
@@ -1137,7 +1137,7 @@ export const ImageContentSchema = z.object({
annotations: AnnotationsSchema.optional(),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
@@ -1163,7 +1163,7 @@ export const AudioContentSchema = z.object({
annotations: AnnotationsSchema.optional(),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
@@ -1195,7 +1195,7 @@ export const ToolUseContentSchema = z.object({
*/
input: z.record(z.string(), z.unknown()),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
@@ -1212,7 +1212,7 @@ export const EmbeddedResourceSchema = z.object({
*/
annotations: AnnotationsSchema.optional(),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
@@ -1375,7 +1375,7 @@ export const ToolSchema = z.object({
execution: ToolExecutionSchema.optional(),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
@@ -1649,7 +1649,7 @@ export const ToolResultContentSchema = z.object({
isError: z.boolean().optional(),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
@@ -1692,7 +1692,7 @@ export const SamplingMessageSchema = z.object({
role: RoleSchema,
content: z.union([SamplingMessageContentBlockSchema, z.array(SamplingMessageContentBlockSchema)]),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
@@ -2179,7 +2179,7 @@ export const RootSchema = z.object({
name: z.string().optional(),
/**
- * See [MCP specification](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/47339c03c143bb4ec01a26e721a1b8fe66634ebe/docs/specification/draft/basic/index.mdx#general-fields)
+ * See [MCP specification](https://modelcontextprotocol.io/specification/2026-07-28/basic/index#general-fields)
* for notes on `_meta` usage.
*/
_meta: z.record(z.string(), z.unknown()).optional()
diff --git a/packages/middleware/README.md b/packages/middleware/README.md
index 2dd2335fb1..a26f879746 100644
--- a/packages/middleware/README.md
+++ b/packages/middleware/README.md
@@ -11,6 +11,7 @@ They intentionally **do not** add new MCP features or “business logic”. MCP
## Packages
- `@modelcontextprotocol/express` — Express helpers (app defaults + Host header validation for DNS rebinding protection).
+- `@modelcontextprotocol/fastify` — Fastify helpers (app defaults + Host header validation).
- `@modelcontextprotocol/hono` — Hono helpers (app defaults + JSON body parsing hook + Host header validation).
- `@modelcontextprotocol/node` — Node.js Streamable HTTP transport wrapper for `IncomingMessage`/`ServerResponse`.
diff --git a/packages/middleware/express/CHANGELOG.md b/packages/middleware/express/CHANGELOG.md
index 620177a99a..d23a21928a 100644
--- a/packages/middleware/express/CHANGELOG.md
+++ b/packages/middleware/express/CHANGELOG.md
@@ -1,5 +1,46 @@
# @modelcontextprotocol/express
+## 2.0.0
+
+### Patch Changes
+
+- [#2402](https://github.com/modelcontextprotocol/typescript-sdk/pull/2402) [`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a) Thanks [@felixweinberger](https://github.com/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`).
+
+- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
+ and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
+ adds a `require` condition so `require('@modelcontextprotocol/…')` works from
+ CommonJS consumers. Output extensions are normalized across all packages
+ (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
+ public import paths are unchanged.
+
+- [#2420](https://github.com/modelcontextprotocol/typescript-sdk/pull/2420) [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`:
+ `requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare
+ Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and
+ `bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined
+ here. The Express middleware adapts the same core and is unchanged in
+ behavior, except that `WWW-Authenticate` challenge values are now RFC 7235
+ quoted-string sanitized (quotes and backslashes escaped, control and
+ non-ASCII characters replaced); `@modelcontextprotocol/express` re-exports
+ `OAuthTokenVerifier` as before.
+
+- [#2422](https://github.com/modelcontextprotocol/typescript-sdk/pull/2422) [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral OAuth discovery serving to `@modelcontextprotocol/server`:
+ `oauthMetadataResponse` serves the RFC 9728 Protected Resource Metadata and
+ RFC 8414 Authorization Server metadata documents from web-standard
+ `fetch(request)` hosts, built on the exported
+ `buildOAuthProtectedResourceMetadata`, with
+ `getOAuthProtectedResourceMetadataUrl` now defined here. The Express metadata
+ router adapts the same core and is unchanged in behavior; the insecure-issuer
+ escape hatch is an explicit `dangerouslyAllowInsecureIssuerUrl` option in the
+ neutral core instead of a module-scope environment read. The web-standard
+ matcher validates lazily so unmatched traffic always falls through, tolerates
+ a trailing slash, supports HEAD, and marks reflected CORS preflights with
+ `Vary`.
+- Updated dependencies [[`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a), [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df), [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9), [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e), [`1480241`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1480241e2a2a7f0ceee8e7723b2adcf88579bb36), [`7c49b47`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7c49b47fb3a58b51cec8fd0b337f656515f1a2b7), [`e0a0ab7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e0a0ab74d9baed74572c9f435313fb6daef1b989), [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3), [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff), [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4), [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091), [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313), [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a), [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796), [`470678d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/470678d56b9ee2a365fcf6d3de21e3a8b32eeef5), [`f130e1a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f130e1ac5fcab34bd568f59907e0f2ce1d22074c), [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae), [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a), [`3f07a32`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3f07a325c6741b2374ce2255846dfa0c25f74d03)]:
+ - @modelcontextprotocol/server@2.0.0
+
## 2.0.0-beta.5
### Patch Changes
diff --git a/packages/middleware/express/package.json b/packages/middleware/express/package.json
index b15467d482..5a2602bafb 100644
--- a/packages/middleware/express/package.json
+++ b/packages/middleware/express/package.json
@@ -1,7 +1,7 @@
{
"name": "@modelcontextprotocol/express",
"private": false,
- "version": "2.0.0-beta.5",
+ "version": "2.0.0",
"description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express middleware",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/packages/middleware/fastify/CHANGELOG.md b/packages/middleware/fastify/CHANGELOG.md
index cf3189a35a..35823f1e73 100644
--- a/packages/middleware/fastify/CHANGELOG.md
+++ b/packages/middleware/fastify/CHANGELOG.md
@@ -1,5 +1,23 @@
# @modelcontextprotocol/fastify
+## 2.0.0
+
+### Patch Changes
+
+- [#2402](https://github.com/modelcontextprotocol/typescript-sdk/pull/2402) [`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a) Thanks [@felixweinberger](https://github.com/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`).
+
+- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
+ and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
+ adds a `require` condition so `require('@modelcontextprotocol/…')` works from
+ CommonJS consumers. Output extensions are normalized across all packages
+ (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
+ public import paths are unchanged.
+- Updated dependencies [[`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a), [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df), [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9), [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e), [`1480241`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1480241e2a2a7f0ceee8e7723b2adcf88579bb36), [`7c49b47`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7c49b47fb3a58b51cec8fd0b337f656515f1a2b7), [`e0a0ab7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e0a0ab74d9baed74572c9f435313fb6daef1b989), [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3), [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff), [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4), [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091), [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313), [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a), [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796), [`470678d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/470678d56b9ee2a365fcf6d3de21e3a8b32eeef5), [`f130e1a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f130e1ac5fcab34bd568f59907e0f2ce1d22074c), [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae), [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a), [`3f07a32`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3f07a325c6741b2374ce2255846dfa0c25f74d03)]:
+ - @modelcontextprotocol/server@2.0.0
+
## 2.0.0-beta.5
### Patch Changes
diff --git a/packages/middleware/fastify/package.json b/packages/middleware/fastify/package.json
index 87cd4f0d33..d15134ef19 100644
--- a/packages/middleware/fastify/package.json
+++ b/packages/middleware/fastify/package.json
@@ -1,7 +1,7 @@
{
"name": "@modelcontextprotocol/fastify",
"private": false,
- "version": "2.0.0-beta.5",
+ "version": "2.0.0",
"description": "Fastify adapters for the Model Context Protocol TypeScript server SDK - Fastify middleware",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/packages/middleware/hono/CHANGELOG.md b/packages/middleware/hono/CHANGELOG.md
index b5f1cc5061..060d31794e 100644
--- a/packages/middleware/hono/CHANGELOG.md
+++ b/packages/middleware/hono/CHANGELOG.md
@@ -1,5 +1,41 @@
# @modelcontextprotocol/hono
+## 2.0.0
+
+### Patch Changes
+
+- [#2402](https://github.com/modelcontextprotocol/typescript-sdk/pull/2402) [`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a) Thanks [@felixweinberger](https://github.com/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`).
+
+- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
+ and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
+ adds a `require` condition so `require('@modelcontextprotocol/…')` works from
+ CommonJS consumers. Output extensions are normalized across all packages
+ (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
+ public import paths are unchanged.
+
+- [#2441](https://github.com/modelcontextprotocol/typescript-sdk/pull/2441) [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9) Thanks [@felixweinberger](https://github.com/felixweinberger)! - POSTs whose `Content-Type` media type is not `application/json` are now
+ rejected with `415 Unsupported Media Type`; the header is parsed instead of
+ substring-matched. Previously any value merely containing the substring
+ passed the check (for example `text/plain; a=application/json`), case
+ variants were wrongly rejected, and the 2026-07-28 entry did not inspect
+ `Content-Type` at 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
+ like `application/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.
+
+- Updated dependencies [[`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a), [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df), [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9), [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e), [`1480241`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1480241e2a2a7f0ceee8e7723b2adcf88579bb36), [`7c49b47`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7c49b47fb3a58b51cec8fd0b337f656515f1a2b7), [`e0a0ab7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e0a0ab74d9baed74572c9f435313fb6daef1b989), [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3), [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff), [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4), [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091), [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313), [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a), [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796), [`470678d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/470678d56b9ee2a365fcf6d3de21e3a8b32eeef5), [`f130e1a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f130e1ac5fcab34bd568f59907e0f2ce1d22074c), [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae), [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a), [`3f07a32`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3f07a325c6741b2374ce2255846dfa0c25f74d03)]:
+ - @modelcontextprotocol/server@2.0.0
+
## 2.0.0-beta.5
### Patch Changes
diff --git a/packages/middleware/hono/package.json b/packages/middleware/hono/package.json
index 04eeedfc6e..3cd2650a4a 100644
--- a/packages/middleware/hono/package.json
+++ b/packages/middleware/hono/package.json
@@ -1,7 +1,7 @@
{
"name": "@modelcontextprotocol/hono",
"private": false,
- "version": "2.0.0-beta.5",
+ "version": "2.0.0",
"description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/packages/middleware/node/CHANGELOG.md b/packages/middleware/node/CHANGELOG.md
index e040f873a3..32e8e337ec 100644
--- a/packages/middleware/node/CHANGELOG.md
+++ b/packages/middleware/node/CHANGELOG.md
@@ -1,5 +1,43 @@
# @modelcontextprotocol/node
+## 2.0.0
+
+### Patch Changes
+
+- [#2402](https://github.com/modelcontextprotocol/typescript-sdk/pull/2402) [`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a) Thanks [@felixweinberger](https://github.com/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`).
+
+- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
+ and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
+ adds a `require` condition so `require('@modelcontextprotocol/…')` works from
+ CommonJS consumers. Output extensions are normalized across all packages
+ (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
+ public import paths are unchanged.
+
+- [#2441](https://github.com/modelcontextprotocol/typescript-sdk/pull/2441) [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9) Thanks [@felixweinberger](https://github.com/felixweinberger)! - POSTs whose `Content-Type` media type is not `application/json` are now
+ rejected with `415 Unsupported Media Type`; the header is parsed instead of
+ substring-matched. Previously any value merely containing the substring
+ passed the check (for example `text/plain; a=application/json`), case
+ variants were wrongly rejected, and the 2026-07-28 entry did not inspect
+ `Content-Type` at 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
+ like `application/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.
+
+- [#2445](https://github.com/modelcontextprotocol/typescript-sdk/pull/2445) [`78fabea`](https://github.com/modelcontextprotocol/typescript-sdk/commit/78fabea44557bd49f5a050b92e57ccd22dab14ad) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Document composing the host and origin validation guards in front of `toNodeHandler` for hand-wired `node:http` servers, matching the protected wiring the examples and serving guide now demonstrate.
+
+- Updated dependencies [[`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a), [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df), [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9), [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e), [`1480241`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1480241e2a2a7f0ceee8e7723b2adcf88579bb36), [`7c49b47`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7c49b47fb3a58b51cec8fd0b337f656515f1a2b7), [`e0a0ab7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e0a0ab74d9baed74572c9f435313fb6daef1b989), [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3), [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff), [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4), [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091), [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313), [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a), [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796), [`470678d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/470678d56b9ee2a365fcf6d3de21e3a8b32eeef5), [`f130e1a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f130e1ac5fcab34bd568f59907e0f2ce1d22074c), [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae), [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a), [`3f07a32`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3f07a325c6741b2374ce2255846dfa0c25f74d03)]:
+ - @modelcontextprotocol/server@2.0.0
+
## 2.0.0-beta.5
### Patch Changes
diff --git a/packages/middleware/node/package.json b/packages/middleware/node/package.json
index 642ebe2831..68ccb73ffd 100644
--- a/packages/middleware/node/package.json
+++ b/packages/middleware/node/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/node",
- "version": "2.0.0-beta.5",
+ "version": "2.0.0",
"description": "Model Context Protocol implementation for TypeScript - Node.js middleware",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/packages/server-legacy/CHANGELOG.md b/packages/server-legacy/CHANGELOG.md
index abe99d6033..b3d95738d1 100644
--- a/packages/server-legacy/CHANGELOG.md
+++ b/packages/server-legacy/CHANGELOG.md
@@ -1,5 +1,30 @@
# @modelcontextprotocol/server-legacy
+## 2.0.0
+
+### Minor Changes
+
+- [#2477](https://github.com/modelcontextprotocol/typescript-sdk/pull/2477) [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Move the schema source modules (spec schemas, OAuth schemas, protocol constants) into `@modelcontextprotocol/core` and 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/core` gains a `./internal` subpath (SDK-internal contract; may change in any release) and the four packages now version together.
+
+### Patch Changes
+
+- [#2402](https://github.com/modelcontextprotocol/typescript-sdk/pull/2402) [`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a) Thanks [@felixweinberger](https://github.com/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`).
+
+- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
+ and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
+ adds a `require` condition so `require('@modelcontextprotocol/…')` works from
+ CommonJS consumers. Output extensions are normalized across all packages
+ (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
+ public import paths are unchanged.
+
+- [#2476](https://github.com/modelcontextprotocol/typescript-sdk/pull/2476) [`e0a0ab7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e0a0ab74d9baed74572c9f435313fb6daef1b989) Thanks [@felixweinberger](https://github.com/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.
+
+- Updated dependencies [[`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a), [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4), [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313)]:
+ - @modelcontextprotocol/core@2.0.0
+
## 2.0.0-beta.5
### Patch Changes
diff --git a/packages/server-legacy/package.json b/packages/server-legacy/package.json
index 7bc53ea6ed..9ef13dcff7 100644
--- a/packages/server-legacy/package.json
+++ b/packages/server-legacy/package.json
@@ -1,7 +1,7 @@
{
"name": "@modelcontextprotocol/server-legacy",
"private": false,
- "version": "2.0.0-beta.5",
+ "version": "2.0.0",
"description": "Frozen v1 SSE transport and OAuth Authorization Server helpers for the Model Context Protocol TypeScript SDK. Deprecated; use StreamableHTTP and a dedicated OAuth server in production.",
"deprecated": "This package is a frozen copy of v1's SSE transport and OAuth Authorization Server helpers for migration purposes only. Use StreamableHTTP from @modelcontextprotocol/server and a dedicated OAuth server in production. Will not receive new features.",
"license": "MIT",
diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md
index 1b63fb069e..c24a3604e5 100644
--- a/packages/server/CHANGELOG.md
+++ b/packages/server/CHANGELOG.md
@@ -1,5 +1,114 @@
# @modelcontextprotocol/server
+## 2.0.0
+
+### Minor Changes
+
+- [#2501](https://github.com/modelcontextprotocol/typescript-sdk/pull/2501) [`1480241`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1480241e2a2a7f0ceee8e7723b2adcf88579bb36) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Export the `Protocol` base class and `mergeCapabilities` from the `@modelcontextprotocol/client` and `@modelcontextprotocol/server` package roots, restoring the v1 import for consumers that subclass `Protocol` (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 `Protocol` and `mergeCapabilities` imports from `shared/protocol.js` to the client or server package root, like the module's other symbols, instead of dropping them with an action-required marker.
+
+- [#2477](https://github.com/modelcontextprotocol/typescript-sdk/pull/2477) [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Move the schema source modules (spec schemas, OAuth schemas, protocol constants) into `@modelcontextprotocol/core` and 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/core` gains a `./internal` subpath (SDK-internal contract; may change in any release) and the four packages now version together.
+
+- [#2513](https://github.com/modelcontextprotocol/typescript-sdk/pull/2513) [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Align the 2026-07-28 wire with the final revision (spec PR #3002): `serverInfo` moves from the `DiscoverResult` body to the result `_meta`, and the per-request envelope's `clientInfo` demotes 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 body `serverInfo` failed parse, so the probe misclassified the server as legacy and attempted an `initialize` handshake 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 omit `clientInfo`.
+
+ Now:
+ - The 2026 wire schemas are the final revision exactly: no body `serverInfo` on `DiscoverResult`, envelope `clientInfo` optional (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-built `subscriptions/listen` graceful-close results — the spec's `SubscriptionsListenResultMeta` extends `ResultMetaObject`.
+ - Clients keep sending `clientInfo` and read server identity from the discover result's `_meta` only. A server that stamps no identity is anonymous: `getServerVersion()` is `undefined` and the response cache partitions under a per-connection surrogate. A malformed `_meta` serverInfo value is treated as absent on receive (the spec marks the field self-reported, unverified, and display-only).
+ - Breaking type changes: `DiscoverResult` no longer declares `serverInfo`; `RequestMetaEnvelope`'s `clientInfo` is optional. New public constant `SERVER_INFO_META_KEY` (`'io.modelcontextprotocol/serverInfo'`).
+
+- [#2369](https://github.com/modelcontextprotocol/typescript-sdk/pull/2369) [`24be404`](https://github.com/modelcontextprotocol/typescript-sdk/commit/24be4040d454a9c5983901229068477c7a9ea796) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Allow `inputRequired.elicit()` to accept a Standard Schema such as a Zod object for `requestedSchema`. The builder converts it to MCP's restricted form-elicitation JSON Schema, while the same schema can validate and type the response through `acceptedContent()` on handler re-entry. Zod formats mapping to `email`, `uri`, `date`, and `date-time` are supported. Shapes the restricted schema cannot express reject before anything is sent — nested objects, `.regex()` and customized zod format patterns, exclusive number bounds (`.positive()`/`.gt()`), literal unions (use `z.enum` or `z.literal(['a', 'b'])`), and non-spec root keywords like `z.strictObject()`'s `additionalProperties`.
+
+- [#2541](https://github.com/modelcontextprotocol/typescript-sdk/pull/2541) [`470678d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/470678d56b9ee2a365fcf6d3de21e3a8b32eeef5) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Add configurable SSE keep-alive comment frames to Streamable HTTP transports and apply `createMcpHandler`'s existing `keepAliveMs` option to every HTTP SSE stream it serves.
+
+- [#2420](https://github.com/modelcontextprotocol/typescript-sdk/pull/2420) [`7635115`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7635115d0112c3f980b45a9773a4770660af8aae) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral Bearer authentication to `@modelcontextprotocol/server`:
+ `requireBearerAuth` gates web-standard `fetch(request)` hosts (Cloudflare
+ Workers, Deno, Bun, Hono), built on the exported `verifyBearerToken` and
+ `bearerAuthChallengeResponse` pieces, with `OAuthTokenVerifier` now defined
+ here. The Express middleware adapts the same core and is unchanged in
+ behavior, except that `WWW-Authenticate` challenge values are now RFC 7235
+ quoted-string sanitized (quotes and backslashes escaped, control and
+ non-ASCII characters replaced); `@modelcontextprotocol/express` re-exports
+ `OAuthTokenVerifier` as before.
+
+- [#2422](https://github.com/modelcontextprotocol/typescript-sdk/pull/2422) [`61866d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/61866d7a5ff4475663ceb525c88447c497c1b92a) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Add runtime-neutral OAuth discovery serving to `@modelcontextprotocol/server`:
+ `oauthMetadataResponse` serves the RFC 9728 Protected Resource Metadata and
+ RFC 8414 Authorization Server metadata documents from web-standard
+ `fetch(request)` hosts, built on the exported
+ `buildOAuthProtectedResourceMetadata`, with
+ `getOAuthProtectedResourceMetadataUrl` now defined here. The Express metadata
+ router adapts the same core and is unchanged in behavior; the insecure-issuer
+ escape hatch is an explicit `dangerouslyAllowInsecureIssuerUrl` option in the
+ neutral core instead of a module-scope environment read. The web-standard
+ matcher validates lazily so unmatched traffic always falls through, tolerates
+ a trailing slash, supports HEAD, and marks reflected CORS preflights with
+ `Vary`.
+
+- [#2483](https://github.com/modelcontextprotocol/typescript-sdk/pull/2483) [`3f07a32`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3f07a325c6741b2374ce2255846dfa0c25f74d03) Thanks [@felixweinberger](https://github.com/felixweinberger)! - 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. Calling `preloadSchemas()` 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 (its `browser` condition previously reused the workerd shim), so browser bundles keep lazy construction.
+
+### Patch Changes
+
+- [#2402](https://github.com/modelcontextprotocol/typescript-sdk/pull/2402) [`a400259`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a) Thanks [@felixweinberger](https://github.com/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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2456) [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Restore the v1 parse tolerance for `CallToolResult.content`: an inbound legacy-era `tools/call` result without `content` defaults to `[]` instead of failing validation. Deployed servers — accepted by SDK v1 for years — return `structuredContent`-only (or otherwise content-less) results, and the strict parse turned every such call into an `INVALID_RESULT` error 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 `content` for 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 foreign `resultType`), 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 to `content: []` before era validation on every leg, reaching the wire spec-valid.
+
+ Conscious call: the nested sampling `ToolResultContentSchema` stays spec-strict — v1 had defaulted its `content` too, but it is params-side (tool results a caller authors into a sampling message), deliberately not restored.
+
+- [#2431](https://github.com/modelcontextprotocol/typescript-sdk/pull/2431) [`1b90c96`](https://github.com/modelcontextprotocol/typescript-sdk/commit/1b90c96d11fd17016d2977cae9dd661de3fb84df) Thanks [@morluto](https://github.com/morluto)! - Fix the CommonJS `validators/ajv` subpath so reading the exported `Ajv` class no longer throws `ReferenceError: import_ajv is not defined`. The subpath now re-exports the bundled provider's concrete `Ajv` value in CJS output, matching the existing ESM behavior.
+
+- [#2405](https://github.com/modelcontextprotocol/typescript-sdk/pull/2405) [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Ship CommonJS builds alongside ESM. Each package now emits both `.mjs`/`.d.mts`
+ and `.cjs`/`.d.cts` (via tsdown `format: ['esm', 'cjs']`), and its `exports` map
+ adds a `require` condition so `require('@modelcontextprotocol/…')` works from
+ CommonJS consumers. Output extensions are normalized across all packages
+ (`@modelcontextprotocol/core` moves from `.js`/`.d.ts` to `.mjs`/`.d.mts`); the
+ public import paths are unchanged.
+
+- [#2441](https://github.com/modelcontextprotocol/typescript-sdk/pull/2441) [`561c6d8`](https://github.com/modelcontextprotocol/typescript-sdk/commit/561c6d83456ef98d6c713bbda9837e64337f22c9) Thanks [@felixweinberger](https://github.com/felixweinberger)! - POSTs whose `Content-Type` media type is not `application/json` are now
+ rejected with `415 Unsupported Media Type`; the header is parsed instead of
+ substring-matched. Previously any value merely containing the substring
+ passed the check (for example `text/plain; a=application/json`), case
+ variants were wrongly rejected, and the 2026-07-28 entry did not inspect
+ `Content-Type` at 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
+ like `application/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](https://github.com/modelcontextprotocol/typescript-sdk/pull/2384) [`ce2f65d`](https://github.com/modelcontextprotocol/typescript-sdk/commit/ce2f65db0e019506f4d2526466ec8cc7106de98e) Thanks [@felixweinberger](https://github.com/felixweinberger)! - `instanceof` on the SDK error classes (`ProtocolError` and its typed subclasses, `SdkError`/`SdkHttpError`, `OAuthError`, and the client's `SseError`, `UnauthorizedError`, and OAuth-client-flow error family — `OAuthClientFlowError` and its subclasses) now works across separately bundled copies of the SDK. The classes match by a stable brand (via `Symbol.hasInstance` and a registry symbol) instead of prototype identity, so a process that uses both `@modelcontextprotocol/client` and `@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-based `instanceof` is 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-bundle `SdkError` used as an abort reason is now rethrown as-is instead of being wrapped as a `RequestTimeout`. 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 over `instanceof`. Also: `UnauthorizedError` now sets `error.name` to `'UnauthorizedError'` (previously `'Error'`), and per-package conformance tests enforce that every exported error class participates in branding. Version-negotiation probing now recognizes `UnauthorizedError` (previously a dead name-string check) and propagates it unchanged, so `connect()` on an auth-gated server rejects with the original `UnauthorizedError` (previously wrapped as the `cause` of an `SdkError(EraNegotiationFailed)`) — run `finishAuth()` and reconnect, and the retry probes with the token.
+
+- [#2458](https://github.com/modelcontextprotocol/typescript-sdk/pull/2458) [`7c49b47`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7c49b47fb3a58b51cec8fd0b337f656515f1a2b7) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Construct the default Ajv validation engine lazily on first validation. Creating a `Client` or `Server` no longer pays the ajv + ajv-formats instantiation cost at startup when no JSON Schema validation ever runs.
+
+- [#2476](https://github.com/modelcontextprotocol/typescript-sdk/pull/2476) [`e0a0ab7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e0a0ab74d9baed74572c9f435313fb6daef1b989) Thanks [@felixweinberger](https://github.com/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.
+
+- [#2451](https://github.com/modelcontextprotocol/typescript-sdk/pull/2451) [`7e69735`](https://github.com/modelcontextprotocol/typescript-sdk/commit/7e697354de95111ca2c70a12ac9f5d3ec96b56c3) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Return JSON-RPC Invalid Params with the original URI and an `invalid_uri` reason when `resources/read` receives a syntactically malformed URI.
+
+- [#2399](https://github.com/modelcontextprotocol/typescript-sdk/pull/2399) [`3c7ddaf`](https://github.com/modelcontextprotocol/typescript-sdk/commit/3c7ddafa05d8f17fb52168bf4638f09251c3d0ff) Thanks [@felixweinberger](https://github.com/felixweinberger)! - Return HTTP 400 for a `MissingRequiredClientCapabilityError` (`-32021`) produced after dispatch. The spec mandates `400 Bad Request` for this error with no condition on where it arose, but only the pre-dispatch capability gate honored that; the post-handler emission — the `input_required` gate rejecting an embedded request whose required capability the caller did not declare — surfaced in-band on HTTP 200. The JSON-RPC error body is unchanged, every other error code (including a handler relaying a downstream peer's `-32020`/`-32022`) keeps the origin-keyed in-band behavior, and the mapping only applies while the response is uncommitted: an exchange that already streamed — or one hosted with `responseMode: 'sse'`, which opens its stream at dispatch end — keeps its committed 200 and carries the error in-stream.
+
+- [#2425](https://github.com/modelcontextprotocol/typescript-sdk/pull/2425) [`e8de519`](https://github.com/modelcontextprotocol/typescript-sdk/commit/e8de519d3129f46b7528d2999b7641f55be1f091) Thanks [@Sehlani042](https://github.com/Sehlani042)! - Stop advertising validator provider classes from the root client/server type declarations. The provider classes remain available from the explicit validator subpaths.
+
+- [#2453](https://github.com/modelcontextprotocol/typescript-sdk/pull/2453) [`0ab5d14`](https://github.com/modelcontextprotocol/typescript-sdk/commit/0ab5d1471d6c7375878316df2930fca77eee1d2a) Thanks [@mattzcarey](https://github.com/mattzcarey)! - Strip RFC 9110 optional whitespace around inbound `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` values before classifying and validating modern HTTP requests. This keeps valid requests portable across Fetch runtimes that expose raw leading or trailing SP/HTAB through `Headers.get()`.
+
+- [#2534](https://github.com/modelcontextprotocol/typescript-sdk/pull/2534) [`f130e1a`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f130e1ac5fcab34bd568f59907e0f2ce1d22074c) Thanks [@felixweinberger](https://github.com/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's `2019-09`/`openAi` targets) with 2019-09 semantics, on both the Ajv and Cloudflare Workers providers (with known engine differences documented in the migration guide). Schemas with no `$schema` still 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`](https://github.com/modelcontextprotocol/typescript-sdk/commit/a4002596b914c675d17ac22471d1287976dbb52a), [`44797d7`](https://github.com/modelcontextprotocol/typescript-sdk/commit/44797d77792953d0ce70b68922bb6bb69e697c32), [`f172626`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f172626a8e98b2ae2f0f690e4afb4dc74dbf6011), [`8e1d2e9`](https://github.com/modelcontextprotocol/typescript-sdk/commit/8e1d2e92b1720d2520122b3a5f20ea084edaf3c4), [`f413763`](https://github.com/modelcontextprotocol/typescript-sdk/commit/f4137630c05dc9a4fb14d4d3777f5cb167bd6313)]:
+ - @modelcontextprotocol/core@2.0.0
+
## 2.0.0-beta.5
### Minor Changes
diff --git a/packages/server/README.md b/packages/server/README.md
index 1d5aefb73f..fac599cdde 100644
--- a/packages/server/README.md
+++ b/packages/server/README.md
@@ -4,7 +4,7 @@ The MCP (Model Context Protocol) TypeScript server SDK. Build MCP servers that e
> [!WARNING]
-> **This is a beta release.** The API surface is settling but breaking changes remain possible until v2 stabilizes. Please try it and open issues — feedback during the beta directly shapes the stable release.
+> **v2 is the stable release line**, implementing the [2026-07-28 MCP spec](https://modelcontextprotocol.io/specification/2026-07-28). Migrating from v1? Start with the [migration guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/).
> [!NOTE]
@@ -13,12 +13,12 @@ The MCP (Model Context Protocol) TypeScript server SDK. Build MCP servers that e
## Install
```bash
-npm install @modelcontextprotocol/server@beta
+npm install @modelcontextprotocol/server
```
TypeScript ≥6.0 no longer auto-includes `@types/*` — add `"types": ["node"]` to your `tsconfig.json` `compilerOptions` (the published `.d.mts` references `Buffer`).
-Optional framework adapters: [`@modelcontextprotocol/express`](https://www.npmjs.com/package/@modelcontextprotocol/express), [`@modelcontextprotocol/hono`](https://www.npmjs.com/package/@modelcontextprotocol/hono),
+Optional framework adapters: [`@modelcontextprotocol/express`](https://www.npmjs.com/package/@modelcontextprotocol/express), [`@modelcontextprotocol/fastify`](https://www.npmjs.com/package/@modelcontextprotocol/fastify), [`@modelcontextprotocol/hono`](https://www.npmjs.com/package/@modelcontextprotocol/hono),
[`@modelcontextprotocol/node`](https://www.npmjs.com/package/@modelcontextprotocol/node).
## Documentation
diff --git a/packages/server/package.json b/packages/server/package.json
index 4881c9cff5..f481e019e7 100644
--- a/packages/server/package.json
+++ b/packages/server/package.json
@@ -1,6 +1,6 @@
{
"name": "@modelcontextprotocol/server",
- "version": "2.0.0-beta.5",
+ "version": "2.0.0",
"description": "Model Context Protocol implementation for TypeScript - Server package",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts
index 1582f8c6eb..5de0d8919c 100644
--- a/packages/server/src/server/server.ts
+++ b/packages/server/src/server/server.ts
@@ -1238,7 +1238,7 @@ export class Server extends Protocol {
* notification for the specified elicitation ID.
*
* The notification (and the `elicitationId` it references) exists only on protocol revision
- * 2025-11-25 — the 2026-07-28 draft removed both. On a connection negotiated at 2026-07-28 the
+ * 2025-11-25 — the 2026-07-28 revision removed both. On a connection negotiated at 2026-07-28 the
* returned callback rejects with a typed local error before anything reaches the transport
* (the method is not part of that revision's wire registry).
*
diff --git a/test/conformance/CHANGELOG.md b/test/conformance/CHANGELOG.md
index 1c9ea88fd3..3dbbf79b03 100644
--- a/test/conformance/CHANGELOG.md
+++ b/test/conformance/CHANGELOG.md
@@ -1,5 +1,7 @@
# @modelcontextprotocol/test-conformance
+## 2.0.0
+
## 2.0.0-alpha.1
### Patch Changes
diff --git a/test/conformance/package.json b/test/conformance/package.json
index 679600a644..8558f92b50 100644
--- a/test/conformance/package.json
+++ b/test/conformance/package.json
@@ -1,7 +1,7 @@
{
"name": "@modelcontextprotocol/test-conformance",
"private": true,
- "version": "2.0.0-beta.0",
+ "version": "2.0.0",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/test/e2e/CHANGELOG.md b/test/e2e/CHANGELOG.md
index 1bfd5dc0ba..6fa629ad34 100644
--- a/test/e2e/CHANGELOG.md
+++ b/test/e2e/CHANGELOG.md
@@ -1,5 +1,7 @@
# @modelcontextprotocol/test-e2e
+## 2.0.0
+
## 2.0.0-alpha.1
### Patch Changes
diff --git a/test/e2e/package.json b/test/e2e/package.json
index 017ffb93b1..2b052482e2 100644
--- a/test/e2e/package.json
+++ b/test/e2e/package.json
@@ -1,7 +1,7 @@
{
"name": "@modelcontextprotocol/test-e2e",
"private": true,
- "version": "2.0.0-beta.0",
+ "version": "2.0.0",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts
index 76b597c5eb..a3439e1ceb 100644
--- a/test/e2e/requirements.ts
+++ b/test/e2e/requirements.ts
@@ -2047,6 +2047,13 @@ export const REQUIREMENTS: Record = {
transports: ['streamableHttp'],
note: 'This exercises the HTTP hosting/auth layer and OAuth client; the matrix transport arg is ignored, so it runs as a single streamableHttp-labelled cell to avoid duplicate runs.'
},
+ 'client-auth:negotiation:auth-before-era': {
+ source: 'sdk',
+ behavior:
+ "An OAuth-protected legacy server is reachable under versionNegotiation mode 'auto': the connect-time probe's 401 propagates the auth challenge (UnauthorizedError) without deciding the era — auth settles first, era second — and after finishAuth the reconnect re-probes with the token, takes the legacy server's real server/discover rejection as the era evidence, completes the legacy initialize, and serves tools/call.",
+ transports: ['streamableHttp'],
+ note: "Wire-order pin: exactly two server/discover POSTs — the pre-auth one 401'd by the auth wall, the post-auth one answered by the legacy stack — then a single initialize, only after the second probe. Same single-cell setup as the rest of the client-auth family (self-contained body; the matrix transport arg is ignored)."
+ },
'client-auth:403-scope-upgrade': {
source: 'https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization#step-up-authorization-flow',
behavior: 'A 403 with WWW-Authenticate triggers a scope-upgrade authorization attempt; repeated 403s do not loop.',
diff --git a/test/e2e/scenarios/client-auth.test.ts b/test/e2e/scenarios/client-auth.test.ts
index 07d052c3b6..caa1d74fe4 100644
--- a/test/e2e/scenarios/client-auth.test.ts
+++ b/test/e2e/scenarios/client-auth.test.ts
@@ -348,6 +348,70 @@ verifies('client-auth:401-triggers-flow', async (_args: TestArgs) => {
}
});
+verifies('client-auth:negotiation:auth-before-era', async (_args: TestArgs) => {
+ const validToken = 'negotiated-token';
+ const as = createMockAuthorizationServer({ tokenResponses: [{ access_token: validToken, token_type: 'Bearer' }] });
+ const provider = new RecordingOAuthClientProvider();
+ // The auth wall lives in createCombinedFetch (401 challenge without the
+ // token); behind it a plain legacy stack serves the tool.
+ const mcpHost = hostPerSession(() => {
+ const s = new McpServer({ name: 's', version: '0' });
+ s.registerTool('probe', { inputSchema: z.object({}) }, () => ({ content: [{ type: 'text', text: 'ok' }] }));
+ return s;
+ });
+ const baseFetch = createCombinedFetch({ as, mcpHost, validToken });
+
+ // Wire recorder for MCP-origin POSTs: JSON-RPC method, response status, auth presence.
+ const mcpPosts: Array<{ method: string; status: number; hasAuth: boolean }> = [];
+ const combinedFetch = async (url: URL | string, init?: RequestInit): Promise => {
+ const urlObj = typeof url === 'string' ? new URL(url) : url;
+ const isMcp = urlObj.origin !== ISSUER && !urlObj.pathname.includes('/.well-known/');
+ const response = await baseFetch(url, init);
+ if (isMcp && init?.method === 'POST') {
+ const body = typeof init.body === 'string' ? (JSON.parse(init.body) as { method?: string }) : {};
+ mcpPosts.push({ method: body.method ?? '?', status: response.status, hasAuth: new Headers(init.headers).has('authorization') });
+ }
+ return response;
+ };
+
+ const client = new Client({ name: 'c', version: '0' }, { versionNegotiation: { mode: 'auto' } });
+ const first = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
+
+ try {
+ // Probe -> 401: the auth challenge propagates. The 401 never decides the
+ // era -- the auth wall answered before the MCP layer saw server/discover.
+ await expect(client.connect(first)).rejects.toThrow(UnauthorizedError);
+ expect(mcpPosts).toEqual([{ method: 'server/discover', status: 401, hasAuth: false }]);
+ expect(provider.redirectedTo).toHaveLength(1);
+
+ // Complete the flow (the mock AS exchanges any code), then reconnect on
+ // a FRESH transport -- a started transport cannot be restarted.
+ await first.finishAuth('e2e-auth-code');
+ expect(provider.saved.tokens?.access_token).toBe(validToken);
+
+ const second = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider, fetch: combinedFetch });
+ await client.connect(second);
+
+ // The post-auth re-probe supplied the era evidence: two probes total --
+ // the pre-auth one 401'd, the post-auth one answered by the legacy
+ // stack -- and initialize ran only after the second.
+ const probes = mcpPosts.filter(p => p.method === 'server/discover');
+ expect(probes).toHaveLength(2);
+ expect(probes[1]).toMatchObject({ hasAuth: true });
+ expect(probes[1]!.status).not.toBe(401);
+ const kinds = mcpPosts.map(p => p.method);
+ expect(kinds.filter(k => k === 'initialize')).toHaveLength(1);
+ expect(kinds.indexOf('initialize')).toBeGreaterThan(kinds.lastIndexOf('server/discover'));
+ expect(client.getNegotiatedProtocolVersion()).toBe('2025-11-25');
+
+ const result = await client.callTool({ name: 'probe', arguments: {} });
+ expect(result.content).toEqual([{ type: 'text', text: 'ok' }]);
+ } finally {
+ await client.close();
+ await mcpHost.close();
+ }
+});
+
verifies('client-auth:401-after-auth-throws', async (_args: TestArgs) => {
const as = createMockAuthorizationServer({
tokenResponses: [{ access_token: 'refreshed-access-token', token_type: 'Bearer' }]
diff --git a/test/helpers/CHANGELOG.md b/test/helpers/CHANGELOG.md
new file mode 100644
index 0000000000..d7ce32c295
--- /dev/null
+++ b/test/helpers/CHANGELOG.md
@@ -0,0 +1,3 @@
+# @modelcontextprotocol/test-helpers
+
+## 2.0.0
diff --git a/test/helpers/package.json b/test/helpers/package.json
index 01d369d7cd..c359fbe6c2 100644
--- a/test/helpers/package.json
+++ b/test/helpers/package.json
@@ -1,7 +1,7 @@
{
"name": "@modelcontextprotocol/test-helpers",
"private": true,
- "version": "2.0.0-beta.0",
+ "version": "2.0.0",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/test/integration/CHANGELOG.md b/test/integration/CHANGELOG.md
index 7672abd7cc..7d1ea6e749 100644
--- a/test/integration/CHANGELOG.md
+++ b/test/integration/CHANGELOG.md
@@ -1,5 +1,7 @@
# @modelcontextprotocol/test-integration
+## 2.0.0
+
## 2.0.0-alpha.1
### Patch Changes
diff --git a/test/integration/package.json b/test/integration/package.json
index 07f94eccc7..ed75fb1b0c 100644
--- a/test/integration/package.json
+++ b/test/integration/package.json
@@ -1,7 +1,7 @@
{
"name": "@modelcontextprotocol/test-integration",
"private": true,
- "version": "2.0.0-beta.0",
+ "version": "2.0.0",
"description": "Model Context Protocol implementation for TypeScript",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
diff --git a/test/integration/test/client/versionNegotiation.test.ts b/test/integration/test/client/versionNegotiation.test.ts
index 809994e3dd..c6e70446cd 100644
--- a/test/integration/test/client/versionNegotiation.test.ts
+++ b/test/integration/test/client/versionNegotiation.test.ts
@@ -22,9 +22,9 @@ import { createServer } from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
-import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
+import { Client, StreamableHTTPClientTransport, UnauthorizedError } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
-import { SdkError, SdkErrorCode } from '@modelcontextprotocol/core-internal';
+import { SdkError, SdkErrorCode, SdkHttpError } from '@modelcontextprotocol/core-internal';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { McpServer } from '@modelcontextprotocol/server';
import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
@@ -230,6 +230,117 @@ describe('typed connect errors (Q12) over real sockets', () => {
}, 15_000);
});
+describe('auth-protected server (HTTP 401/403): typed auth failure, never a legacy verdict (#2561)', () => {
+ const cleanups: Array<() => Promise | void> = [];
+ afterEach(async () => {
+ while (cleanups.length > 0) await cleanups.pop()!();
+ });
+
+ /** An OAuth-protected deployment shape: the auth layer rejects every tokenless request before any MCP handler runs. */
+ async function startProtected(status: 401 | 403) {
+ const server = createServer((_req, res) => {
+ res.writeHead(status, {
+ 'WWW-Authenticate': 'Bearer realm="mcp", error="invalid_token"',
+ 'Content-Type': 'application/json'
+ });
+ res.end('{"error":"invalid_token"}');
+ });
+ const url = await listenOnRandomPort(server);
+ cleanups.push(() => new Promise(resolve => server.close(() => resolve())));
+ return url;
+ }
+
+ it('auto mode, no authProvider: typed auth failure carrying the 401 — no initialize ever sent', async () => {
+ const url = await startProtected(401);
+ const { calls, fetchFn } = recordingFetch();
+ const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
+ const transport = new StreamableHTTPClientTransport(url, { fetch: fetchFn });
+
+ await expect(client.connect(transport)).rejects.toSatisfy(
+ error =>
+ error instanceof SdkHttpError &&
+ // The auth code, never EraNegotiationFailed: era-recovery flows
+ // keyed on that code must not consume auth walls.
+ error.code === SdkErrorCode.ClientHttpAuthentication &&
+ error.status === 401 &&
+ error.message.includes('401')
+ );
+
+ // The probe POST is the only wire traffic — the auth wall is not a
+ // legacy verdict, so no initialize follows it.
+ const posts = calls.filter(c => c.method === 'POST');
+ expect(posts.length).toBeGreaterThan(0);
+ expect(posts.every(c => (c.body ?? '').includes('server/discover'))).toBe(true);
+ expect(calls.some(c => (c.body ?? '').includes('"initialize"'))).toBe(false);
+ });
+
+ it('auto mode, no authProvider: a 403 denial is a typed failure carrying the 403, not era evidence', async () => {
+ const url = await startProtected(403);
+ const { calls, fetchFn } = recordingFetch();
+ const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
+
+ await expect(client.connect(new StreamableHTTPClientTransport(url, { fetch: fetchFn }))).rejects.toSatisfy(
+ error => error instanceof SdkHttpError && error.code === SdkErrorCode.ClientHttpForbidden && error.status === 403
+ );
+ expect(calls.some(c => (c.body ?? '').includes('"initialize"'))).toBe(false);
+ });
+
+ it('pin mode: the rejection names the auth status, never the did-not-offer-pinned-version text', async () => {
+ const url = await startProtected(401);
+ const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } });
+
+ await expect(client.connect(new StreamableHTTPClientTransport(url))).rejects.toSatisfy(
+ error =>
+ error instanceof SdkError &&
+ error.message.includes('401') &&
+ !error.message.includes('did not offer pinned protocol version')
+ );
+ });
+
+ it('with an authProvider the auth flow is unchanged: UnauthorizedError propagates for finishAuth, no fallback runs', async () => {
+ const url = await startProtected(401);
+ const { calls, fetchFn } = recordingFetch();
+ const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
+ const transport = new StreamableHTTPClientTransport(url, {
+ fetch: fetchFn,
+ // Token-only provider (no onUnauthorized) whose token the server
+ // rejects: the transport's 401 handling throws UnauthorizedError,
+ // and the probe propagates it unchanged — same as before this fix.
+ authProvider: { token: async () => 'rejected-token' }
+ });
+
+ await expect(client.connect(transport)).rejects.toSatisfy(error => error instanceof UnauthorizedError);
+ expect(calls.some(c => (c.body ?? '').includes('"initialize"'))).toBe(false);
+ });
+
+ it("onUnauthorized re-auth that does not help: the transport's typed 401-after-re-authentication failure propagates unchanged", async () => {
+ const url = await startProtected(401);
+ const { calls, fetchFn } = recordingFetch();
+ const client = new Client({ name: 'neg-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
+ let reauthRuns = 0;
+ const transport = new StreamableHTTPClientTransport(url, {
+ fetch: fetchFn,
+ authProvider: {
+ token: async () => 'still-rejected',
+ onUnauthorized: async () => {
+ reauthRuns++;
+ }
+ }
+ });
+
+ // First 401 runs onUnauthorized, the retry 401s again: the transport's
+ // own diagnostic must reach the caller, not an era-negotiation rewrap.
+ await expect(client.connect(transport)).rejects.toSatisfy(
+ error =>
+ error instanceof SdkHttpError &&
+ error.code === SdkErrorCode.ClientHttpAuthentication &&
+ error.message.includes('after re-authentication')
+ );
+ expect(reauthRuns).toBe(1);
+ expect(calls.some(c => (c.body ?? '').includes('"initialize"'))).toBe(false);
+ });
+});
+
describe('stdio: silent legacy server (probe timeout fallback)', () => {
// The stdio transport's backward-compatibility rule: a probe that gets no
// response within a reasonable timeout indicates a legacy server — some