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 @@ 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