docs(migration): close v1-to-v2 guide gaps found while migrating sample dependents (#2392)

This commit is contained in:
Felix Weinberger 2026-06-30 15:40:36 +01:00 • committed by GitHub
commit 14160f724b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 653 additions and 66 deletions

View file

@ -360,7 +360,7 @@ and the multi-round-trip retry fields (`inputResponses`, `requestState`).
- **High-level methods return the named public types** (`client.callTool()` →
`Promise<CallToolResult>`, etc.). Handler return positions are unaffected.
- **Reserved envelope keys and retry fields appear in no public params/result type.**
The `RequestMetaEnvelope` type and the four `*_META_KEY` constants stay exported.
The `RequestMetaEnvelope` type and the four envelope `*_META_KEY` constants stay exported.
The protocol layer enforces the same boundary at runtime:
@ -405,10 +405,10 @@ obtain client input (elicitation, sampling, roots) **in-band** by returning
`inputRequired(...)` from a `tools/call` / `prompts/get` / `resources/read` handler; the
client retries the original call with the responses.
| Handler serving 2026-07-28 requests | Mechanical fix |
| ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry |
| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` |
| Handler serving 2026-07-28 requests | Mechanical fix |
| ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry |
| `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` |
| handler shared across both eras | **no branch needed** — write the `inputRequired(...)` form once; the [legacy shim](#legacy-shim-for-input_required) serves it to 2025-era connections by issuing real server→client requests |
`inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from
@ -416,7 +416,11 @@ client retries the original call with the responses.
(`ctx.mcpReq.send` of server→client requests, `ctx.mcpReq.elicitInput`,
`ctx.mcpReq.requestSampling`, instance-level `createMessage()`/`elicitInput()`/`listRoots()`/`ping()`)
fail with a typed local error before anything reaches the wire; their behavior toward
2025-era requests is unchanged.
2025-era requests is unchanged. The same split applies to
`throw new UrlElicitationRequiredError(...)`: on 2025-era connections it is unchanged —
the throw still produces the `-32042` protocol error, not an `isError` result; on
2026-07-28 requests it fails with a clear error steering to
`inputRequired.elicitUrl(...)` rather than being converted silently.
`requestState` round-trips as an opaque, **untrusted** string — see
[Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate)
@ -484,11 +488,11 @@ driver's semantics exactly:
Knobs live at `ServerOptions.inputRequired`:
| Member | Default | Meaning |
| --- | --- | --- |
| `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow |
| `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply |
| `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern |
| Member | Default | Meaning |
| ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow |
| `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply |
| `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern |
Failures surface **per family**: `tools/call` failures (capability refusal, a failed
leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts
@ -519,8 +523,10 @@ phase state so they increase across re-entries — the token spans the whole flo
today's `elicitInput` in that configuration, which waits out its own 60s
default. Interactive tools need a streaming-capable session.
- The 2025-era `notifications/elicitation/complete` channel for URL-mode elicitation
is not bridged (upstream gap F8): URL-mode legs complete like any other elicitation
response.
is not bridged: URL-mode legs complete like any other elicitation
response. The sender API for that channel,
`Server.createElicitationCompletionNotifier()`, is itself unchanged from v1 for
2025-era URL-mode elicitation — only the shim does not bridge it.
---
@ -607,7 +613,10 @@ Task methods are excluded from the typed method maps: `RequestMethod` / `Request
`notifications/tasks/status` entries, so the method-keyed overloads of `request()`,
`ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task
methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` (no
`| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. Where
`| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`.
(The published `2.0.0-alpha.3` typings predate this exclusion — there the typed maps
still carry the `tasks/*` entries and the `CreateTaskResult` unions; narrow with the
`isCallToolResult` guard until the next published alpha.) Where
task interop is genuinely required, use the explicit-schema custom-method form
(`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*`
requests → `-32601`.

View file

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