diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index ce733f729e..57b9ff2d14 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -360,7 +360,7 @@ and the multi-round-trip retry fields (`inputResponses`, `requestState`). - **High-level methods return the named public types** (`client.callTool()` → `Promise`, etc.). Handler return positions are unaffected. - **Reserved envelope keys and retry fields appear in no public params/result type.** - The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported. + The `RequestMetaEnvelope` type and the four envelope `*_META_KEY` constants stay exported. The protocol layer enforces the same boundary at runtime: @@ -405,10 +405,10 @@ obtain client input (elicitation, sampling, roots) **in-band** by returning `inputRequired(...)` from a `tools/call` / `prompts/get` / `resources/read` handler; the client retries the original call with the responses. -| Handler serving 2026-07-28 requests | Mechanical fix | -| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry | -| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` | +| Handler serving 2026-07-28 requests | Mechanical fix | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry | +| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` | | handler shared across both eras | **no branch needed** — write the `inputRequired(...)` form once; the [legacy shim](#legacy-shim-for-input_required) serves it to 2025-era connections by issuing real server→client requests | `inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from @@ -416,7 +416,11 @@ client retries the original call with the responses. (`ctx.mcpReq.send` of server→client requests, `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`, instance-level `createMessage()`/`elicitInput()`/`listRoots()`/`ping()`) fail with a typed local error before anything reaches the wire; their behavior toward -2025-era requests is unchanged. +2025-era requests is unchanged. The same split applies to +`throw new UrlElicitationRequiredError(...)`: on 2025-era connections it is unchanged — +the throw still produces the `-32042` protocol error, not an `isError` result; on +2026-07-28 requests it fails with a clear error steering to +`inputRequired.elicitUrl(...)` rather than being converted silently. `requestState` round-trips as an opaque, **untrusted** string — see [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) @@ -484,11 +488,11 @@ driver's semantics exactly: Knobs live at `ServerOptions.inputRequired`: -| Member | Default | Meaning | -| --- | --- | --- | -| `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow | -| `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply | -| `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern | +| Member | Default | Meaning | +| ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow | +| `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply | +| `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern | Failures surface **per family**: `tools/call` failures (capability refusal, a failed leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts @@ -519,8 +523,10 @@ phase state so they increase across re-entries — the token spans the whole flo today's `elicitInput` in that configuration, which waits out its own 60s default. Interactive tools need a streaming-capable session. - The 2025-era `notifications/elicitation/complete` channel for URL-mode elicitation - is not bridged (upstream gap F8): URL-mode legs complete like any other elicitation - response. + is not bridged: URL-mode legs complete like any other elicitation + response. The sender API for that channel, + `Server.createElicitationCompletionNotifier()`, is itself unchanged from v1 for + 2025-era URL-mode elicitation — only the shim does not bridge it. --- @@ -607,7 +613,10 @@ Task methods are excluded from the typed method maps: `RequestMethod` / `Request `notifications/tasks/status` entries, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` (no -`| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. Where +`| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. +(The published `2.0.0-alpha.3` typings predate this exclusion — there the typed maps +still carry the `tasks/*` entries and the `CreateTaskResult` unions; narrow with the +`isCallToolResult` guard until the next published alpha.) Where task interop is genuinely required, use the explicit-schema custom-method form (`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*` requests → `-32601`. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 924d3174cc..f59ecb7dbe 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -35,6 +35,9 @@ If you are already on v2 and want to adopt the **2026-07-28 protocol revision**, codemod prints the exact command after it runs. 6. **Run your tests.** +Migrating a large codebase gradually instead of in one pass? See +[Migrating in stages (large codebases)](#migrating-in-stages-large-codebases). + ## Contents - [What the codemod handles](#what-the-codemod-handles) @@ -74,7 +77,8 @@ In addition the codemod: your imports actually use). - Rewrites `.tool()` / `.prompt()` / `.resource()` to `registerTool` / `registerPrompt` / `registerResource` and wraps `inputSchema` / `outputSchema` / `argsSchema` / - `uriSchema` raw Zod shapes with `z.object()`. + `uriSchema` raw Zod shapes with `z.object()`, adding `import { z } from 'zod'` + when the file has no `z` binding. - Drops the result-schema argument from `client.request()` / `client.callTool()` for spec methods. - Routes the spec Zod `*Schema` constants imported from `sdk/types.js` to @@ -85,7 +89,9 @@ In addition the codemod: is marked with an action-required diagnostic instead (see [Experimental tasks interception removed](#experimental-tasks-interception-removed)). - Renames `ErrorCode` → `ProtocolErrorCode` and routes the local-only members - (`RequestTimeout`, `ConnectionClosed`) to `SdkErrorCode`. + (`RequestTimeout`, `ConnectionClosed`) to `SdkErrorCode` — rewriting an all-SDK + condition's `instanceof ProtocolError` guard to `SdkError`, and marking guards + that mix the two enums. - Renames every `StreamableHTTPError` reference to `SdkHttpError` and adds the import (constructor calls are marked for review — argument shape changed). - Replaces `IsomorphicHeaders` with the Web Standard `Headers` type and drops the @@ -98,6 +104,13 @@ In addition the codemod: to `ResourceTemplateType` (the spec wire type). The `ResourceTemplate` URI-template helper **class** from `server/mcp.js` keeps its name and is not renamed. - Drops `@modelcontextprotocol/sdk/server/zod-compat.js` imports. +- Inverts optional completable nesting — `completable(schema.optional(), cb)` becomes + `completable(schema, cb).optional()` (see + [Standard Schema objects](#standard-schema-objects-raw-shapes-deprecated)); shapes it + cannot invert get an `@mcp-codemod-error` marker. +- Drops `Protocol` / `mergeCapabilities` from `shared/protocol.js` imports, re-exports, + mocks, and dynamic imports — no v2 package exports them — leaving a marker with the + replacement at each site. ## What the codemod does NOT handle @@ -105,10 +118,11 @@ Each of these maps to a manual section below. The codemod marks every site it recognized but could not safely rewrite with an `@mcp-codemod-error` comment. - **Node 20 / ESM** — pre-flight, not a code rewrite. → [Packaging & runtime](#packaging--runtime) -- **`new Headers()` / `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` +- **Header-read `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but - converting bracket access to `.get()` and wrapping plain objects with `new Headers()` - is manual. → [HTTP & headers](#http--headers) + converting that bracket access to `.get()` is manual. (Headers you _pass in_ via + `requestInit.headers` need no rewrite — plain objects remain valid.) + → [HTTP & headers](#http--headers) - **`ctx.mcpReq.send()` schema-arg drop** — the codemod drops the schema arg from `client.request()` / `client.callTool()` but leaves nested `ctx.mcpReq.send()` calls alone. → [Low-level protocol](#low-level-protocol--handler-context-ctx) @@ -121,6 +135,15 @@ recognized but could not safely rewrite with an `@mcp-codemod-error` comment. `t.CallToolResultSchema.parse(…)` can't be split per-symbol; the codemod flags it action-required — re-import the schema from `@modelcontextprotocol/core` by hand. → [Types & schemas](#types--schemas) +- **Import-less (injected) SDK surfaces** — the codemod is import-driven: a file that + receives the SDK surface as a parameter (dependency injection, factory seams) and has + no SDK import is never rewritten, and the v1 idioms there fail at **runtime**, not + compile time — e.g. the v1 schema-first `setRequestHandler(Schema, …)` form throws a + `TypeError` at registration. Grep such seams for v1 API tokens beyond import + statements (`setRequestHandler(`, `ErrorCode.`, `extra.`) and apply the + [handler-registration](#setrequesthandler--setnotificationhandler-use-method-strings) + and [Errors](#errors) sections by hand. + → [Low-level protocol](#low-level-protocol--handler-context-ctx) - **Behavioral adaptation** — list auto-aggregation, capability empties, lazy validator compilation, output-schema validation rules. → [Behavioral changes](#behavioral-changes) @@ -147,12 +170,39 @@ whichever package you already depend on. `@modelcontextprotocol/core-internal` i `@modelcontextprotocol/core` is the public Zod-schema package (raw `*Schema` constants only); see [Zod `*Schema` constants moved to `@modelcontextprotocol/core`](#zod-schema-constants-moved-to-modelcontextprotocolcore) below. -After the codemod runs, verify the dependencies in `package.json`: the swap rewrites -the **nearest** manifest found walking up from the target directory — one manifest -total, so workspace-member manifests in a monorepo are not visited (remove the v1 -dependency from those by hand once nothing imports it). On already-migrated sources -the codemod still removes the v1 dependency but may not add the v2 packages you need -— check both directions. +After the codemod runs, review the manifest summary it prints: the swap rewrites the +**nearest** manifest found walking up from the target directory — one manifest total. +Workspace-member manifests in a monorepo are never modified; instead the codemod lists +each member that still declares the v1 SDK together with the exact dependency changes +it needs (remove the v1 entry, add the v2 packages that member's imports use) — apply +those edits yourself, then install. The v2 additions are computed from the final import +state of each package's sources, so already-migrated sources still receive the v2 +packages they need when the v1 dependency is removed. In a hoisted monorepo (members +without their own SDK dependency), member usage counts toward the manifest that +declares the v1 SDK, and the summary notes which members contributed. See +[Monorepo workspace members](#monorepo-workspace-members) for how to decide each +member's packages. + +#### Monorepo workspace members + +Declare in every member exactly what its own sources import: files importing +`@modelcontextprotocol/server` (or its subpaths) need `@modelcontextprotocol/server`; +client imports need `@modelcontextprotocol/client`; raw `*Schema` constants need +`@modelcontextprotocol/core`; a framework adapter import (`@modelcontextprotocol/express` +etc.) needs the adapter package **plus the framework itself** in that member (the +adapter declares it as a peer dependency). Place a package in `dependencies` when +shipped runtime code imports it and in `devDependencies` when only tests, fixtures, or +local tooling do — when in doubt, use the section where the member previously declared +`@modelcontextprotocol/sdk`. + +A member that never declared the v1 SDK and resolved it through the root can keep +root-level declarations (the codemod's root rewrite already adds the union of the +contributing members' v2 packages — its hoisting note names them) or move to +per-member declarations; per-member is recommended, since the v2 package split makes each member's +actual needs explicit. To answer "which packages does this member need" directly, run +the codemod against that member's directory with `--dry-run`: the manifest summary is +computed from that member's own imports. (The authoritative import-path routing lives +in the codemod's [mapping file](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts).) The framework adapter packages declare their framework as a **peer dependency** (`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the @@ -165,6 +215,153 @@ unmet-peer warning for `hono` (upstream `@hono/node-server` declares it). v2 requires **Node.js 20+** and ships **ESM only**. If your project uses CommonJS (`require()`), either migrate to ESM or use dynamic `import()`. +Repo-local tooling that encodes the literal v1 package name — dependency-pin lints, +version allowlists, CI checks, scripts — fails after the manifest swap and is invisible +to the codemod (it rewrites sources and manifests, not bespoke gates). Grep for +`@modelcontextprotocol/sdk` outside `src/` before declaring the migration done. While +grepping, also remove v1-era double casts on SDK types (`as unknown as Transport` and +similar, usually annotated to a v1 issue) — v2's types satisfy those contracts +directly, and a surviving cast keeps suppressing type checking that would otherwise +catch real errors. + +Tooling that pins SDK **dist text** (reading a constant out of a built file with +`require.resolve` + a regex) breaks in three stacked ways: the v2 exports maps offer +nothing a CJS `require.resolve` can find; the literal usually lives in a +content-hashed sibling chunk (`dist/sse-.mjs`), not the subpath's entry module, +so fixed-path reads do not survive a rebuild — scan the package's `dist/` directory +for the literal instead; and the emitted quote style differs from v1, so a +quote-anchored pattern misses silently — match either quote. v2 also ships ESM only: +`/dist/cjs/` ↔ `/dist/esm/` flavor-pair path swaps have no equivalent. + +#### Registry availability during the alpha + +All v2 packages are published on the public npm registry. Two notes for the alpha +window: + +- The packages do not share one version number — at the time of writing + `@modelcontextprotocol/core` rides a lower prerelease than its siblings. The + codemod writes ranges that match what is published, so prefer its manifest output + over hand-pinning every package to the same tag. +- Environments that resolve through a corporate or private registry mirror may not + have synced the newer scoped packages yet (the symptom is "not found" for a package + that exists on npmjs.org). Point the install at the public registry + (`npm install --registry=https://registry.npmjs.org/` or the equivalent `.npmrc` + entry), ask your mirror's operators to sync the `@modelcontextprotocol` scope, or — + where neither is possible — build a tarball from a checkout of this repository + (`pnpm install && pnpm build`, then `pnpm pack` in the package directory) and + reference it with a committed `file:` dependency. + +#### CommonJS test runners (Jest) cannot resolve the v2 packages + +Every leaf of the v2 packages' `exports` maps carries only the `types` and `import` +conditions — there is no `require` or `default` leaf — so the packages cannot be +resolved by CJS resolvers at all. Jest under its default CommonJS resolution (including +`next/jest` setups) fails with `Cannot find module '@modelcontextprotocol/client'` even +when a transform that handles ESM is configured: resolution fails before any transform +runs. Vitest and native Node ESM are unaffected. + +The interim recipe — interim because the packaging shape is still under discussion and +a later alpha may make it unnecessary — maps the bare specifiers straight to the dist +ESM files and lets the transform convert them (the dists contain no `import.meta`, so +an ESM→CJS transform such as SWC or Babel handles them cleanly): + +```js +// jest.config.js +transformIgnorePatterns: [], // or a pattern that still transforms @modelcontextprotocol/* +moduleNameMapper: { + '^@modelcontextprotocol/client$': '/node_modules/@modelcontextprotocol/client/dist/index.mjs', + '^@modelcontextprotocol/server$': '/node_modules/@modelcontextprotocol/server/dist/index.mjs', + // `_shims` is the packages' internal runtime-selection self-reference; + // pin it to the Node build under jest. + '^@modelcontextprotocol/client/_shims$': '/node_modules/@modelcontextprotocol/client/dist/shimsNode.mjs', + '^@modelcontextprotocol/server/_shims$': '/node_modules/@modelcontextprotocol/server/dist/shimsNode.mjs', +}, +``` + +The entries are exact-anchored — add one per subpath you import (`/stdio` → +`dist/stdio.mjs`, `/validators/cf-worker` → `dist/validators/cfWorker.mjs`) and one for +`@modelcontextprotocol/core` (`dist/index.js`) if you import the raw schemas. The +`_shims` mappings are required whenever the matching root mapping is present: the dist +entry files import `@modelcontextprotocol/client/_shims` (a package self-reference) +internally, and that specifier fails CJS resolution the same way. In a hoisted +monorepo, point the paths at the `node_modules` directory your package manager actually +installs into. + +#### Bundlers: nested `zod` copies in zod@3-pinned monorepos + +v1's `zod ^3.25 || ^4.0` peer range deduplicated onto a workspace's hoisted zod@3. The +v2 packages depend on `zod ^4.2.0`, so in a workspace that pins zod@3 the dependency +cannot dedupe — each installed v2 package resolves its own nested zod@4 copy. Two +bundler consequences: + +- **Path-substring vendor pins capture the nested copies.** Bundler rules that match + zod by module path — `manualChunks` pins, vendor-chunk matchers, bundle budgets keyed + on a `zod/` path segment — also match `@modelcontextprotocol/*/node_modules/zod`, + which can pull the nested copies into an eagerly-loaded vendor chunk and trip a + budget gate. Exclude the SDK-nested paths from such pins so the copies ride with the + SDK's own (typically lazy) chunks. +- **Ballpark size cost.** Measured on a large production SPA, adding the v2 client and + server packages (with their nested zod@4 copies) alongside a hoisted zod@3 cost + roughly +83 KB gzipped of total JS (about +0.7% whole-app). Upgrading the workspace + to `zod ^4.2.0` re-dedupes and removes the duplication. + +#### Migrating in stages (large codebases) + +The v1 package and the v2 packages have **different names**, so both can be installed +in one manifest at the same time — nothing forces a one-shot swap. The safe order for +an incremental migration: (1) add the v2 packages (and the `zod ^4.2.0` bump) while +**keeping** `@modelcontextprotocol/sdk`; (2) rewrite sources incrementally, +directory-by-directory or package-by-package; (3) remove the v1 dependency only when +nothing imports it any more (`grep -rn "@modelcontextprotocol/sdk" --include="*.ts"`, +plus a look at `package.json`). The inverse order strands files: swapping the manifest +first leaves every not-yet-rewritten import failing module resolution (TS2307) until it +is updated. + +Two caveats for the transition window. First, a codemod run against a subdirectory +still updates the nearest manifest walking up — including removing the v1 dependency — +so during a staged pass review or revert that edit until the final stage (or preview +with `--dry-run`). Second, v1 and v2 modules each have their own classes and types: +objects must not flow between v1-imported and v2-imported code (`instanceof` and +nominal types do not cross — the same boundary described for dual-role processes in +[Errors](#errors)), so stage along process or transport boundaries where the two sides +share only the wire format; the two sides negotiate +a protocol version through the ordinary 2025-era `initialize` handshake and settle +on the newest revision both packages support (currently 2025-11-25 — published v1 +1.29.x and v2 ship the same supported-version list). + +Dependencies you do not control (vendored fixtures, third-party packages) that still +declare `@modelcontextprotocol/sdk` resolve their own v1 copy and need no action. For +`peerDependencies` declarations, keep the v1 package installed to satisfy the range — +or point the name at a chosen version via your package manager's +`overrides`/`resolutions` — until those packages migrate. The same boundary rule +applies: objects must not flow between their v1-imported code and your v2-imported +code. + +**Dependencies that compile against the host's v1 SDK.** A stricter variant of the +above: a workspace or vendored package that ships TypeScript **source** importing +`@modelcontextprotocol/sdk` — resolved from the host's `node_modules` rather than its +own — pins the host. Keep the v1 package installed as a real dependency (not merely a +surviving transitive) until that package migrates. The host files that construct or +hand objects to such a package are part of its v1 boundary and must stay on v1 imports +— and the codemod cannot see that distinction: it rewrites them like any other file +(e.g. converting a `setRequestHandler(Schema, …)` call into the v2 method-string form +against what is still a v1 `Server`, which then fails at runtime). Run the codemod with +`--ignore` glob patterns covering those interfacing files, and migrate them together +with the dependency later. The boundary rule above applies unchanged: objects from the +dependency's v1 modules must never flow into v2-imported code. + +#### Library authors: peer-depending on the SDK + +If your package declares `@modelcontextprotocol/sdk` as a `peerDependency`, the v2 +packages are differently **named**, so swapping the peer declaration is itself a +breaking change for every consumer — ship it as a semver-major. You can migrate +ahead of your consumers only if no SDK object crosses your public API (the +v1/v2 boundary rule above applies to your exports too: a v1-constructed `Client` +or error instance handed to v2-importing consumer code fails `instanceof` and +nominal checks). Until your consumers migrate, they can keep resolving your peer +range with the v1 package installed alongside their own v2 packages — the two +coexist under different names. + ### Imports & transports The codemod rewrites every `@modelcontextprotocol/sdk/...` import path via @@ -203,7 +400,10 @@ A few transports need a decision the codemod can't make: local servers; the `Transport` interface is exported if you need a custom implementation. - **`InMemoryTransport`** is now exported from `@modelcontextprotocol/client` and - `@modelcontextprotocol/server` (both re-export it): + `@modelcontextprotocol/server` (both re-export it). The two packages bundle separate + copies with private state, so the halves of a linked pair must come from the **same + package's** import — pick one package per file (per linked pair) rather than mixing + the client's `InMemoryTransport` with the server's: ```typescript // v1 @@ -215,6 +415,12 @@ A few transports need a decision the codemod can't make: - **`EventStore`, `StreamId`, `EventId`** are exported from `@modelcontextprotocol/server` only (v1 re-exported them alongside the transport from `sdk/server/streamableHttp.js`; `@modelcontextprotocol/node` does not). +- **Client fetch middleware moved to the root barrel.** `createMiddleware`, + `applyMiddlewares`, `withLogging`, `withOAuth`, and the `Middleware` type (v1: + `sdk/client/middleware.js`) are now exported from `@modelcontextprotocol/client` + directly, as is `FetchLike` (v1: `sdk/shared/transport.js`). The call signatures are + unchanged from v1 (`Middleware` is still `(next: FetchLike) => FetchLike`) — only the + import path changes. - **Server auth split.** Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, @@ -233,6 +439,11 @@ A few transports need a decision the codemod can't make: `…/server/middleware/hostHeaderValidation.js` to `@modelcontextprotocol/express`. The AS→`server-legacy` routing is conservative — re-point RS-only call sites (`requireBearerAuth`, `mcpAuthMetadataRouter`) at `@modelcontextprotocol/express` by hand. + Staying on the frozen `server-legacy/auth` copy is a supported interim choice when you + deliberately want the v1 middleware behavior. If you re-point at + `@modelcontextprotocol/express` by hand, also add that package — plus its `express` + peer dependency — to your manifest: the codemod's manifest summary reflects only the + imports it wrote, not re-points you make afterwards. ### Low-level protocol & handler context (`ctx`) @@ -245,29 +456,39 @@ The codemod renames the parameter and remaps property access via A few mappings need optional-chaining adjustment (the `http` group is `undefined` on stdio): -| v1 (`extra.*`) | v2 (`ctx.*`) | Note | -| ------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------ | -| `extra.signal` | `ctx.mcpReq.signal` | | -| `extra.requestId` | `ctx.mcpReq.id` | | -| `extra._meta` | `ctx.mcpReq._meta` | | -| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | -| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | -| `extra.sessionId` | `ctx.sessionId` | | -| `extra.authInfo` | `ctx.http?.authInfo` | optional — `undefined` on stdio | -| `extra.requestInfo` | `ctx.http?.req` | a standard Web `Request`; `ServerContext` only | -| `extra.closeSSEStream` | `ctx.http?.closeSSE` | `ServerContext` only | -| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` | `ServerContext` only | -| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed_ | see [Experimental tasks](#experimental-tasks-interception-removed) | - -`BaseContext` is the common base; `ServerContext` and `ClientContext` extend it. -`ServerContext.mcpReq` adds convenience methods that replace calling `server.*` from -inside a handler: - -| `ctx.mcpReq.*` (new) | Replaces (inside a handler) | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `ctx.mcpReq.log(level, data, logger?)` | `server.sendLoggingMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | -| `ctx.mcpReq.elicitInput(params, options?)` | `server.elicitInput(...)` | -| `ctx.mcpReq.requestSampling(params, options?)` | `server.createMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | +| v1 (`extra.*`) | v2 (`ctx.*`) | Note | +| ------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `extra.signal` | `ctx.mcpReq.signal` | | +| `extra.requestId` | `ctx.mcpReq.id` | | +| `extra._meta` | `ctx.mcpReq._meta` | | +| `extra.sendRequest(...)` | `ctx.mcpReq.send(...)` | | +| `extra.sendNotification(...)` | `ctx.mcpReq.notify(...)` | | +| `extra.sessionId` | `ctx.sessionId` | | +| `extra.authInfo` | `ctx.http?.authInfo` | optional — `undefined` on stdio | +| `extra.requestInfo` | `ctx.http?.req` | a standard Web `Request`; `ServerContext` only | +| `extra.closeSSEStream` | `ctx.http?.closeSSE` | `ServerContext` only; the member itself is also optional — defined only when the transport has an `eventStore` AND the client's negotiated protocol version supports resumable close (2025-11-25+); an `eventStore` transport serving a 2025-06-18 client still leaves it `undefined`. Call as `ctx.http?.closeSSE?.()` | +| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` | `ServerContext` only; member optional as above — `ctx.http?.closeStandaloneSSE?.()` | +| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed_ | see [Experimental tasks](#experimental-tasks-interception-removed) | + +The transport-level seam behind `ctx.http?.authInfo` is unchanged from v1: a transport +that passes `{ authInfo }` as the second argument to `onmessage(message, extra)` — e.g. +an `InMemoryTransport` test seam — still surfaces it as `ctx.http?.authInfo` on any +transport, and `ctx.http` is defined whenever `authInfo` is supplied, even without an +HTTP transport. + +`BaseContext` is the common base; `ServerContext` and `ClientContext` extend it. None +of the three takes type parameters — v1's `RequestHandlerExtra` +arguments selected request/notification unions that the v2 context carries +intrinsically, so their removal loses no type information; review only handlers that +passed custom (non-standard) unions, whose `sendRequest` / `sendNotification` typing +was narrowed by them. `ServerContext.mcpReq` adds convenience methods that replace +calling `server.*` from inside a handler: + +| `ctx.mcpReq.*` (new) | Replaces (inside a handler) | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctx.mcpReq.log(level, data, logger?)` | `server.sendLoggingMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577); the notification also becomes request-related on every era — see [§`ctx.mcpReq.log()` is request-related on every era](#ctxmcpreqlog-is-request-related-on-every-era) | +| `ctx.mcpReq.elicitInput(params, options?)` | `server.elicitInput(...)` | +| `ctx.mcpReq.requestSampling(params, options?)` | `server.createMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | #### Deprecated in v2 (SEP-2577) @@ -279,7 +500,9 @@ instead. - **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and - the `ctx.mcpReq.log` / `ctx.mcpReq.requestSampling` handler-context helpers. + the `ctx.mcpReq.log` / `ctx.mcpReq.requestSampling` handler-context helpers. Outside a + handler, `McpServer` users reach the `Server.*` methods via the unchanged + [`mcpServer.server` accessor](#unchanged-apis). - **Capability fields**: the `roots`, `sampling`, and `logging` capability schema fields. - **Type stacks**: the full Logging stack (`LoggingLevel`, `SetLevelRequest`, `LoggingMessageNotification` and params), the full Sampling stack @@ -290,8 +513,10 @@ instead. - **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata Documents per SEP-991. -JSDoc/types only — wire behavior is unchanged and remains functional for at least the -twelve-month deprecation window. +The deprecation is annotation-only — JSDoc `@deprecated` markers were added, nothing +else: every deprecated runtime API keeps its v1 call signature (e.g. +`Server.sendLoggingMessage(params, sessionId?)` keeps the two-argument form) and its +wire behavior, and remains functional for at least the twelve-month deprecation window. #### `setRequestHandler` / `setNotificationHandler` use method strings @@ -315,6 +540,52 @@ is at `ctx.mcpReq._meta`. The 3-arg notification handler is `(params, notificati server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { ... }); ``` +The custom form also covers **spec method names carried with custom payloads**: a v1 +integration that reused a spec method string for its own payload shape (e.g. +`notifications/message` notifications carrying a proprietary params object) registers +it with the 3-arg form and its own schema. The overloads are selected by the arguments' +shape, not by the method name — a schemas object as the second argument always selects +the custom form, which validates against **your** schema (the spec schema is not +applied) and hands the handler the parsed params rather than the envelope. + +**Spec notifications** use the 2-arg form `setNotificationHandler(method, handler)`. +Unlike the 3-arg custom form, the spec-form handler receives the **full notification +envelope** (`{ method, params }`), parsed against the spec schema — read +`notification.params`: + +```typescript +client.setNotificationHandler('notifications/tools/list_changed', async notification => { + console.log(notification.method, notification.params); +}); +``` + +The two overloads are selected by the method string's **type**: the spec form binds the +method to the `NotificationMethod` union (`RequestMethod` on the request side — both +exported), so a method string computed at runtime must be typed as `NotificationMethod` +to select it; an untyped `string` lands on the custom-schema overload and fails to +compile without a schemas argument. `Parameters[0]` +also resolves to the custom `string` overload by design — name `NotificationMethod` +directly instead. The request side has the same trap one slot over: +`Parameters` (and `typeof`-indexed casts over the overload +set) resolve against the 3-arg custom-method overload, so index `[1]` is the +`{ params, result }` schemas object, **not** the handler — v1 signature-erasing handler +casts derived positionally change meaning with no runtime symptom. Name the exported +types (`RequestMethod` and your own handler/param types) instead of deriving them +positionally. Generic helpers that v1 parameterized on a notification schema need +this conversion by hand; the codemod only warns on them. + +**Handler returns are spec-typed.** In v1 the handler's return type flowed from the +schema you registered; v2 types it from the method name (`'tools/list'` → +`ListToolsResult`, and so on). Tool tables kept as plain object literals surface two +recurring compile errors: an unannotated literal widens `type: 'object'` to `string` +and no longer satisfies the spec type's `type: 'object'` literal member (fix: +`type: 'object' as const`, or annotate the table as `Tool[]`); and a heterogeneous +table whose inferred union carries `prop?: undefined` members does not satisfy the spec +types' `Record` index signatures, since `undefined` is not a +`JSONValue` (fix: annotate the handler's return type — +`async (req): Promise => …` — or the table itself, so each literal is +checked against the target type instead of being inferred and widened first). + #### `request()`, `ctx.mcpReq.send()`, and `callTool()` no longer require a schema for spec methods For **spec** methods, drop the result-schema argument; the SDK resolves it from the @@ -358,8 +629,22 @@ For byte-exact forwarding (member order preserved), pass your own accept-anythin Standard Schema instead. Check call sites whose `method` is **not a literal** — the codemod may have dropped the schema argument there; restore it. +The **inbound half** — a relay re-emitting an upstream JSON-RPC error from its own +handler — has a supported surface too: reconstruct the typed error with +`ProtocolError.fromError(code, message, data)` and throw it; the encode seam serializes +it back to the wire shape (see [Typed `ProtocolError` subclasses](#typed-protocolerror-subclasses)). +Note this is typed reconstruction, not byte-exact relay: legacy codes are normalized at +the encode seam (`-32002` re-emits as `-32602`) and the typed subclasses keep only their +schema-defined `data` members, so extra upstream data keys are dropped. Throwing a plain +object carrying `.code` / `.message` / `.data` happens to work today, but it is +unspecified behavior — prefer `fromError`. + The return type is inferred from the method name via `ResultTypeMap` (e.g. `client.request({ method: 'tools/call', ... })` returns `Promise`). +v1 call sites that passed `CreateMessageResultWithToolsSchema` explicitly need no +replacement: the schema-less send resolves to +`CreateMessageResult | CreateMessageResultWithTools`, and validation selects the +with-tools variant when the request set `tools` or `toolChoice`. ### Server registration API @@ -382,6 +667,16 @@ server.registerTool('greet', { description: 'Greet a user', inputSchema: z.objec `registerResource` requires a `metadata` argument — pass `{}` if you have none. +A tool or prompt registered **without** an `inputSchema` / `argsSchema` passes the +context as its callback's single argument — v1 passed `(extra)`, v2 passes `(ctx)`: + +```typescript +server.registerTool('ping', { description: 'Liveness check' }, async ctx => ({ content: [] })); +``` + +A one-parameter callback typechecks under either reading, so remember that the first +parameter here is the context object, not an args object. + #### Standard Schema objects (raw shapes deprecated) v2 expects schema objects implementing the [Standard Schema spec](https://standardschema.dev/) @@ -390,6 +685,14 @@ are still **accepted via `@deprecated` overloads** on `registerTool`/`registerPr (auto-wrapped with `z.object()`), and `completable()` accepts any `StandardSchemaV1`; prefer wrapping explicitly. Zod v4, ArkType, and Valibot all implement the spec. +For **optional completable arguments**, apply `.optional()` to the _result_ of +`completable()` — `completable(z.string(), cb).optional()`, not +`completable(z.string().optional(), cb)`. v2 resolves completion metadata on the schema +found after unwrapping an outer optional wrapper, so the v1 nesting returns empty +completion lists — nothing errors — and if no argument carries completion metadata in +the v2 position, the server does not advertise the `completions` capability at all. The +codemod inverts the common nesting automatically and flags shapes it cannot rewrite. + **Zod v3 is no longer supported** (v1 peer was `^3.25 || ^4.0`). Check the **declared range** in your `package.json`, not just the installed version: a zod-3 range that satisfied the v1 peer installs and typechecks cleanly under v2 and only fails at @@ -409,6 +712,61 @@ via `fromJsonSchema()`. (Raw shapes are wrapped with the SDK's **bundled** Zod with a foreign Zod they fail at registration or at the first `tools/list`; pass `z.object()`-wrapped schemas from your own Zod instead.) +In a monorepo that pins zod@3 workspace-wide and cannot bump, step (1) can be applied +**per workspace member**: add a zod-4 alias dependency to the migrating member only — +`"zod-v4": "npm:zod@^4.2.0"` in that member's `package.json` — and author SDK-bound +schemas with it (`import { z } from 'zod-v4'`), leaving the rest of the workspace, and +the member's own zod-3 consumer schemas, untouched. The alias copy does not need to be +the same instance as the SDK's bundled zod: conversion runs through the **authoring** +instance's `~standard.jsonSchema`, so `.describe()` descriptions are preserved and the +emitted dialect is 2020-12. Keep the two z's apart — schemas authored with the alias +are for the SDK; they do not compose with the workspace's zod-3 schemas. (For the +bundle-side effects of the same pin, see +[Bundlers: nested `zod` copies](#bundlers-nested-zod-copies-in-zod3-pinned-monorepos).) + +**Hosts that forward consumer-authored schemas.** The ladder assumes you author the +schemas yourself. A host API that accepts raw shapes or schemas written by **its own +consumers** — plugin systems, agent frameworks — cannot control the authoring zod +version or instance, and v1's built-in conversion of foreign shapes is gone. Convert on +the host side and register the result with `fromJsonSchema()`: zod-4 input via zod's +own `z.toJSONSchema(z.object(shape), { io: 'input', target: 'draft-2020-12' })` (the +conversion is runtime-structural, so a zod ≥4.2 in the host handles schemas built by a +different zod-4 copy), zod-3 input via the +[`zod-to-json-schema`](https://www.npmjs.com/package/zod-to-json-schema) package. Strip +the `$schema` member from the converted output before passing it to `fromJsonSchema()` +— `zod-to-json-schema` stamps a draft-07 `$schema` by default, and the default +validator [accepts 2020-12 only](#json-schema-2020-12-posture-sep-1613-sep-2106). + +How a too-old zod surfaces depends on which entry point your code imports. With +main-entry `import { z } from 'zod'` on a zod-3 range, the project **typechecks cleanly +and fails at the first `tools/list`** (the quiet runtime path above). With +`import * as z from 'zod/v4'` — or any zod whose _typings_ predate +`~standard.jsonSchema` (zod 4.0–4.1, and zod 3.25.x via the `zod/v4` subpath) — the +same code **runs** through the bundled fallback but **fails to compile**: +`registerTool`/`registerPrompt` reject the schema with `TS2769: No overload matches +this call` listing both overloads. The real cause is buried in the first overload's +elaboration — `Property 'jsonSchema' is missing in type …` (that property is +`~standard.jsonSchema`, added in zod 4.2.0) — and a follow-on implicit-`any` error on +the handler's arguments usually appears below it. If you see that two-overload error on +a registration call with a zod schema, check the installed zod version before anything +else; both symptoms resolve identically with step (1) of the ladder. + +Projects that must stay below zod 4.2 and accept the documented runtime fallback can +resolve the remaining registration compile errors with an explicit assertion to the +registration schema type — `inputSchema: schema as unknown as +StandardSchemaWithJSON` — or a small typed wrapper that attaches a +`~standard.jsonSchema` provider (step (2) of the ladder, which changes runtime +conversion but not the schema's static type) and returns the asserted type. The +fallback caveats (one-time warning, dropped `.describe()` descriptions) still apply +unless the provider is attached. + +The forced zod-4 bump also surfaces zod's **own** type-level API changes in consumer +annotations: `z.ZodTypeDef` no longer exists and `z.ZodType`'s generic parameters +changed, so v3-era annotations like `z.ZodType` fail to +compile — see [zod's v3-to-v4 changelog](https://zod.dev/v4/changelog). Consumer-only +schemas can keep compiling via zod's v3 compat subpath (`zod/v3`), but anything passed +to the SDK must be a zod-4 (or other Standard Schema) schema. + The deprecated raw-shape overloads exist only on `registerTool` / `registerPrompt`. `RegisteredTool.update()` / `RegisteredPrompt.update()` take **schema objects** (`paramsSchema` / `outputSchema`: `StandardSchemaWithJSON`) — a raw shape passed to @@ -447,8 +805,9 @@ produces the dialect v2 advertises. ### HTTP & headers -Transport APIs and `ctx.http?.req?.headers` use the Web Standard `Headers` object -(`IsomorphicHeaders` is removed). `ctx.http?.req` is a standard Web `Request`. +Header **reads** use the Web Standard `Headers` object (`IsomorphicHeaders` is +removed): `ctx.http?.req` is a standard Web `Request`, so +`ctx.http?.req?.headers` takes `.get()` instead of bracket access. ```typescript // v1 @@ -457,14 +816,20 @@ const transport = new StreamableHTTPClientTransport(url, { }); const sessionId = extra.requestInfo?.headers['mcp-session-id']; -// v2 +// v2 — requestInit is unchanged; only the header *read* changes const transport = new StreamableHTTPClientTransport(url, { - requestInit: { headers: new Headers({ Authorization: 'Bearer token' }) } + requestInit: { headers: { Authorization: 'Bearer token' } } }); const sessionId = ctx.http?.req?.headers.get('mcp-session-id'); const debug = new URL(ctx.http!.req!.url).searchParams.get('debug'); ``` +On the **write** side, `requestInit` on `StreamableHTTPClientTransport` / +`SSEClientTransport` options is a standard fetch `RequestInit`, so `headers` accepts +any `HeadersInit` — a plain object record (as above), a tuple array, or a `Headers` +instance all keep working unchanged; the transports normalize whichever form they +receive. Wrapping with `new Headers()` is optional, not required. + `StreamableHTTPClientTransport` now **appends** any custom `requestInit.headers.Accept` value to the spec-required `application/json, text/event-stream` (v1 let it replace them). The required media types are always present; additional types are kept for @@ -548,6 +913,25 @@ if (e instanceof SseError && (e.code === 401 || e.code === 403)) reauth(); // SS Silent at runtime (no compile error) — grep for `.code ===` status comparisons. +**Classification keyed on the error class name.** The same import-free classifiers +often match by name instead of code: telemetry and allowlists keyed on `error.name` or +`error.constructor.name` against `'McpError'` / `'StreamableHTTPError'` silently stop +matching — the v2 classes are named `ProtocolError`, `SdkError`, and `SdkHttpError`, +and all three assign `.name` accordingly. One v1 asymmetry disappears along the way: +v1's `StreamableHTTPError` never assigned `.name` (instances reported `'Error'`), so +`.name`-keyed matchers saw only `'McpError'`; v2's `SdkHttpError` reports +`'SdkHttpError'`, and assertions pinning `.name === 'Error'` on transport errors need +re-baselining. Add the v2 names to your match lists; during a +[staged migration](#migrating-in-stages-large-codebases) keep the v1 names alongside +for as long as the v1 package remains installed. + +**Status read out of the message text.** Per transport: the Streamable HTTP message +text never carried the status (v1 put it on `.code`, v2 puts it on `.status` — read +`error.status`), and v2's SSE transport still embeds it exactly as v1 did +(`Error POSTing to endpoint (HTTP 404): …`). The silent break is **switching +transports while keeping a message regex**: a status pattern written against SSE +matches nothing on Streamable HTTP. Read `error.status` instead of parsing text. + **Raw numeric code comparisons.** The codemod rewrites `ErrorCode.X` symbol references, but a check against the raw JSON-RPC number — `(e as { code?: unknown }).code === -32000` — is invisible to it and silently never matches in v2, because the two SDK-local codes @@ -558,6 +942,11 @@ it usually targeted are now **string** `SdkErrorCode` values: | `-32000` (ConnectionClosed) | `SdkError` + `SdkErrorCode.ConnectionClosed` | | `-32001` (RequestTimeout) | `SdkError` + `SdkErrorCode.RequestTimeout` | +- Requests that require a session but omit the `Mcp-Session-Id` header still + respond `400` with JSON-RPC `-32000` (`Bad Request: Mcp-Session-Id header is +required`), unchanged from v1 — as with `-32001`, the code is an SDK + convention; key off the HTTP status. + Replace the literal with the named code. Loud (`TS2367`) when the compared value is typed `SdkErrorCode`; silent when the left side is `unknown` or a cast — grep for `=== -32000` / `=== -32001`. @@ -611,13 +1000,26 @@ the third argument — `new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, (carries `data.requiredCapabilities`) are new typed `ProtocolError` subclasses. `resources/read` for an unknown URI now answers `-32602` on every protocol revision (v1.x already emitted `-32602`; an interim `-32002` from earlier v2 alphas is mapped at -the encode seam). The encode-seam mapping applies to **your own throws too**: a handler +the encode seam — published `2.0.0-alpha.3` predates the mapping and still emits +`-32002` on the wire, so accept both until the next published alpha). The encode-seam mapping applies to **your own throws too**: a handler that deliberately throws `ProtocolError(ProtocolErrorCode.ResourceNotFound, …)` reaches peers as `-32602` — a server can no longer emit `-32002` on the wire. `ProtocolErrorCode.ResourceNotFound` (`-32002`) stays importable as receive-tolerated vocabulary — accept both `-32602` and `-32002` from peers. `ProtocolError.fromError(code, message, data)` reconstructs the typed subclass from code + data alone, so it works across bundle boundaries where `instanceof` doesn't. +The default message text changed alongside: v1's unknown-resource error read +`Resource not found`; v2's `ResourceNotFoundError` default is +`Resource not found: ` (the code is unchanged). Tests pinning the exact string +need re-baselining — prefer matching `error.code` plus a URI substring (or the typed +`error.uri`). + +Custom **non-spec** codes pass through untouched: a handler that throws a +`ProtocolError` with a custom code (e.g. `-1`) and `data` reaches the peer as a +JSON-RPC error with that code and `data` unchanged — the encode seam rewrites only the +legacy `-32002` code; `data` is sent verbatim for every thrown error (the typed +subclasses shape their `data` at construction, not at encode time). Construct via +`ProtocolError.fromError(code, message, data)`. ### Auth @@ -697,6 +1099,13 @@ OAuth `onUnauthorized` behavior, for composing your own adapter). - **Metadata discovery falls through on 502.** `discoverAuthorizationServerMetadata()` treats `502 Bad Gateway` like 4xx — fall through to the next candidate URL instead of throwing (fixes path-aware discovery behind reverse proxies). Other 5xx still throw. +- **Scoped credential invalidation on `invalid_client` / `unauthorized_client`.** The + `auth()` retry for these errors now issues two scoped calls — + `invalidateCredentials('client')` then `invalidateCredentials('tokens')` — instead of + v1's single `invalidateCredentials('all')`, deliberately preserving the stored + discovery state so the callback-leg check on retry does not mask the original error. + A provider whose `invalidateCredentials()` implementation special-cases the `'all'` + scope must handle the split calls. #### OAuth client flow errors (new) @@ -712,6 +1121,32 @@ path will not catch them): | Transport 403 `insufficient_scope` with `onInsufficientScope: 'throw'`, or default mode without an `OAuthClientProvider` | `InsufficientScopeError` (`requiredScope`, `resourceMetadataUrl`, `errorDescription`) | | `auth()` callback leg: discovery resolves a different AS than the recorded redirect target | `AuthorizationServerMismatchError` (`recordedIssuer`, `currentIssuer`) | +#### Connect-time OAuth retry (`UnauthorizedError`) + +`UnauthorizedError` survives in v2 (exported from `@modelcontextprotocol/client` — +its only appearance in the error table above is the removed `SSEClientTransport.send()` +401 path), and the v1 connect-time pattern carries over: catch it from `connect()`, +complete the browser flow, call `transport.finishAuth(…)`, reconnect. + +```typescript +try { + await client.connect(transport); +} catch (error) { + if (!(error instanceof UnauthorizedError)) throw error; + // provider.redirectToAuthorization() has been called; complete the flow, + // then reconnect on a FRESH transport (a started transport cannot be restarted). + await transport.finishAuth(new URL(callbackUrl).searchParams); + await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); +} +``` + +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 +[client guide's authentication section](../client.md#authentication). + #### `auth()` options are now `AuthOptions` The inline options object on `auth()` is now the named `AuthOptions` type. New fields: @@ -818,7 +1253,18 @@ add an optional `issuer?: string` field on top of the wire types. `saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the callback leg can verify it is exchanging the code at the same AS the redirect targeted; without it the SDK `console.warn`s once per callback (`discoveryState` must persist with -the same durability as `codeVerifier`). +the same durability as `codeVerifier`). Both methods are optional on +`OAuthClientProvider` and may be sync or async; `OAuthDiscoveryState` (exported from +`@modelcontextprotocol/client`) extends `OAuthServerInfo` with the optional +`resourceMetadataUrl` the protected-resource metadata was found at: + +```typescript +import type { OAuthDiscoveryState } from '@modelcontextprotocol/client'; + +// On OAuthClientProvider: +saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; +discoveryState?(): OAuthDiscoveryState | undefined | Promise; +``` #### Conformance obligations for `OAuthClientProvider` implementers @@ -835,6 +1281,10 @@ The SDK enforces every authorization MUST that lands in SDK code. The following - **Set `application_type` correctly** when overriding the heuristic (SEP-837). - **Track cross-request step-up failures yourself** (SEP-2350) — `maxStepUpRetries` is per request; per-session backoff is host state. +- **Persist discovery state**: implement `discoveryState()` / `saveDiscoveryState()` so + the authorization-server metadata your tokens were issued against survives restarts. +- **Choose the insufficient-scope behavior**: keep the default + `onInsufficientScope: 'reauthorize'`, or handle `InsufficientScopeError` yourself. - **Resource-server operators: do not advertise `offline_access`** in `WWW-Authenticate` `scope` or PRM `scopes_supported` (SEP-2207). @@ -893,10 +1343,56 @@ The Zod-specific `AnySchema` / `SchemaOutput` types from `…/zod-compat.js` are replace with `StandardSchemaV1` / `StandardSchemaV1.InferOutput` (the codemod's removal message says the same). +**Composing two core schemas.** Zod composition needs a shared zod: deriving from a +single core schema (as above) and combining core schemas with your own `z` typecheck +when your `zod` resolves to the **same copy** `@modelcontextprotocol/core` uses (a +`zod ^4.2.0` range that dedupes). When it cannot — a zod@3-pinned project nests core's +own zod@4 — v1 idioms that combined two spec schemas with your `z` no longer compile: +core does not export its zod instance, and a foreign zod's `z.union(…)` / `.or(…)` +rejects core's schema types. For accept-either result parsing, skip composition: +request with the `ResultSchema` passthrough (the same one the +[gateway note](#request-ctxmcpreqsend-and-calltool-no-longer-require-a-schema-for-spec-methods) +uses) and discriminate with sequential `safeParse`: + +```typescript +// v1 — one composed schema +const result = await client.request(req, z.union([CompatibilityCallToolResultSchema, CreateTaskResultSchema])); + +// v2 — passthrough request, then sequential discrimination +import { CompatibilityCallToolResultSchema, CreateTaskResultSchema, ResultSchema } from '@modelcontextprotocol/core'; +const raw = await client.request(req, ResultSchema); +const asTask = CreateTaskResultSchema.safeParse(raw); +const result = asTask.success ? asTask.data : CompatibilityCallToolResultSchema.parse(raw); +``` + +Order the candidates from most to least specific, and `.parse()` the last one so a +result that matches no candidate still fails loudly. + The role-aggregate unions (`ClientRequest`, `ServerResult`, `ServerRequest`, `ClientResult`, `ClientNotification`, `ServerNotification`) and the typed-method maps (`RequestMethod`, `RequestTypeMap`, `ResultTypeMap`, `NotificationTypeMap`) no longer include task vocabulary; the deprecated `Task*` types remain importable on their own. +(One published-alpha qualification, like the `-32002` note in [Errors](#errors): the +`2.0.0-alpha.3` typings predate this — the typed maps there still carry the `tasks/*` +entries, and `ResultTypeMap['tools/call']` still unions `CreateTaskResult`, so a +`client.request({ method: 'tools/call', … })` result does not assign to +`Promise`. Narrow with the `isCallToolResult` guard until the next +published alpha — the guard is the recommended discrimination tool anyway, per the next +paragraph.) + +**Discriminating result shapes: use guards, not the `in` operator.** The v2 +zod-inferred result types are passthrough objects — every union member carries an index +signature — so v1-idiomatic property discrimination such as +`if ('content' in result) { … } else { result.toolResult }` no longer narrows: the `in` +check is satisfiable by every member, and the else branch can collapse to `never` +(surfacing as `TS2339` on the property you then read). Use the exported guards instead: +`isCallToolResult(result)`, or `isSpecType.GetPromptResult(result)` and friends for any +other spec type ([above](#zod-schema-constants-moved-to-modelcontextprotocolcore)). An +adjacent trap when keeping a union for later narrowing: a `const` **annotation** is +control-flow-narrowed straight back to the initializer's type — after +`const r: A | B = await fn()`, `r` has `fn`'s return type, not the union — so when you +need the wider union (e.g. a `CompatibilityCallToolResult` branch), apply an +`as A | B` assertion instead of an annotation. #### Removed type aliases @@ -929,13 +1425,18 @@ names — import the TypeScript types, error classes, enums, and type guards fro `@modelcontextprotocol/client` or `@modelcontextprotocol/server`, and the Zod `*Schema` constants from `@modelcontextprotocol/core`. +One type-level narrowing to note: client/server capability `experimental` payloads are +now typed as JSON-compatible objects (nested JSON values) rather than arbitrary +objects. A payload typed `Record` no longer assigns (`TS2322`) — give +the source a JSON-compatible type or cast at the boundary. + The `Protocol` base class itself is no longer exported (it is internal engine). If you were reaching into protocol internals — rare, mostly debugging tools — `client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every inbound request that no registered handler matches, before capability gating. Delete the v1 `shared/protocol.js` import: `Protocol` has no v2 import path. The codemod -currently rewrites it to a named import from `@modelcontextprotocol/client` that does -not exist (a codemod fix is tracked) — delete that import. +drops `Protocol` (and `mergeCapabilities`) from the rewritten import and leaves an +`@mcp-codemod-error` marker at the site explaining the replacement. #### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) @@ -977,6 +1478,23 @@ rewrite required unless noted. #### Error-shape changes (every era) +- **Unchanged, for re-baselining relief:** timeout rejections still carry + `data.timeout` / `data.maxTotalTimeout` exactly as v1 `McpError` did — v1 assertions + on those survive verbatim. The cancelled-on-timeout signal is unchanged on legacy-era + connections and on stdio/in-memory at any era; on 2026-era Streamable HTTP the cancel + signal is the per-request stream close instead of a `notifications/cancelled` POST + (see [support-2026-07-28.md](./support-2026-07-28.md)). +- **Also unchanged: SSE reconnection exhaustion.** `StreamableHTTPClientTransport`'s + standalone GET-stream reconnection behavior and its exhaustion signal carry over from + v1: when retries run out, the transport emits `onerror` with a plain `Error` whose + message is `Maximum reconnection attempts (N) exceeded.` — there is no typed error + class for this condition, so monitors that match the message text keep working. +- **Also unchanged: elicitation response validation.** `elicitInput`'s local validation + of elicitation responses against `requestedSchema`, the resulting `-32602` error + message wording (`Elicitation response content does not match requested schema: …`), + and the `McpServer` / `Client` `jsonSchemaValidator` option carry over from v1 — + tests pinning the local-validation message and custom validator wiring need no + re-baselining. - **Unknown / disabled tool calls now reject** with `ProtocolError(-32602 InvalidParams)` instead of resolving `CallToolResult{isError: true}`. v1 callers that checked `result.isError` for an unknown tool will get an unhandled rejection — catch the @@ -1025,6 +1543,10 @@ rewrite required unless noted. [support-2026-07-28.md](./support-2026-07-28.md#client-side-versionnegotiation)). v1 had no public equivalent (`SUPPORTED_PROTOCOL_VERSIONS` was a fixed constant) — replace any workaround that patched the offered version with this option. +- **Also unchanged: HTTP 405 tolerances.** A `405` answering the standalone GET stream + open is benign (the client proceeds without the stream), and a `405` answering the + session DELETE resolves `terminateSession()` normally — stateless-topology servers + that decline both verbs keep working without changes, as in v1. #### stdio transport @@ -1040,6 +1562,10 @@ rewrite required unless noted. - `StdioClientTransport` always sets `windowsHide: true` when spawning the server process on Windows (previously Electron-only). Prevents stray console windows in non-Electron Windows hosts. +- Outbound write failures — e.g. the host closing the stdout pipe while a send is + pending — now reject the pending `send()` and close the transport through + `onerror`/`onclose` instead of surfacing an unhandled stream error; lifecycle + tests that pinned a crash-class exit observe a clean shutdown instead. #### Client list methods @@ -1100,12 +1626,23 @@ rewrite required unless noted. zero registrations. `new McpServer(info, { capabilities: { tools: {} } })` with no registered tools answers `tools/list` with `{ tools: [] }` instead of `-32601 Method not found`. Low-level `Server` users remain responsible for registering handlers for - declared capabilities. + declared capabilities — with one exception: declaring the `logging` capability (in + the constructor's capabilities or via pre-connect `registerCapabilities()`) installs + the `logging/setLevel` handler on the low-level `Server` too, so `logging/setLevel` + requests that answered `-32601` in v1 now resolve. Eager install also rewrites the **advertised** capability + objects: a declared `tools: {}` / `resources: {}` / `prompts: {}` is advertised with + `listChanged: true` at construction, so capability pins and initialize-result golden + tests need re-baselining. To advertise without the default, set + `listChanged: false` explicitly; capabilities declared on the low-level `Server` are + advertised verbatim. - **`WebStandardStreamableHTTPServerTransport` store-first `eventStore` semantics.** Request-related events emitted after `closeSSE()` — and the final response when no per-request stream is connected — are now persisted to the configured `eventStore` for replay (v1 dropped them / threw `"No connection established"`). Without an `eventStore`, the same condition surfaces via `onerror` and the request id is retired. + `NodeStreamableHTTPServerTransport` is a thin wrapper over + `WebStandardStreamableHTTPServerTransport`, so this — like every behavioral note on + the web-standard transport — applies to the Node transport too. - **`registerResource` reserves the `cacheHint` config key.** It is validated (`RangeError` on invalid values) and stripped from the resource's list metadata; v1 passed it through verbatim as ordinary metadata. Untyped callers that previously @@ -1142,6 +1679,13 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see - **Sampling `hasTools` discriminant** now keys on `tools || toolChoice` (previously `tools` only) when selecting the with-tools `CreateMessageResult` variant, on every era. +- **Inbound frames that fail message-shape validation are not answered.** v2 routes + every inbound frame through typed message guards; a frame that matches no JSON-RPC + shape (e.g. a hand-built ping with an explicitly-`undefined` `id`, or non-object + `params`) is dropped and surfaces only via `onerror` (`Unknown message type: …`) — no + response is sent. v1-era test fences that await a reply to a hand-written raw frame + hang instead of resolving; send through the typed surface (`client.ping()`, + `client.request()`) instead. #### Experimental tasks interception removed @@ -1206,22 +1750,56 @@ breaks. ## Unchanged APIs -The following are unchanged between v1 and v2 (only the import path changed): +The following are unchanged between v1 and v2 apart from the import path — except +where an entry notes its own signature change: - `Client` constructor and `connect`, `close`, and the typed verbs (`listTools`, `listPrompts`, `listResources`, `readResource`, …) — note `callTool()` and `request()` signatures changed (schema parameter dropped for spec methods). -- `McpServer` constructor, `server.connect(transport)`, `server.close()`. -- `StreamableHTTPClientTransport`, `SSEClientTransport` constructors and options. +- `McpServer` constructor, `server.connect(transport)`, `server.close()`, and the + `McpServer.server` accessor — still the supported way to call the low-level + `Server`'s push verbs (`createMessage` / `listRoots` / `sendLoggingMessage` — ⚠ + `@deprecated`, see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) outside a + handler context. +- The server Streamable HTTP transports' **constructor options** (`sessionIdGenerator`, + `onsessioninitialized`, `onsessionclosed`, `enableJsonResponse`, `eventStore`, + `retryInterval`) and the `handleRequest` surface — only the class name and import + moved: `StreamableHTTPServerTransport` is now `NodeStreamableHTTPServerTransport` + from `@modelcontextprotocol/node`, a thin wrapper over + `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/server`, + which exposes the same options ([decision rule](#imports--transports)). The + transport-level `closeSSEStream(requestId)` / `closeStandaloneSSEStream()` methods + keep their v1 names too — only the handler-context accessors moved to `ctx.http` + ([remap table](#low-level-protocol--handler-context-ctx)). +- `UriTemplate` (v1: `@modelcontextprotocol/sdk/shared/uriTemplate.js`) — `expand` / + `match` semantics carry over; import it from `@modelcontextprotocol/server` or + `@modelcontextprotocol/client` (top-level export; the codemod rewrites the path). +- `StreamableHTTPClientTransport`, `SSEClientTransport` constructors and options — + including resumability: the per-request `resumptionToken` / `onresumptiontoken` + request options carry over from v1 unchanged + ([Resumption tokens](../client.md#resumption-tokens) in the client guide). - `StdioClientTransport` and `StdioServerTransport` — **import path moved** to the `./stdio` subpath and gained an optional `maxBufferSize` ([Imports & transports](#imports--transports)). +- The **`Transport` interface contract** — `start` / `send` / `close`, `onmessage` / + `onclose` / `onerror`, optional `sessionId` and `setProtocolVersion`, + `TransportSendOptions`, `MessageExtraInfo`. Hand-rolled v1 transports (recording + wrappers, test doubles, decorators) compile and run against v2 with only the import + path updated. v2 adds **optional** members only — `hasPerRequestStream` and + `setSupportedProtocolVersions` on the interface, `requestSignal` / `headers` / + `onRequestStreamEnd` on `TransportSendOptions` — which matter only for 2026-era + per-request-stream cancellation and `Mcp-Param-*` header attachment + ([support-2026-07-28.md](./support-2026-07-28.md)). - All TypeScript **type** definitions from `types.ts` (except the aliases listed under - [Removed type aliases](#removed-type-aliases)). + [Removed type aliases](#removed-type-aliases) and the `experimental` capability + payload narrowing — see [Types & schemas](#types--schemas)). - Tool, prompt, and resource callback return types. > The `Server` (low-level) constructor and **most** of its methods are unchanged, but > `setRequestHandler` / `setNotificationHandler` and `request()` signatures changed -> ([Low-level protocol](#low-level-protocol--handler-context-ctx)). The Zod `*Schema` +> ([Low-level protocol](#low-level-protocol--handler-context-ctx)). In particular, +> `Server.createElicitationCompletionNotifier()` is unchanged — including its +> construction-time client-capability check — for 2025-era URL-mode elicitation +> ([support-2026-07-28.md](./support-2026-07-28.md)). The Zod `*Schema` > constants are **not** part of the unchanged surface — they moved to > `@modelcontextprotocol/core` ([Types & schemas](#types--schemas)).