From 976ffaf4d12e70ef9aa8603a217d45e31114e89f Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 19:02:55 +0000 Subject: [PATCH 1/5] docs(migration): close v1-to-v2 guide gaps surfaced by a fresh migration sweep Adds staged-migration ordering, monorepo workspace-member dependency rules, registry-availability notes for the alpha window, the zod compile-time vs runtime symptom split, the completable optional-nesting caveat, gateway inbound error reconstruction with its limits, repo-local tooling that encodes the v1 package name, HeadersInit send-side clarification, the published-alpha error-code qualifier, the InMemoryTransport linked-pair rule, malformed inbound frame behavior, spec-form notification handler examples, OAuth discovery-state and connect-time retry coverage, transport compatibility and unchanged-API bullets, stdio write-failure and session-header notes, third-party dependency guidance, timeout re-baselining relief with per-era cancel-signal qualifiers, and the manifest-handling section matching the codemod's nearest-manifest swap and workspace-member report. --- docs/migration/support-2026-07-28.md | 33 +- docs/migration/upgrade-to-v2.md | 660 ++++++++++++++++++++++++--- 2 files changed, 630 insertions(+), 63 deletions(-) diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index ce733f729e..f4b8a50dbf 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -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 @@ -520,7 +524,9 @@ phase state so they increase across re-entries — the token spans the whole flo 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. + 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..8305a25bc5 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 (add the union of all members' v2 packages at the root — the +codemod's hoisting note lists the contributing members) 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,138 @@ 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; both speak protocol 2025-06-18 to each other. + +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. + ### Imports & transports The codemod rewrites every `@modelcontextprotocol/sdk/...` import path via @@ -203,7 +385,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 +400,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 +424,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 +441,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 with an `eventStore`-configured transport) — 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 +485,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 +498,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 +525,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 +614,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 +652,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 +670,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 +697,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 +790,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 +801,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 +898,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 +927,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 +985,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 +1084,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 +1106,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 +1238,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 @@ -893,10 +1324,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 +1406,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 +1459,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 +1524,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 +1543,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 +1607,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 +1660,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 @@ -1211,17 +1736,50 @@ The following are unchanged between v1 and v2 (only the import path changed): - `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)). From 3e6f9fe6ab18543e96a20bf31fa37d1be553b644 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 30 Jun 2026 12:46:52 +0000 Subject: [PATCH 2/5] docs(migration): correct the staged-migration protocol note and the closeSSE gating note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections from review: v1-imported and v2-imported processes negotiate through the ordinary 2025-era initialize handshake and settle on the newest revision both packages support (the previous text hardcoded 2025-06-18, which neither side selects today), and ctx.http?.closeSSE is populated only when the transport has an eventStore AND the client's negotiated protocol version supports resumable close — an eventStore transport serving an older client still leaves it undefined. --- docs/migration/upgrade-to-v2.md | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 8305a25bc5..75fdf277bf 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -324,7 +324,10 @@ with `--dry-run`). Second, v1 and v2 modules each have their own classes and typ 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; both speak protocol 2025-06-18 to each other. +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 @@ -441,19 +444,19 @@ 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; the member itself is also optional (defined only with an `eventStore`-configured transport) — 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) | +| 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. From f64928b46b190c41a7f727530c327bc8e197250b Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 30 Jun 2026 14:27:38 +0000 Subject: [PATCH 3/5] docs(migration): navigation and structure pass on the v1-to-v2 guide Adds the navigation layer the guide was missing: a symptom index mapping literal compiler/runtime diagnostics to their sections, a by-situation router, a full-depth table of contents, and a closing verification checklist assembling the done-criteria greps. Splits the largest sections by symptom (the zod floor gets its own findable heading), collapses the duplicated codemod-coverage top matter into one routing layer, moves content documented only in routing bullets into the sections they point at, and gates trial-specific edge cases behind one-glance applicability conditions. Factual corrections: the two client-conformance checklists are unified into one canonical superset; the unchanged-APIs lead no longer claims import-path-only changes; published-alpha caveats are deduplicated and stamped with a greppable marker; the hoisted-monorepo paragraph now matches the codemod's actual behavior; and a section for library authors that peer-depend on the SDK fills the one gap a cold-reader probe could not answer. No behavioral guidance was removed; relocated text moves verbatim. --- docs/migration/support-2026-07-28.md | 342 +++++------ docs/migration/upgrade-to-v2.md | 831 ++++++++++++++++++--------- 2 files changed, 720 insertions(+), 453 deletions(-) diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index f4b8a50dbf..3ab7072407 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -6,7 +6,13 @@ title: Supporting protocol revision 2026-07-28 This guide is for code **already on the v2 packages** that wants to speak the 2026-07-28 protocol revision — and for code written against an earlier **v2 alpha** that read -wire-only members directly. If you are on `@modelcontextprotocol/sdk` (v1.x), start with +wire-only members directly; those changes are flagged inline as +**If you were on a v2 alpha** under +[`createMcpHandler`](#server-over-http-createmcphandler), +[Per-era wire codecs](#per-era-wire-codecs) (the wire-schema table and the error-code +renumbering), and +[Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types). +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 @@ -22,12 +28,12 @@ below. ## Contents - [Serving the 2026-07-28 revision](#serving-the-2026-07-28-revision) +- [Multi-round-trip requests](#multi-round-trip-requests) - [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) +- [Legacy shim for `input_required`](#legacy-shim-for-input_required) - [Auth on 2026-07-28](#auth-on-2026-07-28) - [Per-era wire codecs](#per-era-wire-codecs) - [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types) -- [Multi-round-trip requests](#multi-round-trip-requests) -- [Legacy shim for `input_required`](#legacy-shim-for-input_required) - [`subscriptions/listen`](#subscriptionslisten) - [`Mcp-Param-*` and standard headers (SEP-2243)](#mcp-param--and-standard-headers-sep-2243) - [Cache fields and cache hints](#cache-fields-and-cache-hints) @@ -38,8 +44,8 @@ below. ## Serving the 2026-07-28 revision -These entry points are documented in full in the server and client guides; this section -contextualizes them as the migration path. +These entry points are documented in full in [server.md](../server.md) and +[client.md](../client.md). ### Client side: `versionNegotiation` @@ -69,8 +75,7 @@ revision but is not checked against the list. #### Probe policy -Failure semantics under `'auto'` are deliberately conservative but never silent about -infrastructure problems. Anything the probe does not positively recognize as modern +Anything the probe does not positively recognize as modern 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 @@ -87,7 +92,7 @@ versionNegotiation: { mode: 'auto', probe: { timeoutMs: 10_000, // default: the standard request timeout - maxRetries: 0 // default: no retries — governs timeout re-sends only + maxRetries: 0 // default: no retries } } ``` @@ -109,14 +114,17 @@ The probe request itself already carries the per-request `_meta` envelope the envelope to every outgoing request and notification. Tooling that classifies traffic must not treat "saw an envelope" as "modern era negotiated": the legacy-fallback path also begins with one enveloped probe. A gateway/worker fleet can skip the -probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })`. +probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })` — +a zero-round-trip connect: probe once, persist `client.getDiscoverResult()` +(`JSON.stringify`), and feed it to every worker as +`client.connect(transport, { prior })`. The new exported type `ConnectOptions` +extends `RequestOptions` with `prior?: DiscoverResult`. ### Server over HTTP: `createMcpHandler` `createMcpHandler(factory)` from `@modelcontextprotocol/server` is the v2 HTTP entry that serves 2026-07-28 per request — and, by default (`legacy: 'stateless'`), also -serves 2025-era traffic per request through the established stateless idiom. One -factory, one endpoint, both eras. +serves 2025-era traffic per request through the established stateless idiom. ```typescript import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; @@ -217,6 +225,65 @@ are silently suppressed until the client opts in. --- +## Multi-round-trip requests + +The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers +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 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 +`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs +(`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. 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) +for the sealing helper and verification hook. + +**Client side — auto-fulfilment by default.** When a 2026-07-28 call answers +`input_required`, the client fulfils the embedded requests through the same handlers +registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | +'roots/list', …)` and retries (fresh request id, `inputResponses`, byte-exact +`requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). Configure or +opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manually per +call with `allowInputRequired: true` plus `withInputRequired()`. Expect +`SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted. + +**Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a +structural read with an unvalidated cast), two typed readers ship from +`@modelcontextprotocol/server`: + +- `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous + Standard Schema, e.g. a zod object): validates the untrusted accepted content and + returns it typed, or `undefined` on mismatch/decline/missing. +- `inputResponse(responses, key)` — discriminated view + (`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`) + for decline/cancel detection and the non-elicitation kinds. + +Content conveniences stay in your code — e.g. the text of a sampling response is a +one-liner over the discriminated view: + +```typescript +const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); +const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined; +const text = block?.type === 'text' ? block.text : undefined; +``` + +--- + ## Replacing per-session state: `requestState` The 2026-07-28 revision is **per request** — `createMcpHandler` builds a fresh server per @@ -237,8 +304,8 @@ The `createRequestStateCodec({ key, ttlSeconds?, bind? })` helper returns `{ mint, verify }` — `mint` HMAC-SHA256-seals a JSON-serializable payload and `verify` is exactly the function you assign to the hook. The codec is **signed, not encrypted** (the client can base64url-decode the payload). `mint` and -`ctx.mcpReq.requestState()` are the typed encode/read pair: the seam captures what -`verify` returns and the accessor hands it to the handler already decoded — no second +`ctx.mcpReq.requestState()` are the typed encode/read pair — the seam captures what +`verify` returns; no second `verify` call. See `examples/mrtr/server.ts` and [Multi-round-trip requests](#multi-round-trip-requests) for the full handler shape. @@ -279,9 +346,77 @@ async (args, ctx) => { }; ``` -Each `case` knows exactly which answer to read and which data is in scope — the state -machine is explicit, and the same handler runs unchanged on 2025-era connections -through the legacy shim. +--- + +## Legacy shim for `input_required` + +An `input_required` return on a **2025-era** connection is served by the SDK's legacy +shim, on by default: each embedded request is sent as a real server→client request +(`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — +stamped with the originating request's id, so on sessionful Streamable HTTP the +requests ride the originating POST's stream — and the handler is re-entered with the +collected `inputResponses` until it returns a final result. Handlers are **written +once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs +remain available for code that still calls them directly. + +The handler cannot tell which era fulfilled it — the shim mirrors the modern client +driver's semantics exactly: + +- `inputResponses` are **per round** (replaced on every re-entry, never accumulated); + multi-step flows thread earlier answers through `requestState`. +- `requestState` is echoed byte-exact, and the configured + `ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would + on a modern wire retry (so TTL expiry behaves identically; a rejection answers the + frozen `-32602`). +- Responses arrive as the bare result objects, era-wire-shape-validated only: + elicitation accepted content is NOT re-checked against `requestedSchema` — + exactly as on the modern era — so the handler validates with the + schema-aware `acceptedContent(responses, key, schema)` overload and can + re-issue the request instead of the call dying on a mistyped form field. +- Rounds with no embedded requests (requestState-only) are paced at 250ms. +- URL-mode elicitation legs are sent with a synthesized `elicitationId` (the + 2025-11-25 wire requires one; the 2026 in-band shape has none). + +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 | + +Failures surface **per family**: `tools/call` failures (capability refusal, a failed +leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts +already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC +errors. Server bugs (malformed input-required results) fail loudly on both eras. + +The shim emits no progress of its own — the originating request's `progressToken` is +a single must-increase stream that belongs to the handler (one stream, one author) — +so a 2025 client watching a multi-round flow sees exactly what a hand-written 2025 +push-style handler would have produced. A handler that reports progress across rounds +should derive its values from its phase state so they increase across re-entries — +the token spans the whole flow. + +**Inherited limits** (the same ones hand-written push-style handlers have today): + +- The shim pre-checks each embedded request kind against the client capabilities + declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration + counts as form support — the pre-mode meaning, same as the modern `-32021` gate). + Capability-less clients get a clean refusal, never a hang. +- **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a + fresh instance per request: no initialize handshake, no return path for + server→client requests. The shim degrades to the clean capability refusal there — + full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring. +- JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client + requests mid-call: the transport drops them, so a shim leg waits out + `roundTimeoutMs` before failing per family — the same undeliverable class as + 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: URL-mode legs complete like any other elicitation + response. The sender API for that channel, + `Server.createElicitationCompletionNotifier()`, still works for + 2025-era URL-mode elicitation — only the shim does not bridge it. --- @@ -291,12 +426,10 @@ The 2026-07-28 specification's authorization requirements (RFC 9207 `iss` valida SEP-2352 credential isolation, SEP-2350 scope step-up, SEP-837/SEP-2207 DCR + TLS) are implemented in v2 as **SDK-level opt-ins, not protocol-era gates** — they apply on every era once enabled. The migration steps live in -[upgrade-to-v2.md › Auth](./upgrade-to-v2.md#auth). To be **2026-07-28-conformant**, -enable the spec-2026 opt-ins listed there: pass `iss` (or the callback `URLSearchParams`) -to `finishAuth`; round-trip the `issuer` stamp on stored credentials; implement -`discoveryState()`; and either keep `onInsufficientScope: 'reauthorize'` or handle -`InsufficientScopeError` yourself. Nothing in this section is era-switched at the wire -layer. +[upgrade-to-v2.md › Auth](./upgrade-to-v2.md#auth). The full conformance checklist — +every obligation that lives in your code rather than the SDK's — is +[upgrade-to-v2.md › Conformance obligations for `OAuthClientProvider` implementers](./upgrade-to-v2.md#conformance-obligations-for-oauthclientprovider-implementers). +Nothing in this section is era-switched at the wire layer. --- @@ -306,8 +439,10 @@ The wire layer is split into per-revision codecs inside the (private, bundled) c codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The codec is selected by the negotiated protocol version, which is **connection state** on the `Client`/`Server` instance (instances with no negotiated -version default to the 2025 era). An edge classification (`MessageExtraInfo.classification`) -no longer switches the era per message — it is validated against the instance era, and a +version default to the 2025 era). A transport that classifies inbound messages at the +edge may attach an optional `MessageExtraInfo.classification` carrier +(`{ era, revision?, envelope? }`); it no longer switches the era per message — dispatch +validates it against the instance era, and a mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol version` for requests; drop + `onerror` for notifications). @@ -317,7 +452,8 @@ handler is registered, and sending an era-mismatched spec method (e.g. `server/d toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws `SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. -If you were on a v2 alpha and consumed wire schemas directly: +**If you were on a v2 alpha** and validated raw wire traffic with the exported +schemas, guards, or aggregate types: | v2-alpha pattern | Mechanical fix | | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | @@ -348,8 +484,11 @@ application code: the `resultType` discrimination field, the reserved per-reques and the multi-round-trip retry fields (`inputResponses`, `requestState`). - **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, - `GetPromptResult`, …). The wire schemas keep parsing it, and the protocol layer - consumes it before results reach your code. + `GetPromptResult`, …). The SDK's internal per-era wire codecs still parse it off the + wire and the protocol layer consumes it before results reach your code — but the + exported `*Schema` constants validate the neutral model and reject it as an unknown + key (the `EmptyResultSchema` row in the [Per-era wire codecs](#per-era-wire-codecs) + table). - **`DiscoverResult` hides its cache fields at the type level only.** `ttlMs` / `cacheScope` on `server/discover` are read by the client's response-cache layer and are absent from the public `DiscoverResult` type returned by `getDiscoverResult()` — @@ -360,7 +499,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: @@ -384,7 +523,8 @@ The protocol layer enforces the same boundary at runtime: before validation. On a 2026-era exchange `resultType` is REQUIRED; an absent value is a spec violation surfaced as a typed error. -**If you were on a v2 alpha** and read the wire shape directly: +**If you were on a v2 alpha** and read `resultType` off delivered results or off the +public types: | Pattern | Mechanical fix | | -------------------------------------- | --------------------------------------------------------------------------------- | @@ -392,142 +532,6 @@ The protocol layer enforces the same boundary at runtime: | `Result['resultType']` type reference | remove; the member is no longer declared | | return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | -`MessageExtraInfo.classification` is an optional carrier (`{ era, revision?, envelope? }`) -for transports that classify inbound messages at the edge; dispatch validates it against -the instance's negotiated era. - ---- - -## Multi-round-trip requests - -The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers -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 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 -`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs -(`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. 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) -for the sealing helper and verification hook. - -**Client side — auto-fulfilment by default.** When a 2026-07-28 call answers -`input_required`, the client fulfils the embedded requests through the same handlers -registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | -'roots/list', …)` and retries (fresh request id, `inputResponses`, byte-exact -`requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). Configure or -opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manually per -call with `allowInputRequired: true` plus `withInputRequired()`. Expect -`SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted. - -**Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a -structural read with an unvalidated cast), two typed readers ship from -`@modelcontextprotocol/server`: - -- `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous - Standard Schema, e.g. a zod object): validates the untrusted accepted content and - returns it typed, or `undefined` on mismatch/decline/missing. -- `inputResponse(responses, key)` — discriminated view - (`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`) - for decline/cancel detection and the non-elicitation kinds. - -Content conveniences stay in your code — e.g. the text of a sampling response is a -one-liner over the discriminated view: - -```typescript -const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); -const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined; -const text = block?.type === 'text' ? block.text : undefined; -``` - ---- - -## Legacy shim for `input_required` - -An `input_required` return on a **2025-era** connection is served by the SDK's legacy -shim, on by default: each embedded request is sent as a real server→client request -(`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — -stamped with the originating request's id, so on sessionful Streamable HTTP the -requests ride the originating POST's stream — and the handler is re-entered with the -collected `inputResponses` until it returns a final result. Handlers are **written -once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs -remain available for code that still calls them directly. - -The handler cannot tell which era fulfilled it — the shim mirrors the modern client -driver's semantics exactly: - -- `inputResponses` are **per round** (replaced on every re-entry, never accumulated); - multi-step flows thread earlier answers through `requestState`. -- `requestState` is echoed byte-exact, and the configured - `ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would - on a modern wire retry (so TTL expiry behaves identically; a rejection answers the - frozen `-32602`). -- Responses arrive as the bare result objects, era-wire-shape-validated only: - elicitation accepted content is NOT re-checked against `requestedSchema` — - exactly as on the modern era — so the handler validates with the - schema-aware `acceptedContent(responses, key, schema)` overload and can - re-issue the request instead of the call dying on a mistyped form field. -- Rounds with no embedded requests (requestState-only) are paced at 250ms. -- URL-mode elicitation legs are sent with a synthesized `elicitationId` (the - 2025-11-25 wire requires one; the 2026 in-band shape has none). - -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 | - -Failures surface **per family**: `tools/call` failures (capability refusal, a failed -leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts -already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC -errors. Server bugs (malformed input-required results) fail loudly on both eras. - -The shim emits no progress of its own. The originating request's `progressToken` -identifies a single must-increase stream that belongs to the handler — injecting -synthetic ticks into it cannot compose with handler-emitted progress (one stream, -one author), so the shim never writes to it: a 2025 client watching a multi-round -flow sees exactly what a hand-written 2025 push-style handler would have produced. -A handler that reports progress across rounds should derive its values from its -phase state so they increase across re-entries — the token spans the whole flow. - -**Inherited limits** (the same ones hand-written push-style handlers have today): - -- The shim pre-checks each embedded request kind against the client capabilities - declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration - counts as form support — the pre-mode meaning, same as the modern `-32021` gate). - Capability-less clients get a clean refusal, never a hang. -- **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a - fresh instance per request: no initialize handshake, no return path for - server→client requests. The shim degrades to the clean capability refusal there — - full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring. -- JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client - requests mid-call: the transport drops them, so a shim leg waits out - `roundTimeoutMs` before failing per family — the same undeliverable class as - 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. 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. - --- ## `subscriptions/listen` @@ -614,12 +618,12 @@ Task methods are excluded from the typed method maps: `RequestMethod` / `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`. -(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 +(On the published `2.0.0-alpha.3` typings this exclusion is not yet in effect — see +[upgrade-to-v2.md › Zod `*Schema` constants](./upgrade-to-v2.md#zod-schema-constants-moved-to-modelcontextprotocolcore).) +Where task interop is genuinely required, use the explicit-schema custom-method form (`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*` -requests → `-32601`. +requests on a 2026-era connection → `-32601` even if a handler is registered. The experimental tasks **interception** layer is removed entirely — see [upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed). diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 75fdf277bf..548a101539 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -13,49 +13,167 @@ work through the manual sections for what the codemod can't rewrite. If you are already on v2 and want to adopt the **2026-07-28 protocol revision**, see [support-2026-07-28.md](./support-2026-07-28.md) instead. +The codemod handles the v1→v2 SDK surface upgrade only — adopting the 2026-07-28 +protocol revision (`createMcpHandler`, multi-round-trip requests, `versionNegotiation`) +is architectural and not codemod-automatable. + ## TL;DR — quick path 1. **Prerequisites.** Node.js 20+ and ESM (`"type": "module"` or `.mts`). v2 ships ESM - only; CommonJS callers must use dynamic `import()`. + only; CommonJS callers must use dynamic `import()`. If you pass **zod** schemas to + the SDK, declare **`zod ^4.2.0`** — a zod-3 range that satisfied the v1 peer still + installs and typechecks cleanly under v2, and only fails (quietly) at the first + `tools/list`; see [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist). + The codemod adds `zod ^4.2.0` only to a manifest with **no** `zod` entry; it warns + about — but never rewrites — an existing zod-3 / 4.0–4.1 range. 2. **Run the codemod.** ```bash npx @modelcontextprotocol/codemod@alpha 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. + `--dry-run` previews every rewrite including the manifest summary; + `--ignore ` excludes files the codemod must not touch. 3. **Grep for markers.** Anything the codemod recognized but could not safely rewrite is marked in place: ```bash grep -rn '@mcp-codemod-error' . ``` -4. **Type-check.** `tsc --noEmit` (or your build). Remaining errors map to the - [manual sections](#manual-changes-what-the-codemod-does-not-handle) below. +4. **Type-check.** `tsc --noEmit` (or your build). Look up each remaining error in the + [symptom index](#symptom-index) below; anything not listed maps to the + [manual sections](#manual-changes). 5. **Format.** The codemod rewrites the AST without reformatting — run your formatter on the changed files (`prettier --write` / `eslint --fix` / `biome format --write`); the codemod prints the exact command after it runs. -6. **Run your tests.** +6. **Run your tests.** Jest under its default CommonJS resolution fails with + `Cannot find module '@modelcontextprotocol/client'` — see + [CommonJS test runners (Jest)](#commonjs-test-runners-jest-cannot-resolve-the-v2-packages); + Vitest and native Node ESM are unaffected. Failures from runtime-behavior changes + (not compile errors) map to [Behavioral changes](#behavioral-changes). Then + [verify you're done](#verifying-youre-done). Migrating a large codebase gradually instead of in one pass? See [Migrating in stages (large codebases)](#migrating-in-stages-large-codebases). +## Symptom index + +Literal diagnostics → the section that fixes them. The strings are verbatim — search +for yours. + +| Symptom / diagnostic | Section | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `TS2307: Cannot find module '@modelcontextprotocol/sdk/…'` — staged-migration order inverted (manifest swapped before sources are rewritten) | [Migrating in stages](#migrating-in-stages-large-codebases) | +| `TS2307` on `server/zod-json-schema-compat.js` (`toJsonSchemaCompat`) — the codemod does not rewrite this import | [Removed Zod helpers and compat modules](#removed-zod-helpers-and-compat-modules) | +| `TS2307` in a file the codemod never rewrote — outside the run target (run at the package root), or `--ignore`'d while the manifest swap also dropped the v1 dependency | [TL;DR step 2](#tldr--quick-path); [Migrating in stages](#migrating-in-stages-large-codebases) | +| `TS2769: No overload matches this call` on `registerTool` / `registerPrompt` — elaborated as `Property 'jsonSchema' is missing in type …` | [TS2769](#ts2769-no-overload-matches-this-call-registertool--registerprompt) | +| Server starts and connects normally, but the first `tools/list` answers an error pointing at `fromJsonSchema()` | [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist) | +| `TS2367` on `=== -32000` / `=== -32001` | [Errors](#errors) | +| `TS2339` on a property read after an `in` narrow | [Discriminating result shapes](#discriminating-result-shapes-use-guards-not-the-in-operator) | +| `TS2322` on a capability `experimental` payload typed `Record` | [Removed type aliases](#removed-type-aliases) | +| Jest: `Cannot find module '@modelcontextprotocol/client'` | [CommonJS test runners (Jest)](#commonjs-test-runners-jest-cannot-resolve-the-v2-packages) | +| `TypeError: '…' is not a spec method; pass a result schema` | [`request()` / `callTool()` schema drop](#request-ctxmcpreqsend-and-calltool-no-longer-require-a-schema-for-spec-methods) | +| `TypeError` thrown at `setRequestHandler(Schema, …)` registration in a file with no SDK import | [Files the codemod never sees](#files-the-codemod-never-sees-injected-sdk-surfaces) | +| Repeating `[mcp-sdk]` warning on every credential read after upgrading | [Credentials bound to the issuing AS (SEP-2352)](#credentials-bound-to-the-issuing-authorization-server-sep-2352) | +| npm/pnpm "not found" for an `@modelcontextprotocol` package that exists on npmjs.org | [Registry availability during the alpha](#registry-availability-during-the-alpha) | +| `Error("…unsupported dialect…")` | [JSON Schema 2020-12 posture](#json-schema-2020-12-posture-sep-1613-sep-2106) | +| `MissingRefError` surfaced per-tool on `callTool` | [JSON Schema 2020-12 posture](#json-schema-2020-12-posture-sep-1613-sep-2106) | +| `onerror`: `Unknown message type: …` / a raw-frame test fence hangs awaiting a reply | [Wire tightening](#wire-tightening-every-era) | +| Invalid tokens answered HTTP `500` after re-pointing `requireBearerAuth` | [Token verifiers must throw the v2 `OAuthError`](#token-verifiers-must-throw-the-v2-oautherror) | + ## Contents +- [Symptom index](#symptom-index) +- [By situation](#by-situation) - [What the codemod handles](#what-the-codemod-handles) -- [What the codemod does NOT handle](#what-the-codemod-does-not-handle) -- [Manual changes](#manual-changes-what-the-codemod-does-not-handle) +- [Manual changes](#manual-changes) - [Packaging & runtime](#packaging--runtime) + - [Monorepo workspace members](#monorepo-workspace-members) + - [Repo tooling pinned to the v1 package](#repo-tooling-pinned-to-the-v1-package) + - [Registry availability during the alpha](#registry-availability-during-the-alpha) + - [CommonJS test runners (Jest) cannot resolve the v2 packages](#commonjs-test-runners-jest-cannot-resolve-the-v2-packages) + - [Bundlers: nested `zod` copies in zod@3-pinned monorepos](#bundlers-nested-zod-copies-in-zod3-pinned-monorepos) + - [Migrating in stages (large codebases)](#migrating-in-stages-large-codebases) + - [Library authors: peer-depending on the SDK](#library-authors-peer-depending-on-the-sdk) - [Imports & transports](#imports--transports) - [Low-level protocol & handler context (`ctx`)](#low-level-protocol--handler-context-ctx) + - [`setRequestHandler` / `setNotificationHandler` use method strings](#setrequesthandler--setnotificationhandler-use-method-strings) + - [Files the codemod never sees: injected SDK surfaces](#files-the-codemod-never-sees-injected-sdk-surfaces) + - [`request()`, `ctx.mcpReq.send()`, and `callTool()` no longer require a schema for spec methods](#request-ctxmcpreqsend-and-calltool-no-longer-require-a-schema-for-spec-methods) + - [Deprecated in v2 (SEP-2577)](#deprecated-in-v2-sep-2577) - [Server registration API](#server-registration-api) + - [Standard Schema objects (raw shapes deprecated)](#standard-schema-objects-raw-shapes-deprecated) + - [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist) + - [TS2769: No overload matches this call](#ts2769-no-overload-matches-this-call-registertool--registerprompt) + - [zod@3-pinned monorepos: the `npm:zod@^4.2.0` alias](#zod3-pinned-monorepos-the-npmzod420-alias) + - [Hosts that forward consumer-authored schemas](#hosts-that-forward-consumer-authored-schemas) + - [Zod 4's own type-level API changes](#zod-4s-own-type-level-api-changes-zzodtypedef-zzodtype-generics) + - [Removed Zod helpers and compat modules](#removed-zod-helpers-and-compat-modules) - [HTTP & headers](#http--headers) - [Errors](#errors) + - [`SdkErrorCode` enum (complete)](#sdkerrorcode-enum-complete) + - [Typed `ProtocolError` subclasses](#typed-protocolerror-subclasses) - [Auth](#auth) + - [OAuth error consolidation](#oauth-error-consolidation) + - [Token verifiers must throw the v2 `OAuthError`](#token-verifiers-must-throw-the-v2-oautherror) + - [`AuthProvider` — non-OAuth bearer auth and the widened `authProvider` option](#authprovider--non-oauth-bearer-auth-and-the-widened-authprovider-option) + - [OAuth client flow — behavioral changes](#oauth-client-flow--behavioral-changes) + - [OAuth client flow errors (new)](#oauth-client-flow-errors-new) + - [Connect-time OAuth retry (`UnauthorizedError`)](#connect-time-oauth-retry-unauthorizederror) + - [`auth()` options are now `AuthOptions`](#auth-options-are-now-authoptions) + - [Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3)](#authorization-server-mix-up-defense-rfc-9207--rfc-8414-33--action-required) + - [Dynamic Client Registration defaults (SEP-837, SEP-2207)](#dynamic-client-registration-defaults-sep-837-sep-2207) + - [Token endpoint must use TLS (SEP-2207)](#token-endpoint-must-use-tls-sep-2207) + - [Scope step-up on `403 insufficient_scope` (SEP-2350)](#scope-step-up-on-403-insufficient_scope-sep-2350) + - [Credentials bound to the issuing authorization server (SEP-2352)](#credentials-bound-to-the-issuing-authorization-server-sep-2352) + - [Conformance obligations for `OAuthClientProvider` implementers](#conformance-obligations-for-oauthclientprovider-implementers) - [Types & schemas](#types--schemas) + - [Zod `*Schema` constants moved to `@modelcontextprotocol/core`](#zod-schema-constants-moved-to-modelcontextprotocolcore) + - [Removed type aliases](#removed-type-aliases) + - [JSON Schema 2020-12 posture (SEP-1613, SEP-2106)](#json-schema-2020-12-posture-sep-1613-sep-2106) - [Behavioral changes](#behavioral-changes) + - [Error-shape changes (every era)](#error-shape-changes-every-era) + - [Client connection & dispatch](#client-connection--dispatch) + - [stdio transport](#stdio-transport) + - [Client list methods](#client-list-methods) + - [Streamable HTTP: resumability requires protocol version `>= 2025-11-25`](#streamable-http-resumability-requires-protocol-version--2025-11-25) + - [SDK-convention JSON-RPC codes in HTTP error bodies](#sdk-convention-json-rpc-codes-in-http-error-bodies) + - [`getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` are `@deprecated`](#getclientcapabilities--getclientversion--getnegotiatedprotocolversion-are-deprecated) + - [`createMcp*App()` validates the `Origin` header by default](#createmcpapp-validates-the-origin-header-by-default) + - [McpServer installs capability handlers eagerly](#mcpserver-installs-capability-handlers-eagerly-toolslist-answers-tools-not--32601-listchanged-true-advertised-by-default) + - [`eventStore` store-first semantics (Streamable HTTP)](#eventstore-store-first-semantics-streamable-http) + - [`registerResource` reserves the `cacheHint` key](#registerresource-reserves-the-cachehint-key) + - [`ctx.mcpReq.log()` is request-related on every era](#ctxmcpreqlog-is-request-related-on-every-era) + - [Wire tightening (every era)](#wire-tightening-every-era) + - [Experimental tasks interception removed](#experimental-tasks-interception-removed) - [Enhancements](#enhancements) + - [Automatic JSON Schema validator selection by runtime](#automatic-json-schema-validator-selection-by-runtime) + - [Serving the 2026-07-28 revision](#serving-the-2026-07-28-revision) - [Unchanged APIs](#unchanged-apis) + - [Specification clarifications adopted (no SDK behavior change)](#specification-clarifications-adopted-no-sdk-behavior-change) +- [Verifying you're done](#verifying-youre-done) - [Need help?](#need-help) +## By situation + +Situations whose entry point is not a heading name — the full map is the Contents +above: + +- A gateway or proxy forwarding arbitrary methods, or relaying upstream errors → + [`request()` / `callTool()` schema drop](#request-ctxmcpreqsend-and-calltool-no-longer-require-a-schema-for-spec-methods); + [Errors](#errors) +- A plugin host or agent framework registering schemas its own consumers author → + [Hosts that forward consumer-authored schemas](#hosts-that-forward-consumer-authored-schemas) +- A process that is both MCP client and server, or objects crossing the two packages → + [Errors](#errors) (dual-role `instanceof` note) +- A workspace or vendored dependency that compiles against the host's v1 SDK → + [Migrating in stages](#migrating-in-stages-large-codebases) +- An SDK surface received by injection (dependency injection / factory seams), with no + import in the file → + [Files the codemod never sees](#files-the-codemod-never-sees-injected-sdk-surfaces) +- A workspace whose declared zod range is still `^3` → + [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist); + [the `npm:zod@^4.2.0` alias](#zod3-pinned-monorepos-the-npmzod420-alias) + --- ## What the codemod handles @@ -71,97 +189,88 @@ mechanically applies every rename whose mapping is fixed. The mappings are the | `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) | | `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` property remap | [`mappings/contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) | -In addition the codemod: +In addition the codemod applies the structural transforms below. A transform may still +leave `@mcp-codemod-error` markers at sites it recognized but could not rewrite safely +(noted per bullet); each marker's comment text names the problem and the replacement — +find them all with `grep -rn '@mcp-codemod-error' .` +([step 3](#tldr--quick-path), [marker format](../../packages/codemod/README.md#mcp-codemod-error-markers)). +Idioms the codemod cannot see at all are listed at the top of +[Manual changes](#manual-changes). - Updates `package.json` dependencies (`@modelcontextprotocol/sdk` → the v2 packages - your imports actually use). + your imports actually use) — [Packaging & runtime](#packaging--runtime). - Rewrites `.tool()` / `.prompt()` / `.resource()` to `registerTool` / `registerPrompt` - / `registerResource` and wraps `inputSchema` / `outputSchema` / `argsSchema` / - `uriSchema` raw Zod shapes with `z.object()`, adding `import { z } from 'zod'` - when the file has no `z` binding. + / `registerResource`, wrapping raw Zod shapes with `z.object()` and adding the `z` + import when the file has no `z` binding — + [Server registration API](#server-registration-api). - Drops the result-schema argument from `client.request()` / `client.callTool()` for - spec methods. + spec methods — + [details](#request-ctxmcpreqsend-and-calltool-no-longer-require-a-schema-for-spec-methods). - Routes the spec Zod `*Schema` constants imported from `sdk/types.js` to - `@modelcontextprotocol/core` (mixed imports are split; `.parse()` / `.safeParse()` - calls are left untouched). Task-handler schema constants - (`GetTaskRequestSchema` etc.) used as `setRequestHandler` args are **not** rewritten - — the experimental tasks feature was removed (SEP-2663), so each such registration - 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` — rewriting an all-SDK - condition's `instanceof ProtocolError` guard to `SdkError`, and marking guards - that mix the two enums. + `@modelcontextprotocol/core` (mixed imports are split); task-handler schema + registrations (`GetTaskRequestSchema` etc.) are marked, not rewritten — the + experimental tasks feature was removed (SEP-2663) + ([Experimental tasks interception removed](#experimental-tasks-interception-removed); + [Types & schemas](#types--schemas)). +- Renames `ErrorCode` → `ProtocolErrorCode`, routing the local-only members + (`RequestTimeout`, `ConnectionClosed`) to `SdkErrorCode` (guards mixing the two + enums are marked) — [Errors](#errors). - Renames every `StreamableHTTPError` reference to `SdkHttpError` and adds the import - (constructor calls are marked for review — argument shape changed). + (constructor calls are marked for review — argument shape changed) — + [Errors](#errors). - Replaces `IsomorphicHeaders` with the Web Standard `Headers` type and drops the - import (a warning notes `Headers` uses `.get()`/`.set()`, not bracket access). + import — [HTTP & headers](#http--headers). - Rewrites `SchemaInput` → `StandardSchemaWithJSON.InferInput`. - Renames `RequestHandlerExtra` → `ServerContext` / `ClientContext` and the `extra` - parameter to `ctx`. + parameter to `ctx` — + [Low-level protocol & handler context](#low-level-protocol--handler-context-ctx). - Rewrites `vi.mock` / `jest.mock` and dynamic `import()` paths. -- Renames the `ResourceTemplate` **type** imported from `@modelcontextprotocol/sdk/types.js` - to `ResourceTemplateType` (the spec wire type). The `ResourceTemplate` URI-template - helper **class** from `server/mcp.js` keeps its name and is not renamed. +- Renames the `ResourceTemplate` **type** imported from `sdk/types.js` to + `ResourceTemplateType`, scoped so the URI-template helper **class** keeps its name — + [footnote ³](#removed-type-aliases). - 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. + `completable(schema, cb).optional()` (uninvertible shapes are marked) — + [Standard Schema objects](#standard-schema-objects-raw-shapes-deprecated). - 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 - -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) -- **Header-read `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` - and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but - 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) -- **OAuth error-class consolidation** — `instanceof InvalidGrantError` → `OAuthError` + - `OAuthErrorCode` is a judgment rewrite. → [Auth](#auth) -- **`SdkErrorCode` branch selection** — the codemod renames `StreamableHTTPError` → - `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is - judgment. → [Errors](#errors) -- **Namespace schema access** — `import * as t from '…/types.js'` + - `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) + replacement at each site ([details](#removed-type-aliases)). --- -## Manual changes (what the codemod does not handle) +## Manual changes + +Each gap below maps to a manual section in this part of the guide. The codemod marks +every site it recognized but could not safely rewrite with an `@mcp-codemod-error` +comment. + +| Gap | Why it's manual | Section | +| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| **Node 20 / ESM** | pre-flight, not a code rewrite | [Packaging & runtime](#packaging--runtime) | +| **`zod ^4.2.0` bump on an existing `zod` declaration** | the codemod adds `zod ^4.2.0` only to a manifest that declares no `zod`; it warns about — but never rewrites — a declared zod-3 / 4.0–4.1 range | [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist) | +| **Header-read `.get()` rewrite** | `IsomorphicHeaders` is renamed to `Headers` and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but converting that bracket access to `.get()` is manual | [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) | +| **OAuth error-class consolidation** | `instanceof InvalidGrantError` → `OAuthError` + `OAuthErrorCode` is a judgment rewrite | [Auth](#auth) | +| **`SdkErrorCode` branch selection** | the codemod renames `StreamableHTTPError` → `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is judgment | [Errors](#errors) | +| **Namespace schema access** | `import * as t from '…/types.js'` can't be split per-symbol; the codemod marks it action-required | [Zod `*Schema` constants moved](#zod-schema-constants-moved-to-modelcontextprotocolcore) | +| **Import-less (injected) SDK surfaces** | the codemod is import-driven: a file with no SDK import is never rewritten, and the v1 idioms there fail at **runtime**, not compile time | [Files the codemod never sees](#files-the-codemod-never-sees-injected-sdk-surfaces) | +| **Behavioral adaptation** | list auto-aggregation, capability empties, lazy validator compilation, output-schema validation rules | [Behavioral changes](#behavioral-changes) | ### Packaging & runtime +v2 requires **Node.js 20+** and ships **ESM only**. If your project uses CommonJS +(`require()`), either migrate to ESM or use dynamic `import()`. + The single `@modelcontextprotocol/sdk` package is split: -| v1 | v2 | -| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/client` (client implementation) | -| | `@modelcontextprotocol/server` (server implementation) | -| | `@modelcontextprotocol/core` (public Zod `*Schema` constants) | -| | `@modelcontextprotocol/core-internal` (internal — never import directly) | -| Built-in HTTP framework support | `@modelcontextprotocol/node` / `@modelcontextprotocol/express` / `@modelcontextprotocol/hono` / `@modelcontextprotocol/fastify` | +| v1 | v2 | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/client` (client implementation) | +| | `@modelcontextprotocol/server` (server implementation) | +| | `@modelcontextprotocol/core` (public Zod `*Schema` constants) | +| | `@modelcontextprotocol/core-internal` (internal — never import directly) | +| `SSEServerTransport`, AS auth helpers | `@modelcontextprotocol/server-legacy` (frozen v1 copies: `/sse`, `/auth` — deprecated migration bridge) | +| Built-in HTTP framework support | `@modelcontextprotocol/node` / `@modelcontextprotocol/express` / `@modelcontextprotocol/hono` / `@modelcontextprotocol/fastify` | `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core-internal`, so import types and error classes from @@ -170,6 +279,14 @@ 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. +The framework adapter packages declare their framework as a **peer dependency** +(`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the +`@modelcontextprotocol/*` packages your imports use, but does not add the framework +peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` +depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) but +does **not** require the `hono` framework — your package manager may emit a harmless +unmet-peer warning for `hono` (upstream `@hono/node-server` declares it). + 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 @@ -179,9 +296,7 @@ those edits yourself, then install. The v2 additions are computed from the final 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. +declares the v1 SDK. #### Monorepo workspace members @@ -195,49 +310,50 @@ shipped runtime code imports it and in `devDependencies` when only tests, fixtur 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 (add the union of all members' v2 packages at the root — the -codemod's hoisting note lists the contributing members) 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 +A member that never declared the v1 SDK and resolved it through the root has two +options. **Keep root-level declarations:** nothing to do — the codemod's root rewrite +already wrote the union of the contributing members' v2 packages into the root +manifest, and its hoisting note names each contributor. **Move to per-member +declarations** (recommended, since the v2 package split makes each member's actual +needs explicit): derive each member's packages from the import → package rule above — +the hoisting note names contributors, not packages. The codemod's `--dry-run` answer +to "which packages does this member need" works only for a member that itself declares +the v1 SDK (where the root run already prints the same edits); against a hoisted +member it prints no manifest summary. (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 -`@modelcontextprotocol/*` packages your imports use, but does not add the framework -peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` -depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) but -does **not** require the `hono` framework — your package manager may emit a harmless -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 tooling pinned to the v1 package -Repo-local tooling that encodes the literal v1 package name — dependency-pin lints, +**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 +`@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. +**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; +- 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 @@ -260,6 +376,8 @@ resolved by CJS resolvers at all. Jest under its default CommonJS resolution (in 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 @@ -305,12 +423,20 @@ bundler consequences: 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. +For the registration-time and runtime symptoms of the same pin — and the per-member +`npm:zod@^4.2.0` alias workaround — see +[Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist). + #### 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, +**keeping** `@modelcontextprotocol/sdk` — the v2 packages do not share one version, so +do not hand-pin them to one tag; run the codemod with `--dry-run` first and copy the +package ranges from its manifest summary +([Registry availability during the alpha](#registry-availability-during-the-alpha)); +(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 @@ -333,22 +459,47 @@ Dependencies you do not control (vendored fixtures, third-party packages) that s 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. +`overrides`/`resolutions` — until those packages migrate; the boundary rule above +still governs objects you exchange with them. **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 +surviving transitive) until that package migrates. The host files that construct, +hand objects to, or receive objects from 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. +with the dependency later. + +#### Library authors: peer-depending on the SDK + +If you publish a library whose `peerDependencies` name `@modelcontextprotocol/sdk`: + +- **The codemod does not rewrite `peerDependencies`.** The manifest swap reads and + writes only `dependencies` and `devDependencies` — after a run, your sources import + v2 but a `peerDependencies` entry naming `@modelcontextprotocol/sdk` is left + untouched and unreported. Update it by hand. +- Swapping which package(s) your consumers must supply is a breaking change to your + package contract — bump your major. +- Because the v2 packages are differently _named_, consumers can satisfy your new v2 + peer alongside their own still-installed `@modelcontextprotocol/sdk` + ([both can be installed in one manifest at the same time](#migrating-in-stages-large-codebases)). +- You can ship that major before your consumers migrate **only if no SDK object + crosses your public API surface** — the boundary rule above (objects must not flow + between v1-imported and v2-imported code; `instanceof` and nominal types do not + cross) and the dual-role `instanceof` note in [Errors](#errors) (match on stable + fields / `ProtocolError.fromError` at the boundary) apply at your package boundary. + A peer dependency typically exists _because_ objects cross (you register tools on + the host's `McpServer`, accept its `Transport`, return its `Client`) — if so, your + consumers must take your major and migrate their own SDK usage in the same step. +- Peer ranges during the alpha: the packages + [do not share one version number](#registry-availability-during-the-alpha) — mirror + the ranges the codemod writes into `dependencies` when transposing them into + `peerDependencies`. ### Imports & transports @@ -431,7 +582,10 @@ A few transports need a decision the codemod can't make: 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. + imports it wrote, not re-points you make afterwards. Doing this re-point alone turns + invalid tokens into HTTP 500 — apply + [Token verifiers must throw the v2 `OAuthError`](#token-verifiers-must-throw-the-v2-oautherror) + in the same change. ### Low-level protocol & handler context (`ctx`) @@ -440,9 +594,12 @@ object named `extra` — is now a structured **context** object named `ctx`. Thi `ctx` that appears throughout the rest of this guide. The codemod renames the parameter and remaps property access via -[`contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts). -A few mappings need optional-chaining adjustment (the `http` group is `undefined` on -stdio): +[`contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) +(the source of truth). The full remap is reproduced here — the one mapping table that +is — because +[files the codemod never sees (injected SDK surfaces)](#files-the-codemod-never-sees-injected-sdk-surfaces) +are never rewritten and need it applied by hand. Mappings into the `http?` group need +optional chaining (`http` is `undefined` on stdio): | v1 (`extra.*`) | v2 (`ctx.*`) | Note | | ------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -478,34 +635,6 @@ calling `server.*` from inside a handler: | `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) - -The roots, sampling, and logging subsystems are deprecated as of protocol version -2026-07-28 (SEP-2577). Everything below is **still fully functional in v2** and marked -`@deprecated` for removal in a later major; on a 2026-07-28 connection prefer the -[multi-round-trip `input_required` pattern](./support-2026-07-28.md#multi-round-trip-requests) -instead. - -- **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, - `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and - 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 - (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, - `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), - and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, - `RootsListChangedNotification`). -- **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata - Documents per SEP-991. - -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 The low-level handler registration takes a **method string** instead of a Zod schema. @@ -547,20 +676,27 @@ client.setNotificationHandler('notifications/tools/list_changed', async notifica }); ``` -The two overloads are selected by the method string's **type**: the spec form binds the +**Overloads are selected by the method string's static 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. +compile without a schemas argument. + +**Never derive types positionally from these methods** — `Parameters<…>` and +`typeof`-indexed casts resolve against the custom-schema overload on both sides; name +the exported `NotificationMethod` / `RequestMethod` and your own handler/param types +instead. + +- Notification side: `Parameters[0]` resolves to the + custom `string` overload by design. +- Request side: `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. + +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'` → @@ -574,6 +710,17 @@ types' `Record` index signatures, since `undefined` is not a `async (req): Promise => …` — or the table itself, so each literal is checked against the target type instead of being inferred and widened first). +#### Files the codemod never sees: 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. + #### `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 @@ -619,13 +766,10 @@ 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`. +`ProtocolError.fromError(code, message, data)` and throw it. This is typed +reconstruction, **not byte-exact relay** — see +[Typed `ProtocolError` subclasses](#typed-protocolerror-subclasses) for the two ways +it deviates and why not to throw a plain `.code` / `.message` / `.data` object. The return type is inferred from the method name via `ResultTypeMap` (e.g. `client.request({ method: 'tools/call', ... })` returns `Promise`). @@ -634,6 +778,35 @@ replacement: the schema-less send resolves to `CreateMessageResult | CreateMessageResultWithTools`, and validation selects the with-tools variant when the request set `tools` or `toolChoice`. +### Deprecated in v2 (SEP-2577) + +**No action required for v1→v2.** The roots, sampling, and logging subsystems are +deprecated as of protocol version 2026-07-28 (SEP-2577). Everything below is **still +fully functional in v2** and marked `@deprecated` for removal in a later major; on a +2026-07-28 connection prefer the +[multi-round-trip `input_required` pattern](./support-2026-07-28.md#multi-round-trip-requests) +instead. + +- **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, + `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and + 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 + (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, + `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), + and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, + `RootsListChangedNotification`). +- **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata + Documents per SEP-991. + +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. + ### Server registration API The deprecated variadic `.tool()`, `.prompt()`, `.resource()` are removed. Use @@ -681,6 +854,28 @@ completion lists — nothing errors — and if no argument carries completion me 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. +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 +`update()` is not auto-wrapped; wrap it with `z.object()` yourself. + +```typescript +import * as z from 'zod/v4'; +server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, handler); + +// ArkType works too +import { type } from 'arktype'; +server.registerTool('greet', { inputSchema: type({ name: 'string' }) }, handler); + +// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) +import { fromJsonSchema } from '@modelcontextprotocol/server'; +server.registerTool('greet', { inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) }, handler); + +// No-parameter tools: z.object({}) +``` + +#### Zod: `^4.2.0` required (a declared zod-3 range typechecks cleanly under v2 and fails quietly at the first `tools/list`) + **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 @@ -700,6 +895,33 @@ 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.) +#### TS2769: No overload matches this call (`registerTool` / `registerPrompt`) + +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. + +#### zod@3-pinned monorepos: the `npm:zod@^4.2.0` alias + 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 @@ -712,7 +934,9 @@ are for the SDK; they do not compose with the workspace's zod-3 schemas. (For th 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 +#### 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 @@ -725,28 +949,7 @@ the `$schema` member from the converted output before passing it to `fromJsonSch — `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. +#### Zod 4's own type-level API changes (`z.ZodTypeDef`, `z.ZodType` generics) 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 @@ -755,34 +958,18 @@ compile — see [zod's v3-to-v4 changelog](https://zod.dev/v4/changelog). Consum 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 -`update()` is not auto-wrapped; wrap it with `z.object()` yourself. +#### Removed Zod helpers and compat modules -```typescript -import * as z from 'zod/v4'; -server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, handler); +Removed Zod-specific helpers — the codemod marks each call site `@mcp-codemod-error`: -// ArkType works too -import { type } from 'arktype'; -server.registerTool('greet', { inputSchema: type({ name: 'string' }) }, handler); +| Removed | Replacement | Note | +| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `schemaToJson` | `fromJsonSchema()` from `@modelcontextprotocol/server` for raw JSON Schema, or your schema library's native conversion | | +| `parseSchemaAsync` | your schema library's validation directly (e.g. Zod's `.safeParseAsync()`) | | +| `getSchemaShape` / `getSchemaDescription` / `isOptionalSchema` / `unwrapOptionalSchema` | — | no replacement (internal Zod introspection) | +| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | rewritten mechanically by the codemod, unlike its row-mates | -// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) -import { fromJsonSchema } from '@modelcontextprotocol/server'; -server.registerTool('greet', { inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) }, handler); - -// No-parameter tools: z.object({}) -``` - -Removed Zod-specific helpers (the codemod marks each call site `@mcp-codemod-error`): -`schemaToJson` — use `fromJsonSchema()` from `@modelcontextprotocol/server` for raw JSON -Schema, or your schema library's native JSON-Schema conversion; `parseSchemaAsync` — use -your schema library's validation directly (e.g. Zod's `.safeParseAsync()`); -`getSchemaShape` / `getSchemaDescription` / `isOptionalSchema` / `unwrapOptionalSchema` -have no replacement (internal Zod introspection). `SchemaInput` → -`StandardSchemaWithJSON.InferInput` is rewritten mechanically by the codemod. The -internal `standardSchemaToJsonSchema` / `validateStandardSchema` helpers are **not** part +The internal `standardSchemaToJsonSchema` / `validateStandardSchema` helpers are **not** part of the public surface — do not import them. v1's second compat module, `server/zod-json-schema-compat.js` (`toJsonSchemaCompat`), is @@ -813,10 +1000,9 @@ 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. +`SSEClientTransport` options is a standard fetch `RequestInit`: `headers` accepts any +`HeadersInit` (a plain object record as above, a tuple array, or a `Headers` +instance) — 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 @@ -840,8 +1026,12 @@ The SDK now distinguishes three error kinds: and `.statusText`. The codemod renames `McpError` → `ProtocolError`, `ErrorCode` → `ProtocolErrorCode` -(routing `RequestTimeout` / `ConnectionClosed` to `SdkErrorCode`), and -`StreamableHTTPError` → `SdkHttpError`. After the codemod runs, your `instanceof` +(routing `RequestTimeout` / `ConnectionClosed` to `SdkErrorCode` — an all-SDK +condition's `instanceof ProtocolError` guard is rewritten to `instanceof SdkError` +with it), and `StreamableHTTPError` → `SdkHttpError`. Guards that mix +`ProtocolErrorCode` and `SdkErrorCode` members in one condition are not rewritten — +the codemod marks them. +After the codemod runs, your `instanceof` checks already name the v2 classes — what's left is choosing which `SdkErrorCode` / class to match per scenario: @@ -881,8 +1071,6 @@ if (error instanceof SdkHttpError) { } ``` -`StreamableHTTPError` is removed. - **Status read off `.code` by duck-typing.** Code that classified HTTP failures by the status without an `instanceof` — `if ('code' in e && e.code === 403)` — silently stops matching: on `SdkHttpError` the HTTP status moved to `.status` (its `.code` is a @@ -930,10 +1118,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. +Two hits that grep will also surface are not this break: the server still puts +SDK-convention `-32000` / `-32001` in the HTTP `400` (missing `Mcp-Session-Id`) / +`404` (session mismatch) error bodies, unchanged from v1 — key off the HTTP status, +not the body code (see +[SDK-convention JSON-RPC codes in HTTP error bodies](#sdk-convention-json-rpc-codes-in-http-error-bodies)). 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 @@ -947,8 +1136,8 @@ class imported from the other, silently. When an error may originate from the ot package, match on stable fields instead of class identity: `error.code` values (`SdkErrorCode` strings for SDK errors, numeric JSON-RPC codes for protocol errors, `OAuthErrorCode` strings for OAuth errors) plus presence checks like `'status' in e`, -or reconstruct typed protocol errors with `ProtocolError.fromError(code, message, data)` -— it exists precisely because `instanceof` does not survive bundle boundaries. +or reconstruct typed protocol errors with +[`ProtocolError.fromError(code, message, data)`](#typed-protocolerror-subclasses). **Constructing the error (test stubs, custom transports).** v1 `new StreamableHTTPError(code, message)` becomes @@ -984,6 +1173,8 @@ the third argument — `new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, #### Typed `ProtocolError` subclasses + + `ResourceNotFoundError` (carries `.uri`) and `MissingRequiredClientCapabilityError` (carries `data.requiredCapabilities`) are new typed `ProtocolError` subclasses. `resources/read` for an unknown URI now answers `-32602` on every protocol revision @@ -1006,11 +1197,22 @@ 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)`. +subclasses shape their `data` at construction, not at encode time — a `fromError` +reconstruction keeps only the subclass's schema-defined `data` members, so extra +upstream `data` keys are dropped). Construct via +`ProtocolError.fromError(code, message, data)`; throwing a plain object carrying +`.code` / `.message` / `.data` happens to work today, but it is unspecified behavior — +prefer `fromError`. ### Auth +Skip this section if you don't use SDK auth. `OAuthClientProvider` implementers: the +[conformance checklist](#conformance-obligations-for-oauthclientprovider-implementers) +is the complete list of obligations that live in your code rather than the SDK's. +`requireBearerAuth` users: read +[Token verifiers must throw the v2 `OAuthError`](#token-verifiers-must-throw-the-v2-oautherror) +before re-pointing anything. + #### OAuth error consolidation The individual OAuth error classes are replaced with a single `OAuthError` + `OAuthErrorCode`. @@ -1050,6 +1252,11 @@ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } ``` +A frozen copy of the v1 classes (and `mcpAuthRouter`) is available from +`@modelcontextprotocol/server-legacy/auth` during migration. + +#### Token verifiers must throw the v2 `OAuthError` + ⚠ **Token verifiers must throw the v2 `OAuthError`.** `requireBearerAuth` (from `@modelcontextprotocol/express`) classifies the error your `OAuthTokenVerifier.verifyAccessToken()` throws: a v2 @@ -1060,9 +1267,6 @@ become HTTP `500`**. When you re-point `requireBearerAuth` at `@modelcontextprotocol/express`, migrate the error classes your verifier throws in the same change. -A frozen copy of the v1 classes (and `mcpAuthRouter`) is available from -`@modelcontextprotocol/server-legacy/auth` during migration. - #### `AuthProvider` — non-OAuth bearer auth and the widened `authProvider` option The transport `authProvider` option is widened to `AuthProvider | OAuthClientProvider`. @@ -1261,6 +1465,10 @@ The SDK enforces every authorization MUST that lands in SDK code. The following - **Round-trip the `issuer` stamp** on persisted credentials (SEP-2352). Persist the value verbatim from `saveTokens` / `saveClientInformation` and return it verbatim. +- **Implement `discoveryState()` / `saveDiscoveryState()`** (SEP-2352) — so the + callback leg can verify it exchanges the code at the AS the redirect targeted; + without it the SDK `console.warn`s once per callback + ([details](#credentials-bound-to-the-issuing-authorization-server-sep-2352)). - **Pass `expectedIssuer`** when constructing static-credential providers (SEP-2352). - **Keep refresh tokens confidential in storage** (SEP-2207) — OS keychain or encrypted-at-rest store, never `localStorage` / plain files / logs. @@ -1268,7 +1476,9 @@ The SDK enforces every authorization MUST that lands in SDK code. The following `IssuerMismatchError` is thrown, do not render the callback's `error*` values. - **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. + per request; per-session backoff is host state; and either keep + `onInsufficientScope: 'reauthorize'` (the default) or handle + `InsufficientScopeError` yourself. - **Resource-server operators: do not advertise `offline_access`** in `WWW-Authenticate` `scope` or PRM `scopes_supported` (SEP-2207). @@ -1294,6 +1504,10 @@ import { CallToolResultSchema } from '@modelcontextprotocol/core'; if (CallToolResultSchema.safeParse(value).success) { ... } ``` +A namespace import — `import * as t from '…/types.js'` + +`t.CallToolResultSchema.parse(…)` — can't be split per-symbol; the codemod flags it +action-required. Re-import the schema from `@modelcontextprotocol/core` by hand. + `@modelcontextprotocol/core` is the canonical home for the spec's Zod schema constants (and the OAuth/OpenID metadata schemas). It is runtime-neutral (its only dependency is `zod`) and is **not** required by `client` / `server` — install it only if you import the @@ -1356,15 +1570,19 @@ 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/*` + + + +(The published `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 +##### 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` @@ -1502,8 +1720,7 @@ rewrite required unless noted. - **`connect()` skips the `initialize` handshake when the transport already exposes a `sessionId`** — it assumes it is reconnecting to an existing session (unchanged from - v1.x, where the same guard has existed since 1.10.0; recorded here because the - far-away symptom keeps surprising migrators). A custom or test transport that sets `sessionId` at construction + v1.x, where the same guard has existed since 1.10.0). A custom or test transport that sets `sessionId` at construction silently skips initialization: `getServerCapabilities()` stays `undefined` and the list verbs return empty results. Expose `sessionId` only after the first request has been sent. @@ -1520,11 +1737,9 @@ rewrite required unless noted. `ProtocolOptions.supportedProtocolVersions` pins the legacy `initialize` handshake: the **first** pre-2026 entry in the list is offered (list order is preference order), a counter-offer is accepted only if it is one of the list's pre-2026 entries, and a - list with no pre-2026 entry makes the handshake throw. Under - `versionNegotiation: 'auto'` the modern probe candidates are the list's modern - entries when it has any (otherwise the SDK's default modern set); a `{ pin }` is - honored as given and is not checked against the list (see - [support-2026-07-28.md](./support-2026-07-28.md#client-side-versionnegotiation)). + list with no pre-2026 entry makes the handshake throw. The same list also shapes the + 2026-07-28 probing modes (`'auto'` / `{ pin }`) — see + [support-2026-07-28.md › Client side: `versionNegotiation`](./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 @@ -1577,20 +1792,30 @@ rewrite required unless noted. `cachePartition`, `defaultCacheTtlMs`. `ResponseCacheStore` gained `delete(key)`; `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512). -#### Server (Streamable HTTP transport) +#### Streamable HTTP: resumability requires protocol version `>= 2025-11-25` -- Resumability behavior (SSE priming events, `closeSSE` / `closeStandaloneSSE` - callbacks) is only enabled for protocol versions in the transport's supported-versions - list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` - request body no longer enable it. -- Session-ID mismatch still responds `404` with JSON-RPC `-32001` (`Session not found`), - unchanged from v1. This `-32001` is an SDK convention, not spec-assigned; client code - should key off the HTTP `404` status, not `-32001`. +Resumability behavior (SSE priming events, `closeSSE` / `closeStandaloneSSE` +callbacks) is only enabled for protocol versions in the transport's supported-versions +list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` +request body no longer enable it. -#### Server (deprecated accessors and app-factory Origin validation) +#### SDK-convention JSON-RPC codes in HTTP error bodies + +Unchanged from v1 — key off the HTTP status, not the body code; neither code is +spec-assigned: + +| HTTP response | JSON-RPC code in body | +| ----------------------------------------------------------------------------------------------------------------------------------- | --------------------- | +| `400` to a request that requires a session but omits the `Mcp-Session-Id` header (`Bad Request: Mcp-Session-Id header is required`) | `-32000` | +| `404` on a session-ID mismatch (`Session not found`) | `-32001` | + +#### `getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` are `@deprecated` + +`Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()` +are `@deprecated` but functional. On 2026-07-28 requests, prefer `ctx.mcpReq.envelope`. + +#### `createMcp*App()` validates the `Origin` header by default -- `Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()` - are `@deprecated` but functional. On 2026-07-28 requests, prefer `ctx.mcpReq.envelope`. - `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default. Browser-served clients on a non-localhost origin need `allowedOrigins: [...]` (replaces the default @@ -1603,22 +1828,27 @@ rewrite required unless noted. `@modelcontextprotocol/server`; `@modelcontextprotocol/node` ships `hostHeaderValidation` / `originValidation` request guards for plain `node:http`. -#### Server (McpServer / Streamable HTTP behavior) +#### McpServer installs capability handlers eagerly: `tools/list` answers `{tools:[]}` (not `-32601`); `listChanged: true` advertised by default - **Eager capability-handler install.** `McpServer` now installs list/read/call handlers for every primitive capability declared in `ServerOptions.capabilities`, even with 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 +not found`. +- Low-level `Server` users remain responsible for registering handlers for 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 + 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. + +#### `eventStore` store-first semantics (Streamable HTTP) + - **`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 @@ -1627,6 +1857,9 @@ not found`. Low-level `Server` users remain responsible for registering handlers `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` key + - **`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 @@ -1671,7 +1904,7 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see hang instead of resolving; send through the typed surface (`client.ping()`, `client.request()`) instead. -#### Experimental tasks interception removed +### Experimental tasks interception removed The 2025-11 task side-channel through `Protocol` is removed (was always `@experimental`). No mechanical migration; remove usages. Gone: `ProtocolOptions.tasks`, @@ -1684,24 +1917,13 @@ types they yielded, `registerToolTask`, `ToolTaskHandler`, `TaskRequestHandler`, `BaseQueuedMessage` / `Queued*`, `CreateTaskServerContext`, `TaskServerContext`, `TaskToolExecution`, `TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`, and the `new McpServer(info, { taskStore, taskMessageQueue })` constructor option keys -(the codemod emits an action-required diagnostic at each — remove the option). +(the codemod emits an action-required diagnostic at each removed accessor, constructor +option key, and `setRequestHandler(GetTask…Schema, …)` registration — remove the +usage). The task **wire types** remain importable as `@deprecated` vocabulary for 2025-11-25 interop — see [support-2026-07-28.md](./support-2026-07-28.md#tasks-deprecated-wire-vocabulary). -#### Specification clarifications adopted (no SDK behavior change) - -The 2026-07-28 specification revision includes a number of documentation-only -clarifications recorded here so an audit of the revision's changelog against this guide -is complete; nothing in this list requires code changes: per-operation timeout guidance -removal (`RequestOptions.timeout` / `DEFAULT_REQUEST_TIMEOUT_MSEC` unchanged); stdio -shutdown wording; transports-as-bindings reframe; `resources/read` wording (the -`file://` path-sanitization MUST is server-author guidance — your handler must reject -traversal / symlink escapes itself); `PromptMessage` resource links (already in -`ContentBlock`); completion `ref/resource` URI templates; pagination empty-string -cursors (already passed through verbatim); sampling host-requirement docs; elicitation -statefulness wording; cosmetic schema/JSDoc sweeps. - --- ## Enhancements @@ -1716,15 +1938,11 @@ the named class from the explicit subpath (`@modelcontextprotocol/{client,server}/validators/ajv` or `…/cf-worker`) — importing from a subpath means the corresponding peer dep must be in your `package.json`. -### `Client.connect(transport, { prior })` — zero-round-trip connect - -Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to -every worker as `client.connect(transport, { prior })` — 2026-07-28+ only. New exported -type `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`). - ### Serving the 2026-07-28 revision -`createMcpHandler`, `serveStdio`, `versionNegotiation`, multi-round-trip requests +`createMcpHandler`, `serveStdio`, `versionNegotiation`, +`Client.connect(transport, { prior })` / `ConnectOptions` (zero-round-trip connect), +multi-round-trip requests (`requestState`), client cancellation via stream-close, `subscriptions/listen`, `Mcp-Param-*` headers, and per-era wire codecs are covered in **[support-2026-07-28.md](./support-2026-07-28.md)** — they are net-new in v2, not v1→v2 @@ -1734,7 +1952,9 @@ breaks. ## Unchanged APIs -The following are unchanged between v1 and v2 (only the import path changed): +The following carry over from v1 to v2. Most need only an import-path update; where an +entry has a residual difference (a renamed class, a dropped or added parameter), it is +called out inline after the em-dash with a link to its section. - `Client` constructor and `connect`, `close`, and the typed verbs (`listTools`, `listPrompts`, `listResources`, `readResource`, …) — note `callTool()` and `request()` @@ -1746,11 +1966,11 @@ The following are unchanged between v1 and v2 (only the import path changed): 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 + `retryInterval`) and the `handleRequest` surface — only the class names and imports 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 + from `@modelcontextprotocol/node`, and + `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/server` + 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)). @@ -1786,6 +2006,49 @@ The following are unchanged between v1 and v2 (only the import path changed): > constants are **not** part of the unchanged surface — they moved to > `@modelcontextprotocol/core` ([Types & schemas](#types--schemas)). +### Specification clarifications adopted (no SDK behavior change) + +The 2026-07-28 revision's remaining changelog entries are documentation-only +clarifications; none changes SDK behavior or requires code changes: per-operation +timeout guidance +removal (`RequestOptions.timeout` / `DEFAULT_REQUEST_TIMEOUT_MSEC` unchanged); stdio +shutdown wording; transports-as-bindings reframe; `resources/read` wording (the +`file://` path-sanitization MUST is server-author guidance — your handler must reject +traversal / symlink escapes itself); `PromptMessage` resource links (already in +`ContentBlock`); completion `ref/resource` URI templates; pagination empty-string +cursors (already passed through verbatim); sampling host-requirement docs; elicitation +statefulness wording; cosmetic schema/JSDoc sweeps. + +--- + +## Verifying you're done + +Each check links to the section that explains a hit. + +1. `grep -rn '@mcp-codemod-error' .` returns nothing — every marker the codemod left is + resolved ([Manual changes](#manual-changes)). +2. `grep -rn '@modelcontextprotocol/sdk' --exclude-dir=node_modules .` returns only + hits you deliberately kept — a v1 boundary around an unmigrated dependency, or the + transition window of a [staged migration](#migrating-in-stages-large-codebases). + Everything else must go: sources, manifests, and the repo-local tooling that + encodes the literal name (dependency-pin lints, version allowlists, CI checks, + scripts), which the codemod never touches + ([Packaging & runtime](#packaging--runtime)). While here, drop surviving v1-era + double casts (`as unknown as Transport` and kin). +3. `tsc --noEmit` (or your build) is clean. +4. The silent breaks — wrong at runtime with no compile error. Grep each; the linked + section says what to change: + - `grep -rn '\.code ===' .` — an HTTP status compared against `.code` + ([Errors](#errors): it moved to `.status` on `SdkHttpError`). + - `grep -rn -e '=== -32000' -e '=== -32001' .` — raw numeric SDK codes + ([Errors](#errors): both are string `SdkErrorCode` values now). + - `grep -rn -e "'McpError'" -e "'StreamableHTTPError'" -e "'MCP error " .` — + class-name and message-text matchers ([Errors](#errors)). + - Files that receive the SDK by injection (no `@modelcontextprotocol` import) + still using v1 idioms — the codemod never visits them + ([Files the codemod never sees](#files-the-codemod-never-sees-injected-sdk-surfaces)). +5. Your tests pass. + --- ## Need help? From 490cdaa4fddf06a6944b10c90f8bcccba41e84ce Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 30 Jun 2026 14:35:33 +0000 Subject: [PATCH 4/5] Revert "docs(migration): navigation and structure pass on the v1-to-v2 guide" This reverts commit f64928b46b190c41a7f727530c327bc8e197250b. --- docs/migration/support-2026-07-28.md | 342 ++++++----- docs/migration/upgrade-to-v2.md | 831 +++++++++------------------ 2 files changed, 453 insertions(+), 720 deletions(-) diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index 3ab7072407..f4b8a50dbf 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -6,13 +6,7 @@ title: Supporting protocol revision 2026-07-28 This guide is for code **already on the v2 packages** that wants to speak the 2026-07-28 protocol revision — and for code written against an earlier **v2 alpha** that read -wire-only members directly; those changes are flagged inline as -**If you were on a v2 alpha** under -[`createMcpHandler`](#server-over-http-createmcphandler), -[Per-era wire codecs](#per-era-wire-codecs) (the wire-schema table and the error-code -renumbering), and -[Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types). -If you are on `@modelcontextprotocol/sdk` (v1.x), start with +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 @@ -28,12 +22,12 @@ below. ## Contents - [Serving the 2026-07-28 revision](#serving-the-2026-07-28-revision) -- [Multi-round-trip requests](#multi-round-trip-requests) - [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) -- [Legacy shim for `input_required`](#legacy-shim-for-input_required) - [Auth on 2026-07-28](#auth-on-2026-07-28) - [Per-era wire codecs](#per-era-wire-codecs) - [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types) +- [Multi-round-trip requests](#multi-round-trip-requests) +- [Legacy shim for `input_required`](#legacy-shim-for-input_required) - [`subscriptions/listen`](#subscriptionslisten) - [`Mcp-Param-*` and standard headers (SEP-2243)](#mcp-param--and-standard-headers-sep-2243) - [Cache fields and cache hints](#cache-fields-and-cache-hints) @@ -44,8 +38,8 @@ below. ## Serving the 2026-07-28 revision -These entry points are documented in full in [server.md](../server.md) and -[client.md](../client.md). +These entry points are documented in full in the server and client guides; this section +contextualizes them as the migration path. ### Client side: `versionNegotiation` @@ -75,7 +69,8 @@ revision but is not checked against the list. #### Probe policy -Anything the probe does not positively recognize as modern +Failure semantics under `'auto'` are deliberately conservative but never silent about +infrastructure problems. Anything the probe does not positively recognize as modern 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 @@ -92,7 +87,7 @@ versionNegotiation: { mode: 'auto', probe: { timeoutMs: 10_000, // default: the standard request timeout - maxRetries: 0 // default: no retries + maxRetries: 0 // default: no retries — governs timeout re-sends only } } ``` @@ -114,17 +109,14 @@ The probe request itself already carries the per-request `_meta` envelope the envelope to every outgoing request and notification. Tooling that classifies traffic must not treat "saw an envelope" as "modern era negotiated": the legacy-fallback path also begins with one enveloped probe. A gateway/worker fleet can skip the -probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })` — -a zero-round-trip connect: probe once, persist `client.getDiscoverResult()` -(`JSON.stringify`), and feed it to every worker as -`client.connect(transport, { prior })`. The new exported type `ConnectOptions` -extends `RequestOptions` with `prior?: DiscoverResult`. +probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })`. ### Server over HTTP: `createMcpHandler` `createMcpHandler(factory)` from `@modelcontextprotocol/server` is the v2 HTTP entry that serves 2026-07-28 per request — and, by default (`legacy: 'stateless'`), also -serves 2025-era traffic per request through the established stateless idiom. +serves 2025-era traffic per request through the established stateless idiom. One +factory, one endpoint, both eras. ```typescript import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; @@ -225,65 +217,6 @@ are silently suppressed until the client opts in. --- -## Multi-round-trip requests - -The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers -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 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 -`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs -(`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. 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) -for the sealing helper and verification hook. - -**Client side — auto-fulfilment by default.** When a 2026-07-28 call answers -`input_required`, the client fulfils the embedded requests through the same handlers -registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | -'roots/list', …)` and retries (fresh request id, `inputResponses`, byte-exact -`requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). Configure or -opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manually per -call with `allowInputRequired: true` plus `withInputRequired()`. Expect -`SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted. - -**Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a -structural read with an unvalidated cast), two typed readers ship from -`@modelcontextprotocol/server`: - -- `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous - Standard Schema, e.g. a zod object): validates the untrusted accepted content and - returns it typed, or `undefined` on mismatch/decline/missing. -- `inputResponse(responses, key)` — discriminated view - (`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`) - for decline/cancel detection and the non-elicitation kinds. - -Content conveniences stay in your code — e.g. the text of a sampling response is a -one-liner over the discriminated view: - -```typescript -const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); -const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined; -const text = block?.type === 'text' ? block.text : undefined; -``` - ---- - ## Replacing per-session state: `requestState` The 2026-07-28 revision is **per request** — `createMcpHandler` builds a fresh server per @@ -304,8 +237,8 @@ The `createRequestStateCodec({ key, ttlSeconds?, bind? })` helper returns `{ mint, verify }` — `mint` HMAC-SHA256-seals a JSON-serializable payload and `verify` is exactly the function you assign to the hook. The codec is **signed, not encrypted** (the client can base64url-decode the payload). `mint` and -`ctx.mcpReq.requestState()` are the typed encode/read pair — the seam captures what -`verify` returns; no second +`ctx.mcpReq.requestState()` are the typed encode/read pair: the seam captures what +`verify` returns and the accessor hands it to the handler already decoded — no second `verify` call. See `examples/mrtr/server.ts` and [Multi-round-trip requests](#multi-round-trip-requests) for the full handler shape. @@ -346,77 +279,9 @@ async (args, ctx) => { }; ``` ---- - -## Legacy shim for `input_required` - -An `input_required` return on a **2025-era** connection is served by the SDK's legacy -shim, on by default: each embedded request is sent as a real server→client request -(`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — -stamped with the originating request's id, so on sessionful Streamable HTTP the -requests ride the originating POST's stream — and the handler is re-entered with the -collected `inputResponses` until it returns a final result. Handlers are **written -once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs -remain available for code that still calls them directly. - -The handler cannot tell which era fulfilled it — the shim mirrors the modern client -driver's semantics exactly: - -- `inputResponses` are **per round** (replaced on every re-entry, never accumulated); - multi-step flows thread earlier answers through `requestState`. -- `requestState` is echoed byte-exact, and the configured - `ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would - on a modern wire retry (so TTL expiry behaves identically; a rejection answers the - frozen `-32602`). -- Responses arrive as the bare result objects, era-wire-shape-validated only: - elicitation accepted content is NOT re-checked against `requestedSchema` — - exactly as on the modern era — so the handler validates with the - schema-aware `acceptedContent(responses, key, schema)` overload and can - re-issue the request instead of the call dying on a mistyped form field. -- Rounds with no embedded requests (requestState-only) are paced at 250ms. -- URL-mode elicitation legs are sent with a synthesized `elicitationId` (the - 2025-11-25 wire requires one; the 2026 in-band shape has none). - -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 | - -Failures surface **per family**: `tools/call` failures (capability refusal, a failed -leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts -already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC -errors. Server bugs (malformed input-required results) fail loudly on both eras. - -The shim emits no progress of its own — the originating request's `progressToken` is -a single must-increase stream that belongs to the handler (one stream, one author) — -so a 2025 client watching a multi-round flow sees exactly what a hand-written 2025 -push-style handler would have produced. A handler that reports progress across rounds -should derive its values from its phase state so they increase across re-entries — -the token spans the whole flow. - -**Inherited limits** (the same ones hand-written push-style handlers have today): - -- The shim pre-checks each embedded request kind against the client capabilities - declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration - counts as form support — the pre-mode meaning, same as the modern `-32021` gate). - Capability-less clients get a clean refusal, never a hang. -- **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a - fresh instance per request: no initialize handshake, no return path for - server→client requests. The shim degrades to the clean capability refusal there — - full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring. -- JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client - requests mid-call: the transport drops them, so a shim leg waits out - `roundTimeoutMs` before failing per family — the same undeliverable class as - 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: URL-mode legs complete like any other elicitation - response. The sender API for that channel, - `Server.createElicitationCompletionNotifier()`, still works for - 2025-era URL-mode elicitation — only the shim does not bridge it. +Each `case` knows exactly which answer to read and which data is in scope — the state +machine is explicit, and the same handler runs unchanged on 2025-era connections +through the legacy shim. --- @@ -426,10 +291,12 @@ The 2026-07-28 specification's authorization requirements (RFC 9207 `iss` valida SEP-2352 credential isolation, SEP-2350 scope step-up, SEP-837/SEP-2207 DCR + TLS) are implemented in v2 as **SDK-level opt-ins, not protocol-era gates** — they apply on every era once enabled. The migration steps live in -[upgrade-to-v2.md › Auth](./upgrade-to-v2.md#auth). The full conformance checklist — -every obligation that lives in your code rather than the SDK's — is -[upgrade-to-v2.md › Conformance obligations for `OAuthClientProvider` implementers](./upgrade-to-v2.md#conformance-obligations-for-oauthclientprovider-implementers). -Nothing in this section is era-switched at the wire layer. +[upgrade-to-v2.md › Auth](./upgrade-to-v2.md#auth). To be **2026-07-28-conformant**, +enable the spec-2026 opt-ins listed there: pass `iss` (or the callback `URLSearchParams`) +to `finishAuth`; round-trip the `issuer` stamp on stored credentials; implement +`discoveryState()`; and either keep `onInsufficientScope: 'reauthorize'` or handle +`InsufficientScopeError` yourself. Nothing in this section is era-switched at the wire +layer. --- @@ -439,10 +306,8 @@ The wire layer is split into per-revision codecs inside the (private, bundled) c codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The codec is selected by the negotiated protocol version, which is **connection state** on the `Client`/`Server` instance (instances with no negotiated -version default to the 2025 era). A transport that classifies inbound messages at the -edge may attach an optional `MessageExtraInfo.classification` carrier -(`{ era, revision?, envelope? }`); it no longer switches the era per message — dispatch -validates it against the instance era, and a +version default to the 2025 era). An edge classification (`MessageExtraInfo.classification`) +no longer switches the era per message — it is validated against the instance era, and a mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol version` for requests; drop + `onerror` for notifications). @@ -452,8 +317,7 @@ handler is registered, and sending an era-mismatched spec method (e.g. `server/d toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws `SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. -**If you were on a v2 alpha** and validated raw wire traffic with the exported -schemas, guards, or aggregate types: +If you were on a v2 alpha and consumed wire schemas directly: | v2-alpha pattern | Mechanical fix | | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | @@ -484,11 +348,8 @@ application code: the `resultType` discrimination field, the reserved per-reques and the multi-round-trip retry fields (`inputResponses`, `requestState`). - **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, - `GetPromptResult`, …). The SDK's internal per-era wire codecs still parse it off the - wire and the protocol layer consumes it before results reach your code — but the - exported `*Schema` constants validate the neutral model and reject it as an unknown - key (the `EmptyResultSchema` row in the [Per-era wire codecs](#per-era-wire-codecs) - table). + `GetPromptResult`, …). The wire schemas keep parsing it, and the protocol layer + consumes it before results reach your code. - **`DiscoverResult` hides its cache fields at the type level only.** `ttlMs` / `cacheScope` on `server/discover` are read by the client's response-cache layer and are absent from the public `DiscoverResult` type returned by `getDiscoverResult()` — @@ -499,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 envelope `*_META_KEY` constants stay exported. + The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported. The protocol layer enforces the same boundary at runtime: @@ -523,8 +384,7 @@ The protocol layer enforces the same boundary at runtime: before validation. On a 2026-era exchange `resultType` is REQUIRED; an absent value is a spec violation surfaced as a typed error. -**If you were on a v2 alpha** and read `resultType` off delivered results or off the -public types: +**If you were on a v2 alpha** and read the wire shape directly: | Pattern | Mechanical fix | | -------------------------------------- | --------------------------------------------------------------------------------- | @@ -532,6 +392,142 @@ public types: | `Result['resultType']` type reference | remove; the member is no longer declared | | return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | +`MessageExtraInfo.classification` is an optional carrier (`{ era, revision?, envelope? }`) +for transports that classify inbound messages at the edge; dispatch validates it against +the instance's negotiated era. + +--- + +## Multi-round-trip requests + +The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers +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 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 +`@modelcontextprotocol/server`. On 2026-era requests the push-style APIs +(`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. 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) +for the sealing helper and verification hook. + +**Client side — auto-fulfilment by default.** When a 2026-07-28 call answers +`input_required`, the client fulfils the embedded requests through the same handlers +registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | +'roots/list', …)` and retries (fresh request id, `inputResponses`, byte-exact +`requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). Configure or +opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manually per +call with `allowInputRequired: true` plus `withInputRequired()`. Expect +`SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted. + +**Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a +structural read with an unvalidated cast), two typed readers ship from +`@modelcontextprotocol/server`: + +- `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous + Standard Schema, e.g. a zod object): validates the untrusted accepted content and + returns it typed, or `undefined` on mismatch/decline/missing. +- `inputResponse(responses, key)` — discriminated view + (`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`) + for decline/cancel detection and the non-elicitation kinds. + +Content conveniences stay in your code — e.g. the text of a sampling response is a +one-liner over the discriminated view: + +```typescript +const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); +const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined; +const text = block?.type === 'text' ? block.text : undefined; +``` + +--- + +## Legacy shim for `input_required` + +An `input_required` return on a **2025-era** connection is served by the SDK's legacy +shim, on by default: each embedded request is sent as a real server→client request +(`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — +stamped with the originating request's id, so on sessionful Streamable HTTP the +requests ride the originating POST's stream — and the handler is re-entered with the +collected `inputResponses` until it returns a final result. Handlers are **written +once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs +remain available for code that still calls them directly. + +The handler cannot tell which era fulfilled it — the shim mirrors the modern client +driver's semantics exactly: + +- `inputResponses` are **per round** (replaced on every re-entry, never accumulated); + multi-step flows thread earlier answers through `requestState`. +- `requestState` is echoed byte-exact, and the configured + `ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would + on a modern wire retry (so TTL expiry behaves identically; a rejection answers the + frozen `-32602`). +- Responses arrive as the bare result objects, era-wire-shape-validated only: + elicitation accepted content is NOT re-checked against `requestedSchema` — + exactly as on the modern era — so the handler validates with the + schema-aware `acceptedContent(responses, key, schema)` overload and can + re-issue the request instead of the call dying on a mistyped form field. +- Rounds with no embedded requests (requestState-only) are paced at 250ms. +- URL-mode elicitation legs are sent with a synthesized `elicitationId` (the + 2025-11-25 wire requires one; the 2026 in-band shape has none). + +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 | + +Failures surface **per family**: `tools/call` failures (capability refusal, a failed +leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts +already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC +errors. Server bugs (malformed input-required results) fail loudly on both eras. + +The shim emits no progress of its own. The originating request's `progressToken` +identifies a single must-increase stream that belongs to the handler — injecting +synthetic ticks into it cannot compose with handler-emitted progress (one stream, +one author), so the shim never writes to it: a 2025 client watching a multi-round +flow sees exactly what a hand-written 2025 push-style handler would have produced. +A handler that reports progress across rounds should derive its values from its +phase state so they increase across re-entries — the token spans the whole flow. + +**Inherited limits** (the same ones hand-written push-style handlers have today): + +- The shim pre-checks each embedded request kind against the client capabilities + declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration + counts as form support — the pre-mode meaning, same as the modern `-32021` gate). + Capability-less clients get a clean refusal, never a hang. +- **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a + fresh instance per request: no initialize handshake, no return path for + server→client requests. The shim degrades to the clean capability refusal there — + full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring. +- JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client + requests mid-call: the transport drops them, so a shim leg waits out + `roundTimeoutMs` before failing per family — the same undeliverable class as + 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. 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. + --- ## `subscriptions/listen` @@ -618,12 +614,12 @@ Task methods are excluded from the typed method maps: `RequestMethod` / `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`. -(On the published `2.0.0-alpha.3` typings this exclusion is not yet in effect — see -[upgrade-to-v2.md › Zod `*Schema` constants](./upgrade-to-v2.md#zod-schema-constants-moved-to-modelcontextprotocolcore).) -Where +(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 on a 2026-era connection → `-32601` even if a handler is registered. +requests → `-32601`. The experimental tasks **interception** layer is removed entirely — see [upgrade-to-v2.md › Experimental tasks interception removed](./upgrade-to-v2.md#experimental-tasks-interception-removed). diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 548a101539..75fdf277bf 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -13,167 +13,49 @@ work through the manual sections for what the codemod can't rewrite. If you are already on v2 and want to adopt the **2026-07-28 protocol revision**, see [support-2026-07-28.md](./support-2026-07-28.md) instead. -The codemod handles the v1→v2 SDK surface upgrade only — adopting the 2026-07-28 -protocol revision (`createMcpHandler`, multi-round-trip requests, `versionNegotiation`) -is architectural and not codemod-automatable. - ## TL;DR — quick path 1. **Prerequisites.** Node.js 20+ and ESM (`"type": "module"` or `.mts`). v2 ships ESM - only; CommonJS callers must use dynamic `import()`. If you pass **zod** schemas to - the SDK, declare **`zod ^4.2.0`** — a zod-3 range that satisfied the v1 peer still - installs and typechecks cleanly under v2, and only fails (quietly) at the first - `tools/list`; see [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist). - The codemod adds `zod ^4.2.0` only to a manifest with **no** `zod` entry; it warns - about — but never rewrites — an existing zod-3 / 4.0–4.1 range. + only; CommonJS callers must use dynamic `import()`. 2. **Run the codemod.** ```bash npx @modelcontextprotocol/codemod@alpha 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. - `--dry-run` previews every rewrite including the manifest summary; - `--ignore ` excludes files the codemod must not touch. 3. **Grep for markers.** Anything the codemod recognized but could not safely rewrite is marked in place: ```bash grep -rn '@mcp-codemod-error' . ``` -4. **Type-check.** `tsc --noEmit` (or your build). Look up each remaining error in the - [symptom index](#symptom-index) below; anything not listed maps to the - [manual sections](#manual-changes). +4. **Type-check.** `tsc --noEmit` (or your build). Remaining errors map to the + [manual sections](#manual-changes-what-the-codemod-does-not-handle) below. 5. **Format.** The codemod rewrites the AST without reformatting — run your formatter on the changed files (`prettier --write` / `eslint --fix` / `biome format --write`); the codemod prints the exact command after it runs. -6. **Run your tests.** Jest under its default CommonJS resolution fails with - `Cannot find module '@modelcontextprotocol/client'` — see - [CommonJS test runners (Jest)](#commonjs-test-runners-jest-cannot-resolve-the-v2-packages); - Vitest and native Node ESM are unaffected. Failures from runtime-behavior changes - (not compile errors) map to [Behavioral changes](#behavioral-changes). Then - [verify you're done](#verifying-youre-done). +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). -## Symptom index - -Literal diagnostics → the section that fixes them. The strings are verbatim — search -for yours. - -| Symptom / diagnostic | Section | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| `TS2307: Cannot find module '@modelcontextprotocol/sdk/…'` — staged-migration order inverted (manifest swapped before sources are rewritten) | [Migrating in stages](#migrating-in-stages-large-codebases) | -| `TS2307` on `server/zod-json-schema-compat.js` (`toJsonSchemaCompat`) — the codemod does not rewrite this import | [Removed Zod helpers and compat modules](#removed-zod-helpers-and-compat-modules) | -| `TS2307` in a file the codemod never rewrote — outside the run target (run at the package root), or `--ignore`'d while the manifest swap also dropped the v1 dependency | [TL;DR step 2](#tldr--quick-path); [Migrating in stages](#migrating-in-stages-large-codebases) | -| `TS2769: No overload matches this call` on `registerTool` / `registerPrompt` — elaborated as `Property 'jsonSchema' is missing in type …` | [TS2769](#ts2769-no-overload-matches-this-call-registertool--registerprompt) | -| Server starts and connects normally, but the first `tools/list` answers an error pointing at `fromJsonSchema()` | [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist) | -| `TS2367` on `=== -32000` / `=== -32001` | [Errors](#errors) | -| `TS2339` on a property read after an `in` narrow | [Discriminating result shapes](#discriminating-result-shapes-use-guards-not-the-in-operator) | -| `TS2322` on a capability `experimental` payload typed `Record` | [Removed type aliases](#removed-type-aliases) | -| Jest: `Cannot find module '@modelcontextprotocol/client'` | [CommonJS test runners (Jest)](#commonjs-test-runners-jest-cannot-resolve-the-v2-packages) | -| `TypeError: '…' is not a spec method; pass a result schema` | [`request()` / `callTool()` schema drop](#request-ctxmcpreqsend-and-calltool-no-longer-require-a-schema-for-spec-methods) | -| `TypeError` thrown at `setRequestHandler(Schema, …)` registration in a file with no SDK import | [Files the codemod never sees](#files-the-codemod-never-sees-injected-sdk-surfaces) | -| Repeating `[mcp-sdk]` warning on every credential read after upgrading | [Credentials bound to the issuing AS (SEP-2352)](#credentials-bound-to-the-issuing-authorization-server-sep-2352) | -| npm/pnpm "not found" for an `@modelcontextprotocol` package that exists on npmjs.org | [Registry availability during the alpha](#registry-availability-during-the-alpha) | -| `Error("…unsupported dialect…")` | [JSON Schema 2020-12 posture](#json-schema-2020-12-posture-sep-1613-sep-2106) | -| `MissingRefError` surfaced per-tool on `callTool` | [JSON Schema 2020-12 posture](#json-schema-2020-12-posture-sep-1613-sep-2106) | -| `onerror`: `Unknown message type: …` / a raw-frame test fence hangs awaiting a reply | [Wire tightening](#wire-tightening-every-era) | -| Invalid tokens answered HTTP `500` after re-pointing `requireBearerAuth` | [Token verifiers must throw the v2 `OAuthError`](#token-verifiers-must-throw-the-v2-oautherror) | - ## Contents -- [Symptom index](#symptom-index) -- [By situation](#by-situation) - [What the codemod handles](#what-the-codemod-handles) -- [Manual changes](#manual-changes) +- [What the codemod does NOT handle](#what-the-codemod-does-not-handle) +- [Manual changes](#manual-changes-what-the-codemod-does-not-handle) - [Packaging & runtime](#packaging--runtime) - - [Monorepo workspace members](#monorepo-workspace-members) - - [Repo tooling pinned to the v1 package](#repo-tooling-pinned-to-the-v1-package) - - [Registry availability during the alpha](#registry-availability-during-the-alpha) - - [CommonJS test runners (Jest) cannot resolve the v2 packages](#commonjs-test-runners-jest-cannot-resolve-the-v2-packages) - - [Bundlers: nested `zod` copies in zod@3-pinned monorepos](#bundlers-nested-zod-copies-in-zod3-pinned-monorepos) - - [Migrating in stages (large codebases)](#migrating-in-stages-large-codebases) - - [Library authors: peer-depending on the SDK](#library-authors-peer-depending-on-the-sdk) - [Imports & transports](#imports--transports) - [Low-level protocol & handler context (`ctx`)](#low-level-protocol--handler-context-ctx) - - [`setRequestHandler` / `setNotificationHandler` use method strings](#setrequesthandler--setnotificationhandler-use-method-strings) - - [Files the codemod never sees: injected SDK surfaces](#files-the-codemod-never-sees-injected-sdk-surfaces) - - [`request()`, `ctx.mcpReq.send()`, and `callTool()` no longer require a schema for spec methods](#request-ctxmcpreqsend-and-calltool-no-longer-require-a-schema-for-spec-methods) - - [Deprecated in v2 (SEP-2577)](#deprecated-in-v2-sep-2577) - [Server registration API](#server-registration-api) - - [Standard Schema objects (raw shapes deprecated)](#standard-schema-objects-raw-shapes-deprecated) - - [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist) - - [TS2769: No overload matches this call](#ts2769-no-overload-matches-this-call-registertool--registerprompt) - - [zod@3-pinned monorepos: the `npm:zod@^4.2.0` alias](#zod3-pinned-monorepos-the-npmzod420-alias) - - [Hosts that forward consumer-authored schemas](#hosts-that-forward-consumer-authored-schemas) - - [Zod 4's own type-level API changes](#zod-4s-own-type-level-api-changes-zzodtypedef-zzodtype-generics) - - [Removed Zod helpers and compat modules](#removed-zod-helpers-and-compat-modules) - [HTTP & headers](#http--headers) - [Errors](#errors) - - [`SdkErrorCode` enum (complete)](#sdkerrorcode-enum-complete) - - [Typed `ProtocolError` subclasses](#typed-protocolerror-subclasses) - [Auth](#auth) - - [OAuth error consolidation](#oauth-error-consolidation) - - [Token verifiers must throw the v2 `OAuthError`](#token-verifiers-must-throw-the-v2-oautherror) - - [`AuthProvider` — non-OAuth bearer auth and the widened `authProvider` option](#authprovider--non-oauth-bearer-auth-and-the-widened-authprovider-option) - - [OAuth client flow — behavioral changes](#oauth-client-flow--behavioral-changes) - - [OAuth client flow errors (new)](#oauth-client-flow-errors-new) - - [Connect-time OAuth retry (`UnauthorizedError`)](#connect-time-oauth-retry-unauthorizederror) - - [`auth()` options are now `AuthOptions`](#auth-options-are-now-authoptions) - - [Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3)](#authorization-server-mix-up-defense-rfc-9207--rfc-8414-33--action-required) - - [Dynamic Client Registration defaults (SEP-837, SEP-2207)](#dynamic-client-registration-defaults-sep-837-sep-2207) - - [Token endpoint must use TLS (SEP-2207)](#token-endpoint-must-use-tls-sep-2207) - - [Scope step-up on `403 insufficient_scope` (SEP-2350)](#scope-step-up-on-403-insufficient_scope-sep-2350) - - [Credentials bound to the issuing authorization server (SEP-2352)](#credentials-bound-to-the-issuing-authorization-server-sep-2352) - - [Conformance obligations for `OAuthClientProvider` implementers](#conformance-obligations-for-oauthclientprovider-implementers) - [Types & schemas](#types--schemas) - - [Zod `*Schema` constants moved to `@modelcontextprotocol/core`](#zod-schema-constants-moved-to-modelcontextprotocolcore) - - [Removed type aliases](#removed-type-aliases) - - [JSON Schema 2020-12 posture (SEP-1613, SEP-2106)](#json-schema-2020-12-posture-sep-1613-sep-2106) - [Behavioral changes](#behavioral-changes) - - [Error-shape changes (every era)](#error-shape-changes-every-era) - - [Client connection & dispatch](#client-connection--dispatch) - - [stdio transport](#stdio-transport) - - [Client list methods](#client-list-methods) - - [Streamable HTTP: resumability requires protocol version `>= 2025-11-25`](#streamable-http-resumability-requires-protocol-version--2025-11-25) - - [SDK-convention JSON-RPC codes in HTTP error bodies](#sdk-convention-json-rpc-codes-in-http-error-bodies) - - [`getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` are `@deprecated`](#getclientcapabilities--getclientversion--getnegotiatedprotocolversion-are-deprecated) - - [`createMcp*App()` validates the `Origin` header by default](#createmcpapp-validates-the-origin-header-by-default) - - [McpServer installs capability handlers eagerly](#mcpserver-installs-capability-handlers-eagerly-toolslist-answers-tools-not--32601-listchanged-true-advertised-by-default) - - [`eventStore` store-first semantics (Streamable HTTP)](#eventstore-store-first-semantics-streamable-http) - - [`registerResource` reserves the `cacheHint` key](#registerresource-reserves-the-cachehint-key) - - [`ctx.mcpReq.log()` is request-related on every era](#ctxmcpreqlog-is-request-related-on-every-era) - - [Wire tightening (every era)](#wire-tightening-every-era) - - [Experimental tasks interception removed](#experimental-tasks-interception-removed) - [Enhancements](#enhancements) - - [Automatic JSON Schema validator selection by runtime](#automatic-json-schema-validator-selection-by-runtime) - - [Serving the 2026-07-28 revision](#serving-the-2026-07-28-revision) - [Unchanged APIs](#unchanged-apis) - - [Specification clarifications adopted (no SDK behavior change)](#specification-clarifications-adopted-no-sdk-behavior-change) -- [Verifying you're done](#verifying-youre-done) - [Need help?](#need-help) -## By situation - -Situations whose entry point is not a heading name — the full map is the Contents -above: - -- A gateway or proxy forwarding arbitrary methods, or relaying upstream errors → - [`request()` / `callTool()` schema drop](#request-ctxmcpreqsend-and-calltool-no-longer-require-a-schema-for-spec-methods); - [Errors](#errors) -- A plugin host or agent framework registering schemas its own consumers author → - [Hosts that forward consumer-authored schemas](#hosts-that-forward-consumer-authored-schemas) -- A process that is both MCP client and server, or objects crossing the two packages → - [Errors](#errors) (dual-role `instanceof` note) -- A workspace or vendored dependency that compiles against the host's v1 SDK → - [Migrating in stages](#migrating-in-stages-large-codebases) -- An SDK surface received by injection (dependency injection / factory seams), with no - import in the file → - [Files the codemod never sees](#files-the-codemod-never-sees-injected-sdk-surfaces) -- A workspace whose declared zod range is still `^3` → - [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist); - [the `npm:zod@^4.2.0` alias](#zod3-pinned-monorepos-the-npmzod420-alias) - --- ## What the codemod handles @@ -189,88 +71,97 @@ mechanically applies every rename whose mapping is fixed. The mappings are the | `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) | | `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` property remap | [`mappings/contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) | -In addition the codemod applies the structural transforms below. A transform may still -leave `@mcp-codemod-error` markers at sites it recognized but could not rewrite safely -(noted per bullet); each marker's comment text names the problem and the replacement — -find them all with `grep -rn '@mcp-codemod-error' .` -([step 3](#tldr--quick-path), [marker format](../../packages/codemod/README.md#mcp-codemod-error-markers)). -Idioms the codemod cannot see at all are listed at the top of -[Manual changes](#manual-changes). +In addition the codemod: - Updates `package.json` dependencies (`@modelcontextprotocol/sdk` → the v2 packages - your imports actually use) — [Packaging & runtime](#packaging--runtime). + your imports actually use). - Rewrites `.tool()` / `.prompt()` / `.resource()` to `registerTool` / `registerPrompt` - / `registerResource`, wrapping raw Zod shapes with `z.object()` and adding the `z` - import when the file has no `z` binding — - [Server registration API](#server-registration-api). + / `registerResource` and wraps `inputSchema` / `outputSchema` / `argsSchema` / + `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 — - [details](#request-ctxmcpreqsend-and-calltool-no-longer-require-a-schema-for-spec-methods). + spec methods. - Routes the spec Zod `*Schema` constants imported from `sdk/types.js` to - `@modelcontextprotocol/core` (mixed imports are split); task-handler schema - registrations (`GetTaskRequestSchema` etc.) are marked, not rewritten — the - experimental tasks feature was removed (SEP-2663) - ([Experimental tasks interception removed](#experimental-tasks-interception-removed); - [Types & schemas](#types--schemas)). -- Renames `ErrorCode` → `ProtocolErrorCode`, routing the local-only members - (`RequestTimeout`, `ConnectionClosed`) to `SdkErrorCode` (guards mixing the two - enums are marked) — [Errors](#errors). + `@modelcontextprotocol/core` (mixed imports are split; `.parse()` / `.safeParse()` + calls are left untouched). Task-handler schema constants + (`GetTaskRequestSchema` etc.) used as `setRequestHandler` args are **not** rewritten + — the experimental tasks feature was removed (SEP-2663), so each such registration + 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` — 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) — - [Errors](#errors). + (constructor calls are marked for review — argument shape changed). - Replaces `IsomorphicHeaders` with the Web Standard `Headers` type and drops the - import — [HTTP & headers](#http--headers). + import (a warning notes `Headers` uses `.get()`/`.set()`, not bracket access). - Rewrites `SchemaInput` → `StandardSchemaWithJSON.InferInput`. - Renames `RequestHandlerExtra` → `ServerContext` / `ClientContext` and the `extra` - parameter to `ctx` — - [Low-level protocol & handler context](#low-level-protocol--handler-context-ctx). + parameter to `ctx`. - Rewrites `vi.mock` / `jest.mock` and dynamic `import()` paths. -- Renames the `ResourceTemplate` **type** imported from `sdk/types.js` to - `ResourceTemplateType`, scoped so the URI-template helper **class** keeps its name — - [footnote ³](#removed-type-aliases). +- Renames the `ResourceTemplate` **type** imported from `@modelcontextprotocol/sdk/types.js` + 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()` (uninvertible shapes are marked) — - [Standard Schema objects](#standard-schema-objects-raw-shapes-deprecated). + `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 ([details](#removed-type-aliases)). + replacement at each site. + +## What the codemod does NOT handle + +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) +- **Header-read `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` + and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but + 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) +- **OAuth error-class consolidation** — `instanceof InvalidGrantError` → `OAuthError` + + `OAuthErrorCode` is a judgment rewrite. → [Auth](#auth) +- **`SdkErrorCode` branch selection** — the codemod renames `StreamableHTTPError` → + `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is + judgment. → [Errors](#errors) +- **Namespace schema access** — `import * as t from '…/types.js'` + + `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) --- -## Manual changes - -Each gap below maps to a manual section in this part of the guide. The codemod marks -every site it recognized but could not safely rewrite with an `@mcp-codemod-error` -comment. - -| Gap | Why it's manual | Section | -| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -| **Node 20 / ESM** | pre-flight, not a code rewrite | [Packaging & runtime](#packaging--runtime) | -| **`zod ^4.2.0` bump on an existing `zod` declaration** | the codemod adds `zod ^4.2.0` only to a manifest that declares no `zod`; it warns about — but never rewrites — a declared zod-3 / 4.0–4.1 range | [Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist) | -| **Header-read `.get()` rewrite** | `IsomorphicHeaders` is renamed to `Headers` and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but converting that bracket access to `.get()` is manual | [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) | -| **OAuth error-class consolidation** | `instanceof InvalidGrantError` → `OAuthError` + `OAuthErrorCode` is a judgment rewrite | [Auth](#auth) | -| **`SdkErrorCode` branch selection** | the codemod renames `StreamableHTTPError` → `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is judgment | [Errors](#errors) | -| **Namespace schema access** | `import * as t from '…/types.js'` can't be split per-symbol; the codemod marks it action-required | [Zod `*Schema` constants moved](#zod-schema-constants-moved-to-modelcontextprotocolcore) | -| **Import-less (injected) SDK surfaces** | the codemod is import-driven: a file with no SDK import is never rewritten, and the v1 idioms there fail at **runtime**, not compile time | [Files the codemod never sees](#files-the-codemod-never-sees-injected-sdk-surfaces) | -| **Behavioral adaptation** | list auto-aggregation, capability empties, lazy validator compilation, output-schema validation rules | [Behavioral changes](#behavioral-changes) | +## Manual changes (what the codemod does not handle) ### Packaging & runtime -v2 requires **Node.js 20+** and ships **ESM only**. If your project uses CommonJS -(`require()`), either migrate to ESM or use dynamic `import()`. - The single `@modelcontextprotocol/sdk` package is split: -| v1 | v2 | -| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/client` (client implementation) | -| | `@modelcontextprotocol/server` (server implementation) | -| | `@modelcontextprotocol/core` (public Zod `*Schema` constants) | -| | `@modelcontextprotocol/core-internal` (internal — never import directly) | -| `SSEServerTransport`, AS auth helpers | `@modelcontextprotocol/server-legacy` (frozen v1 copies: `/sse`, `/auth` — deprecated migration bridge) | -| Built-in HTTP framework support | `@modelcontextprotocol/node` / `@modelcontextprotocol/express` / `@modelcontextprotocol/hono` / `@modelcontextprotocol/fastify` | +| v1 | v2 | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `@modelcontextprotocol/sdk` | `@modelcontextprotocol/client` (client implementation) | +| | `@modelcontextprotocol/server` (server implementation) | +| | `@modelcontextprotocol/core` (public Zod `*Schema` constants) | +| | `@modelcontextprotocol/core-internal` (internal — never import directly) | +| Built-in HTTP framework support | `@modelcontextprotocol/node` / `@modelcontextprotocol/express` / `@modelcontextprotocol/hono` / `@modelcontextprotocol/fastify` | `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core-internal`, so import types and error classes from @@ -279,14 +170,6 @@ 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. -The framework adapter packages declare their framework as a **peer dependency** -(`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the -`@modelcontextprotocol/*` packages your imports use, but does not add the framework -peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` -depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) but -does **not** require the `hono` framework — your package manager may emit a harmless -unmet-peer warning for `hono` (upstream `@hono/node-server` declares it). - 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 @@ -296,7 +179,9 @@ those edits yourself, then install. The v2 additions are computed from the final 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. +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 @@ -310,50 +195,49 @@ shipped runtime code imports it and in `devDependencies` when only tests, fixtur 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 has two -options. **Keep root-level declarations:** nothing to do — the codemod's root rewrite -already wrote the union of the contributing members' v2 packages into the root -manifest, and its hoisting note names each contributor. **Move to per-member -declarations** (recommended, since the v2 package split makes each member's actual -needs explicit): derive each member's packages from the import → package rule above — -the hoisting note names contributors, not packages. The codemod's `--dry-run` answer -to "which packages does this member need" works only for a member that itself declares -the v1 SDK (where the root run already prints the same edits); against a hoisted -member it prints no manifest summary. (The authoritative import-path routing lives +A member that never declared the v1 SDK and resolved it through the root can keep +root-level declarations (add the union of all members' v2 packages at the root — the +codemod's hoisting note lists the contributing members) 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).) -#### Repo tooling pinned to the v1 package +The framework adapter packages declare their framework as a **peer dependency** +(`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the +`@modelcontextprotocol/*` packages your imports use, but does not add the framework +peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` +depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) but +does **not** require the `hono` framework — your package manager may emit a harmless +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, +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 +`@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; -- 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. +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 @@ -376,8 +260,6 @@ resolved by CJS resolvers at all. Jest under its default CommonJS resolution (in 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 @@ -423,20 +305,12 @@ bundler consequences: 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. -For the registration-time and runtime symptoms of the same pin — and the per-member -`npm:zod@^4.2.0` alias workaround — see -[Zod: `^4.2.0` required](#zod-420-required-a-declared-zod-3-range-typechecks-cleanly-under-v2-and-fails-quietly-at-the-first-toolslist). - #### 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` — the v2 packages do not share one version, so -do not hand-pin them to one tag; run the codemod with `--dry-run` first and copy the -package ranges from its manifest summary -([Registry availability during the alpha](#registry-availability-during-the-alpha)); -(2) rewrite sources incrementally, +**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 @@ -459,47 +333,22 @@ Dependencies you do not control (vendored fixtures, third-party packages) that s 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 boundary rule above -still governs objects you exchange with them. +`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, -hand objects to, or receive objects from such a package are part of its v1 boundary -and must stay on v1 imports +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. - -#### Library authors: peer-depending on the SDK - -If you publish a library whose `peerDependencies` name `@modelcontextprotocol/sdk`: - -- **The codemod does not rewrite `peerDependencies`.** The manifest swap reads and - writes only `dependencies` and `devDependencies` — after a run, your sources import - v2 but a `peerDependencies` entry naming `@modelcontextprotocol/sdk` is left - untouched and unreported. Update it by hand. -- Swapping which package(s) your consumers must supply is a breaking change to your - package contract — bump your major. -- Because the v2 packages are differently _named_, consumers can satisfy your new v2 - peer alongside their own still-installed `@modelcontextprotocol/sdk` - ([both can be installed in one manifest at the same time](#migrating-in-stages-large-codebases)). -- You can ship that major before your consumers migrate **only if no SDK object - crosses your public API surface** — the boundary rule above (objects must not flow - between v1-imported and v2-imported code; `instanceof` and nominal types do not - cross) and the dual-role `instanceof` note in [Errors](#errors) (match on stable - fields / `ProtocolError.fromError` at the boundary) apply at your package boundary. - A peer dependency typically exists _because_ objects cross (you register tools on - the host's `McpServer`, accept its `Transport`, return its `Client`) — if so, your - consumers must take your major and migrate their own SDK usage in the same step. -- Peer ranges during the alpha: the packages - [do not share one version number](#registry-availability-during-the-alpha) — mirror - the ranges the codemod writes into `dependencies` when transposing them into - `peerDependencies`. +with the dependency later. The boundary rule above applies unchanged: objects from the +dependency's v1 modules must never flow into v2-imported code. ### Imports & transports @@ -582,10 +431,7 @@ A few transports need a decision the codemod can't make: 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. Doing this re-point alone turns - invalid tokens into HTTP 500 — apply - [Token verifiers must throw the v2 `OAuthError`](#token-verifiers-must-throw-the-v2-oautherror) - in the same change. + imports it wrote, not re-points you make afterwards. ### Low-level protocol & handler context (`ctx`) @@ -594,12 +440,9 @@ object named `extra` — is now a structured **context** object named `ctx`. Thi `ctx` that appears throughout the rest of this guide. The codemod renames the parameter and remaps property access via -[`contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts) -(the source of truth). The full remap is reproduced here — the one mapping table that -is — because -[files the codemod never sees (injected SDK surfaces)](#files-the-codemod-never-sees-injected-sdk-surfaces) -are never rewritten and need it applied by hand. Mappings into the `http?` group need -optional chaining (`http` is `undefined` on stdio): +[`contextPropertyMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/contextPropertyMap.ts). +A few mappings need optional-chaining adjustment (the `http` group is `undefined` on +stdio): | v1 (`extra.*`) | v2 (`ctx.*`) | Note | | ------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -635,6 +478,34 @@ calling `server.*` from inside a handler: | `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) + +The roots, sampling, and logging subsystems are deprecated as of protocol version +2026-07-28 (SEP-2577). Everything below is **still fully functional in v2** and marked +`@deprecated` for removal in a later major; on a 2026-07-28 connection prefer the +[multi-round-trip `input_required` pattern](./support-2026-07-28.md#multi-round-trip-requests) +instead. + +- **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, + `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and + 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 + (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, + `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), + and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, + `RootsListChangedNotification`). +- **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata + Documents per SEP-991. + +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 The low-level handler registration takes a **method string** instead of a Zod schema. @@ -676,27 +547,20 @@ client.setNotificationHandler('notifications/tools/list_changed', async notifica }); ``` -**Overloads are selected by the method string's static type.** The spec form binds the +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. - -**Never derive types positionally from these methods** — `Parameters<…>` and -`typeof`-indexed casts resolve against the custom-schema overload on both sides; name -the exported `NotificationMethod` / `RequestMethod` and your own handler/param types -instead. - -- Notification side: `Parameters[0]` resolves to the - custom `string` overload by design. -- Request side: `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. - -Generic helpers that v1 parameterized on a notification schema need this conversion by -hand; the codemod only warns on them. +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'` → @@ -710,17 +574,6 @@ types' `Record` index signatures, since `undefined` is not a `async (req): Promise => …` — or the table itself, so each literal is checked against the target type instead of being inferred and widened first). -#### Files the codemod never sees: 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. - #### `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 @@ -766,10 +619,13 @@ 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. This is typed -reconstruction, **not byte-exact relay** — see -[Typed `ProtocolError` subclasses](#typed-protocolerror-subclasses) for the two ways -it deviates and why not to throw a plain `.code` / `.message` / `.data` object. +`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`). @@ -778,35 +634,6 @@ replacement: the schema-less send resolves to `CreateMessageResult | CreateMessageResultWithTools`, and validation selects the with-tools variant when the request set `tools` or `toolChoice`. -### Deprecated in v2 (SEP-2577) - -**No action required for v1→v2.** The roots, sampling, and logging subsystems are -deprecated as of protocol version 2026-07-28 (SEP-2577). Everything below is **still -fully functional in v2** and marked `@deprecated` for removal in a later major; on a -2026-07-28 connection prefer the -[multi-round-trip `input_required` pattern](./support-2026-07-28.md#multi-round-trip-requests) -instead. - -- **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, - `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and - 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 - (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, - `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), - and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, - `RootsListChangedNotification`). -- **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata - Documents per SEP-991. - -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. - ### Server registration API The deprecated variadic `.tool()`, `.prompt()`, `.resource()` are removed. Use @@ -854,28 +681,6 @@ completion lists — nothing errors — and if no argument carries completion me 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. -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 -`update()` is not auto-wrapped; wrap it with `z.object()` yourself. - -```typescript -import * as z from 'zod/v4'; -server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, handler); - -// ArkType works too -import { type } from 'arktype'; -server.registerTool('greet', { inputSchema: type({ name: 'string' }) }, handler); - -// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) -import { fromJsonSchema } from '@modelcontextprotocol/server'; -server.registerTool('greet', { inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) }, handler); - -// No-parameter tools: z.object({}) -``` - -#### Zod: `^4.2.0` required (a declared zod-3 range typechecks cleanly under v2 and fails quietly at the first `tools/list`) - **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 @@ -895,33 +700,6 @@ 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.) -#### TS2769: No overload matches this call (`registerTool` / `registerPrompt`) - -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. - -#### zod@3-pinned monorepos: the `npm:zod@^4.2.0` alias - 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 @@ -934,9 +712,7 @@ are for the SDK; they do not compose with the workspace's zod-3 schemas. (For th 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 +**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 @@ -949,7 +725,28 @@ the `$schema` member from the converted output before passing it to `fromJsonSch — `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). -#### Zod 4's own type-level API changes (`z.ZodTypeDef`, `z.ZodType` generics) +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 @@ -958,18 +755,34 @@ compile — see [zod's v3-to-v4 changelog](https://zod.dev/v4/changelog). Consum 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. -#### Removed Zod helpers and compat modules +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 +`update()` is not auto-wrapped; wrap it with `z.object()` yourself. -Removed Zod-specific helpers — the codemod marks each call site `@mcp-codemod-error`: +```typescript +import * as z from 'zod/v4'; +server.registerTool('greet', { inputSchema: z.object({ name: z.string() }) }, handler); -| Removed | Replacement | Note | -| --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | -| `schemaToJson` | `fromJsonSchema()` from `@modelcontextprotocol/server` for raw JSON Schema, or your schema library's native conversion | | -| `parseSchemaAsync` | your schema library's validation directly (e.g. Zod's `.safeParseAsync()`) | | -| `getSchemaShape` / `getSchemaDescription` / `isOptionalSchema` / `unwrapOptionalSchema` | — | no replacement (internal Zod introspection) | -| `SchemaInput` | `StandardSchemaWithJSON.InferInput` | rewritten mechanically by the codemod, unlike its row-mates | +// ArkType works too +import { type } from 'arktype'; +server.registerTool('greet', { inputSchema: type({ name: 'string' }) }, handler); -The internal `standardSchemaToJsonSchema` / `validateStandardSchema` helpers are **not** part +// Raw JSON Schema via fromJsonSchema (validator defaults to runtime-appropriate choice) +import { fromJsonSchema } from '@modelcontextprotocol/server'; +server.registerTool('greet', { inputSchema: fromJsonSchema({ type: 'object', properties: { name: { type: 'string' } } }) }, handler); + +// No-parameter tools: z.object({}) +``` + +Removed Zod-specific helpers (the codemod marks each call site `@mcp-codemod-error`): +`schemaToJson` — use `fromJsonSchema()` from `@modelcontextprotocol/server` for raw JSON +Schema, or your schema library's native JSON-Schema conversion; `parseSchemaAsync` — use +your schema library's validation directly (e.g. Zod's `.safeParseAsync()`); +`getSchemaShape` / `getSchemaDescription` / `isOptionalSchema` / `unwrapOptionalSchema` +have no replacement (internal Zod introspection). `SchemaInput` → +`StandardSchemaWithJSON.InferInput` is rewritten mechanically by the codemod. The +internal `standardSchemaToJsonSchema` / `validateStandardSchema` helpers are **not** part of the public surface — do not import them. v1's second compat module, `server/zod-json-schema-compat.js` (`toJsonSchemaCompat`), is @@ -1000,9 +813,10 @@ 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`: `headers` accepts any -`HeadersInit` (a plain object record as above, a tuple array, or a `Headers` -instance) — wrapping with `new Headers()` is optional, not required. +`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 @@ -1026,12 +840,8 @@ The SDK now distinguishes three error kinds: and `.statusText`. The codemod renames `McpError` → `ProtocolError`, `ErrorCode` → `ProtocolErrorCode` -(routing `RequestTimeout` / `ConnectionClosed` to `SdkErrorCode` — an all-SDK -condition's `instanceof ProtocolError` guard is rewritten to `instanceof SdkError` -with it), and `StreamableHTTPError` → `SdkHttpError`. Guards that mix -`ProtocolErrorCode` and `SdkErrorCode` members in one condition are not rewritten — -the codemod marks them. -After the codemod runs, your `instanceof` +(routing `RequestTimeout` / `ConnectionClosed` to `SdkErrorCode`), and +`StreamableHTTPError` → `SdkHttpError`. After the codemod runs, your `instanceof` checks already name the v2 classes — what's left is choosing which `SdkErrorCode` / class to match per scenario: @@ -1071,6 +881,8 @@ if (error instanceof SdkHttpError) { } ``` +`StreamableHTTPError` is removed. + **Status read off `.code` by duck-typing.** Code that classified HTTP failures by the status without an `instanceof` — `if ('code' in e && e.code === 403)` — silently stops matching: on `SdkHttpError` the HTTP status moved to `.status` (its `.code` is a @@ -1118,11 +930,10 @@ it usually targeted are now **string** `SdkErrorCode` values: | `-32000` (ConnectionClosed) | `SdkError` + `SdkErrorCode.ConnectionClosed` | | `-32001` (RequestTimeout) | `SdkError` + `SdkErrorCode.RequestTimeout` | -Two hits that grep will also surface are not this break: the server still puts -SDK-convention `-32000` / `-32001` in the HTTP `400` (missing `Mcp-Session-Id`) / -`404` (session mismatch) error bodies, unchanged from v1 — key off the HTTP status, -not the body code (see -[SDK-convention JSON-RPC codes in HTTP error bodies](#sdk-convention-json-rpc-codes-in-http-error-bodies)). +- 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 @@ -1136,8 +947,8 @@ class imported from the other, silently. When an error may originate from the ot package, match on stable fields instead of class identity: `error.code` values (`SdkErrorCode` strings for SDK errors, numeric JSON-RPC codes for protocol errors, `OAuthErrorCode` strings for OAuth errors) plus presence checks like `'status' in e`, -or reconstruct typed protocol errors with -[`ProtocolError.fromError(code, message, data)`](#typed-protocolerror-subclasses). +or reconstruct typed protocol errors with `ProtocolError.fromError(code, message, data)` +— it exists precisely because `instanceof` does not survive bundle boundaries. **Constructing the error (test stubs, custom transports).** v1 `new StreamableHTTPError(code, message)` becomes @@ -1173,8 +984,6 @@ the third argument — `new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, #### Typed `ProtocolError` subclasses - - `ResourceNotFoundError` (carries `.uri`) and `MissingRequiredClientCapabilityError` (carries `data.requiredCapabilities`) are new typed `ProtocolError` subclasses. `resources/read` for an unknown URI now answers `-32602` on every protocol revision @@ -1197,22 +1006,11 @@ 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 — a `fromError` -reconstruction keeps only the subclass's schema-defined `data` members, so extra -upstream `data` keys are dropped). Construct via -`ProtocolError.fromError(code, message, data)`; throwing a plain object carrying -`.code` / `.message` / `.data` happens to work today, but it is unspecified behavior — -prefer `fromError`. +subclasses shape their `data` at construction, not at encode time). Construct via +`ProtocolError.fromError(code, message, data)`. ### Auth -Skip this section if you don't use SDK auth. `OAuthClientProvider` implementers: the -[conformance checklist](#conformance-obligations-for-oauthclientprovider-implementers) -is the complete list of obligations that live in your code rather than the SDK's. -`requireBearerAuth` users: read -[Token verifiers must throw the v2 `OAuthError`](#token-verifiers-must-throw-the-v2-oautherror) -before re-pointing anything. - #### OAuth error consolidation The individual OAuth error classes are replaced with a single `OAuthError` + `OAuthErrorCode`. @@ -1252,11 +1050,6 @@ import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } ``` -A frozen copy of the v1 classes (and `mcpAuthRouter`) is available from -`@modelcontextprotocol/server-legacy/auth` during migration. - -#### Token verifiers must throw the v2 `OAuthError` - ⚠ **Token verifiers must throw the v2 `OAuthError`.** `requireBearerAuth` (from `@modelcontextprotocol/express`) classifies the error your `OAuthTokenVerifier.verifyAccessToken()` throws: a v2 @@ -1267,6 +1060,9 @@ become HTTP `500`**. When you re-point `requireBearerAuth` at `@modelcontextprotocol/express`, migrate the error classes your verifier throws in the same change. +A frozen copy of the v1 classes (and `mcpAuthRouter`) is available from +`@modelcontextprotocol/server-legacy/auth` during migration. + #### `AuthProvider` — non-OAuth bearer auth and the widened `authProvider` option The transport `authProvider` option is widened to `AuthProvider | OAuthClientProvider`. @@ -1465,10 +1261,6 @@ The SDK enforces every authorization MUST that lands in SDK code. The following - **Round-trip the `issuer` stamp** on persisted credentials (SEP-2352). Persist the value verbatim from `saveTokens` / `saveClientInformation` and return it verbatim. -- **Implement `discoveryState()` / `saveDiscoveryState()`** (SEP-2352) — so the - callback leg can verify it exchanges the code at the AS the redirect targeted; - without it the SDK `console.warn`s once per callback - ([details](#credentials-bound-to-the-issuing-authorization-server-sep-2352)). - **Pass `expectedIssuer`** when constructing static-credential providers (SEP-2352). - **Keep refresh tokens confidential in storage** (SEP-2207) — OS keychain or encrypted-at-rest store, never `localStorage` / plain files / logs. @@ -1476,9 +1268,7 @@ The SDK enforces every authorization MUST that lands in SDK code. The following `IssuerMismatchError` is thrown, do not render the callback's `error*` values. - **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; and either keep - `onInsufficientScope: 'reauthorize'` (the default) or handle - `InsufficientScopeError` yourself. + per request; per-session backoff is host state. - **Resource-server operators: do not advertise `offline_access`** in `WWW-Authenticate` `scope` or PRM `scopes_supported` (SEP-2207). @@ -1504,10 +1294,6 @@ import { CallToolResultSchema } from '@modelcontextprotocol/core'; if (CallToolResultSchema.safeParse(value).success) { ... } ``` -A namespace import — `import * as t from '…/types.js'` + -`t.CallToolResultSchema.parse(…)` — can't be split per-symbol; the codemod flags it -action-required. Re-import the schema from `@modelcontextprotocol/core` by hand. - `@modelcontextprotocol/core` is the canonical home for the spec's Zod schema constants (and the OAuth/OpenID metadata schemas). It is runtime-neutral (its only dependency is `zod`) and is **not** required by `client` / `server` — install it only if you import the @@ -1570,19 +1356,15 @@ 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. - - - -(The published `2.0.0-alpha.3` typings predate this — the typed maps there still carry the `tasks/*` +(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 +**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` @@ -1720,7 +1502,8 @@ rewrite required unless noted. - **`connect()` skips the `initialize` handshake when the transport already exposes a `sessionId`** — it assumes it is reconnecting to an existing session (unchanged from - v1.x, where the same guard has existed since 1.10.0). A custom or test transport that sets `sessionId` at construction + v1.x, where the same guard has existed since 1.10.0; recorded here because the + far-away symptom keeps surprising migrators). A custom or test transport that sets `sessionId` at construction silently skips initialization: `getServerCapabilities()` stays `undefined` and the list verbs return empty results. Expose `sessionId` only after the first request has been sent. @@ -1737,9 +1520,11 @@ rewrite required unless noted. `ProtocolOptions.supportedProtocolVersions` pins the legacy `initialize` handshake: the **first** pre-2026 entry in the list is offered (list order is preference order), a counter-offer is accepted only if it is one of the list's pre-2026 entries, and a - list with no pre-2026 entry makes the handshake throw. The same list also shapes the - 2026-07-28 probing modes (`'auto'` / `{ pin }`) — see - [support-2026-07-28.md › Client side: `versionNegotiation`](./support-2026-07-28.md#client-side-versionnegotiation). + list with no pre-2026 entry makes the handshake throw. Under + `versionNegotiation: 'auto'` the modern probe candidates are the list's modern + entries when it has any (otherwise the SDK's default modern set); a `{ pin }` is + honored as given and is not checked against the list (see + [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 @@ -1792,30 +1577,20 @@ rewrite required unless noted. `cachePartition`, `defaultCacheTtlMs`. `ResponseCacheStore` gained `delete(key)`; `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512). -#### Streamable HTTP: resumability requires protocol version `>= 2025-11-25` +#### Server (Streamable HTTP transport) -Resumability behavior (SSE priming events, `closeSSE` / `closeStandaloneSSE` -callbacks) is only enabled for protocol versions in the transport's supported-versions -list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` -request body no longer enable it. +- Resumability behavior (SSE priming events, `closeSSE` / `closeStandaloneSSE` + callbacks) is only enabled for protocol versions in the transport's supported-versions + list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` + request body no longer enable it. +- Session-ID mismatch still responds `404` with JSON-RPC `-32001` (`Session not found`), + unchanged from v1. This `-32001` is an SDK convention, not spec-assigned; client code + should key off the HTTP `404` status, not `-32001`. -#### SDK-convention JSON-RPC codes in HTTP error bodies - -Unchanged from v1 — key off the HTTP status, not the body code; neither code is -spec-assigned: - -| HTTP response | JSON-RPC code in body | -| ----------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| `400` to a request that requires a session but omits the `Mcp-Session-Id` header (`Bad Request: Mcp-Session-Id header is required`) | `-32000` | -| `404` on a session-ID mismatch (`Session not found`) | `-32001` | - -#### `getClientCapabilities()` / `getClientVersion()` / `getNegotiatedProtocolVersion()` are `@deprecated` - -`Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()` -are `@deprecated` but functional. On 2026-07-28 requests, prefer `ctx.mcpReq.envelope`. - -#### `createMcp*App()` validates the `Origin` header by default +#### Server (deprecated accessors and app-factory Origin validation) +- `Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()` + are `@deprecated` but functional. On 2026-07-28 requests, prefer `ctx.mcpReq.envelope`. - `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default. Browser-served clients on a non-localhost origin need `allowedOrigins: [...]` (replaces the default @@ -1828,27 +1603,22 @@ are `@deprecated` but functional. On 2026-07-28 requests, prefer `ctx.mcpReq.env `@modelcontextprotocol/server`; `@modelcontextprotocol/node` ships `hostHeaderValidation` / `originValidation` request guards for plain `node:http`. -#### McpServer installs capability handlers eagerly: `tools/list` answers `{tools:[]}` (not `-32601`); `listChanged: true` advertised by default +#### Server (McpServer / Streamable HTTP behavior) - **Eager capability-handler install.** `McpServer` now installs list/read/call handlers for every primitive capability declared in `ServerOptions.capabilities`, even with 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 +not found`. Low-level `Server` users remain responsible for registering handlers for 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 + 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. - -#### `eventStore` store-first semantics (Streamable HTTP) - - **`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 @@ -1857,9 +1627,6 @@ not found`. `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` key - - **`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 @@ -1904,7 +1671,7 @@ requests, the per-request `_meta.logLevel` envelope key is the filter — see hang instead of resolving; send through the typed surface (`client.ping()`, `client.request()`) instead. -### Experimental tasks interception removed +#### Experimental tasks interception removed The 2025-11 task side-channel through `Protocol` is removed (was always `@experimental`). No mechanical migration; remove usages. Gone: `ProtocolOptions.tasks`, @@ -1917,13 +1684,24 @@ types they yielded, `registerToolTask`, `ToolTaskHandler`, `TaskRequestHandler`, `BaseQueuedMessage` / `Queued*`, `CreateTaskServerContext`, `TaskServerContext`, `TaskToolExecution`, `TaskStore`, `InMemoryTaskStore`, `CreateTaskOptions`, `isTerminal`, and the `new McpServer(info, { taskStore, taskMessageQueue })` constructor option keys -(the codemod emits an action-required diagnostic at each removed accessor, constructor -option key, and `setRequestHandler(GetTask…Schema, …)` registration — remove the -usage). +(the codemod emits an action-required diagnostic at each — remove the option). The task **wire types** remain importable as `@deprecated` vocabulary for 2025-11-25 interop — see [support-2026-07-28.md](./support-2026-07-28.md#tasks-deprecated-wire-vocabulary). +#### Specification clarifications adopted (no SDK behavior change) + +The 2026-07-28 specification revision includes a number of documentation-only +clarifications recorded here so an audit of the revision's changelog against this guide +is complete; nothing in this list requires code changes: per-operation timeout guidance +removal (`RequestOptions.timeout` / `DEFAULT_REQUEST_TIMEOUT_MSEC` unchanged); stdio +shutdown wording; transports-as-bindings reframe; `resources/read` wording (the +`file://` path-sanitization MUST is server-author guidance — your handler must reject +traversal / symlink escapes itself); `PromptMessage` resource links (already in +`ContentBlock`); completion `ref/resource` URI templates; pagination empty-string +cursors (already passed through verbatim); sampling host-requirement docs; elicitation +statefulness wording; cosmetic schema/JSDoc sweeps. + --- ## Enhancements @@ -1938,11 +1716,15 @@ the named class from the explicit subpath (`@modelcontextprotocol/{client,server}/validators/ajv` or `…/cf-worker`) — importing from a subpath means the corresponding peer dep must be in your `package.json`. +### `Client.connect(transport, { prior })` — zero-round-trip connect + +Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to +every worker as `client.connect(transport, { prior })` — 2026-07-28+ only. New exported +type `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`). + ### Serving the 2026-07-28 revision -`createMcpHandler`, `serveStdio`, `versionNegotiation`, -`Client.connect(transport, { prior })` / `ConnectOptions` (zero-round-trip connect), -multi-round-trip requests +`createMcpHandler`, `serveStdio`, `versionNegotiation`, multi-round-trip requests (`requestState`), client cancellation via stream-close, `subscriptions/listen`, `Mcp-Param-*` headers, and per-era wire codecs are covered in **[support-2026-07-28.md](./support-2026-07-28.md)** — they are net-new in v2, not v1→v2 @@ -1952,9 +1734,7 @@ breaks. ## Unchanged APIs -The following carry over from v1 to v2. Most need only an import-path update; where an -entry has a residual difference (a renamed class, a dropped or added parameter), it is -called out inline after the em-dash with a link to its section. +The following are unchanged between v1 and v2 (only the import path changed): - `Client` constructor and `connect`, `close`, and the typed verbs (`listTools`, `listPrompts`, `listResources`, `readResource`, …) — note `callTool()` and `request()` @@ -1966,11 +1746,11 @@ called out inline after the em-dash with a link to its section. handler context. - The server Streamable HTTP transports' **constructor options** (`sessionIdGenerator`, `onsessioninitialized`, `onsessionclosed`, `enableJsonResponse`, `eventStore`, - `retryInterval`) and the `handleRequest` surface — only the class names and imports + `retryInterval`) and the `handleRequest` surface — only the class name and import moved: `StreamableHTTPServerTransport` is now `NodeStreamableHTTPServerTransport` - from `@modelcontextprotocol/node`, and - `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/server` - exposes the same options ([decision rule](#imports--transports)). The + 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)). @@ -2006,49 +1786,6 @@ called out inline after the em-dash with a link to its section. > constants are **not** part of the unchanged surface — they moved to > `@modelcontextprotocol/core` ([Types & schemas](#types--schemas)). -### Specification clarifications adopted (no SDK behavior change) - -The 2026-07-28 revision's remaining changelog entries are documentation-only -clarifications; none changes SDK behavior or requires code changes: per-operation -timeout guidance -removal (`RequestOptions.timeout` / `DEFAULT_REQUEST_TIMEOUT_MSEC` unchanged); stdio -shutdown wording; transports-as-bindings reframe; `resources/read` wording (the -`file://` path-sanitization MUST is server-author guidance — your handler must reject -traversal / symlink escapes itself); `PromptMessage` resource links (already in -`ContentBlock`); completion `ref/resource` URI templates; pagination empty-string -cursors (already passed through verbatim); sampling host-requirement docs; elicitation -statefulness wording; cosmetic schema/JSDoc sweeps. - ---- - -## Verifying you're done - -Each check links to the section that explains a hit. - -1. `grep -rn '@mcp-codemod-error' .` returns nothing — every marker the codemod left is - resolved ([Manual changes](#manual-changes)). -2. `grep -rn '@modelcontextprotocol/sdk' --exclude-dir=node_modules .` returns only - hits you deliberately kept — a v1 boundary around an unmigrated dependency, or the - transition window of a [staged migration](#migrating-in-stages-large-codebases). - Everything else must go: sources, manifests, and the repo-local tooling that - encodes the literal name (dependency-pin lints, version allowlists, CI checks, - scripts), which the codemod never touches - ([Packaging & runtime](#packaging--runtime)). While here, drop surviving v1-era - double casts (`as unknown as Transport` and kin). -3. `tsc --noEmit` (or your build) is clean. -4. The silent breaks — wrong at runtime with no compile error. Grep each; the linked - section says what to change: - - `grep -rn '\.code ===' .` — an HTTP status compared against `.code` - ([Errors](#errors): it moved to `.status` on `SdkHttpError`). - - `grep -rn -e '=== -32000' -e '=== -32001' .` — raw numeric SDK codes - ([Errors](#errors): both are string `SdkErrorCode` values now). - - `grep -rn -e "'McpError'" -e "'StreamableHTTPError'" -e "'MCP error " .` — - class-name and message-text matchers ([Errors](#errors)). - - Files that receive the SDK by injection (no `@modelcontextprotocol` import) - still using v1 idioms — the codemod never visits them - ([Files the codemod never sees](#files-the-codemod-never-sees-injected-sdk-surfaces)). -5. Your tests pass. - --- ## Need help? From 231b79679c486a015eafeceed68e1f3cb1642bd1 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Tue, 30 Jun 2026 14:37:00 +0000 Subject: [PATCH 5/5] docs(migration): correctness and completeness fixes Completes the OAuthClientProvider conformance checklist with the two obligations the 2026-07-28 guide lists (discovery-state persistence and the insufficient-scope choice), fixes the unchanged-APIs lead to match its own entries, aligns the hoisted-monorepo guidance with the codemod's actual root rewrite, adds a short section for library authors that peer-depend on the SDK, and corrects two notes in the 2026-07-28 guide. --- docs/migration/support-2026-07-28.md | 4 ++-- docs/migration/upgrade-to-v2.md | 25 +++++++++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index f4b8a50dbf..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: @@ -523,7 +523,7 @@ 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 + 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. diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 75fdf277bf..f59ecb7dbe 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -196,9 +196,9 @@ local tooling do — when in doubt, use the section where the member previously `@modelcontextprotocol/sdk`. A member that never declared the v1 SDK and resolved it through the root can keep -root-level declarations (add the union of all members' v2 packages at the root — the -codemod's hoisting note lists the contributing members) or move to per-member -declarations; per-member is recommended, since the v2 package split makes each member's +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 @@ -350,6 +350,18 @@ against what is still a v1 `Server`, which then fails at runtime). Run the codem 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 @@ -1269,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). @@ -1734,7 +1750,8 @@ 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()`