--- title: Upgrading from v1.x to v2 name: migrate-v1-to-v2 description: Migrate MCP TypeScript SDK code from v1 (@modelcontextprotocol/sdk) to v2 (@modelcontextprotocol/core, /client, /server). Use when a user asks to migrate, upgrade, or port their MCP TypeScript code from v1 to v2. --- # Upgrading from v1.x to v2 This guide covers upgrading from `@modelcontextprotocol/sdk` (v1.x) to the v2 packages. It is written for shell-capable agents and humans alike: run the codemod first, then 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. ## 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()`. 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. 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. 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.** ## Contents - [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) - [Packaging & runtime](#packaging--runtime) - [Imports & transports](#imports--transports) - [Low-level protocol & handler context (`ctx`)](#low-level-protocol--handler-context-ctx) - [Server registration API](#server-registration-api) - [HTTP & headers](#http--headers) - [Errors](#errors) - [Auth](#auth) - [Types & schemas](#types--schemas) - [Behavioral changes](#behavioral-changes) - [Enhancements](#enhancements) - [Unchanged APIs](#unchanged-apis) - [Need help?](#need-help) --- ## What the codemod handles The codemod ([`@modelcontextprotocol/codemod`](../../packages/codemod/README.md)) mechanically applies every rename whose mapping is fixed. The mappings are the **source of truth** — they live in the codemod package and are not reproduced here: | Mapping | Source file | | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `@modelcontextprotocol/sdk/...` import paths → v2 packages | [`mappings/importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) | | Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) | | `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: - Updates `package.json` dependencies (`@modelcontextprotocol/sdk` → the v2 packages your imports actually use). - Rewrites `.tool()` / `.prompt()` / `.resource()` to `registerTool` / `registerPrompt` / `registerResource` and wraps `inputSchema` / `outputSchema` / `argsSchema` / `uriSchema` raw Zod shapes with `z.object()`. - 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 `@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`. - 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 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`. - 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. - Drops `@modelcontextprotocol/sdk/server/zod-compat.js` imports. ## 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) - **`new Headers()` / `.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) - **`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) - **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 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` | `@modelcontextprotocol/client` and `@modelcontextprotocol/server` both re-export shared types from `@modelcontextprotocol/core-internal`, so import types and error classes from whichever package you already depend on. `@modelcontextprotocol/core-internal` is `private: true` and is not published — **do not import from it directly.** `@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. 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()`. ### Imports & transports The codemod rewrites every `@modelcontextprotocol/sdk/...` import path via [`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts). A few transports need a decision the codemod can't make: - **`StreamableHTTPServerTransport` → which runtime?** The codemod renames it to `NodeStreamableHTTPServerTransport` from `@modelcontextprotocol/node`. If you deploy to a web-standard runtime (Cloudflare Workers, Deno, Bun), use `WebStandardStreamableHTTPServerTransport` from `@modelcontextprotocol/server` instead. **Decision rule:** if your handler receives a Node `IncomingMessage` / `ServerResponse`, use `@modelcontextprotocol/node`; if it receives a web-standard `Request` and returns a `Response`, use `@modelcontextprotocol/server`. - **stdio transports moved to a `./stdio` subpath.** Import `StdioClientTransport`, `getDefaultEnvironment`, `DEFAULT_INHERITED_ENV_VARS`, and `StdioServerParameters` from `@modelcontextprotocol/client/stdio`; import `StdioServerTransport` from `@modelcontextprotocol/server/stdio`. The package root barrels do **not** export these (the root entries are runtime-neutral so browser/Workers bundlers can consume them). The stdio utilities `ReadBuffer`, `serializeMessage`, `deserializeMessage` stay in the root barrel. - **Zod `*Schema` constants → `@modelcontextprotocol/core`.** A mixed `import { CallToolResult, CallToolResultSchema } from '…/types.js'` is split by the codemod — see [Types & schemas](#types--schemas). ```typescript // v1 import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; // v2 import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; ``` - **`SSEServerTransport`** is removed. Migrate to Streamable HTTP. A frozen v1 copy is available from `@modelcontextprotocol/server-legacy/sse` as a temporary bridge. - **`WebSocketClientTransport`** is removed (WebSocket is not a spec transport). Use `StreamableHTTPClientTransport` for remote servers or `StdioClientTransport` for 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): ```typescript // v1 import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; // v2 import { InMemoryTransport } from '@modelcontextprotocol/server'; // or /client ``` - **`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). - **Server auth split.** Resource Server helpers (`requireBearerAuth`, `mcpAuthMetadataRouter`, `getOAuthProtectedResourceMetadataUrl`, `OAuthTokenVerifier`) → `@modelcontextprotocol/express`. Authorization Server helpers (`mcpAuthRouter`, `OAuthServerProvider`, `ProxyOAuthServerProvider`, `allowedMethods`, `authenticateClient`, `metadataHandler`, `createOAuthMetadata`, `authorizationHandler` / `tokenHandler` / `revocationHandler` / `clientRegistrationHandler`) → `@modelcontextprotocol/server-legacy/auth` (deprecated, frozen v1 copy); migrate AS to a dedicated IdP/OAuth library. `AuthInfo` is now re-exported by `@modelcontextprotocol/client` and `@modelcontextprotocol/server`. The codemod's [`importMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) routes every `…/server/auth/**` deep path (including `…/server/auth/middleware/{bearerAuth,allowedMethods,clientAuth}.js`, `…/server/auth/handlers/*.js`, `…/server/auth/providers/proxyProvider.js`) to `@modelcontextprotocol/server-legacy/auth`, and `…/server/express.js` / `…/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. ### Low-level protocol & handler context (`ctx`) The second parameter to every request handler — previously the flat `RequestHandlerExtra` object named `extra` — is now a structured **context** object named `ctx`. This is the `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): | 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) | #### 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. - **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. JSDoc/types only — wire behavior is unchanged 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. The codemod rewrites every spec-method registration via [`schemaToMethodMap.ts`](../../packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts). ```typescript // v1 server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { ... }); // v2 server.setRequestHandler('tools/call', async (request, ctx) => { ... }); ``` **Custom (non-spec) methods** use the 3-arg form `(method, { params, result? }, handler)` where `params` and `result` are any [Standard Schema](https://standardschema.dev). The handler receives the parsed `params` directly (not the full request envelope); `_meta` is at `ctx.mcpReq._meta`. The 3-arg notification handler is `(params, notification) => void`. ```typescript server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async (params, ctx) => { ... }); ``` #### `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 method name. The codemod drops it from `client.request()` and `client.callTool()`; drop it from `ctx.mcpReq.send()` by hand. ```typescript // v1 import { CreateMessageResultSchema } from '@modelcontextprotocol/sdk/types.js'; server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { const r = await extra.sendRequest({ method: 'sampling/createMessage', params: { ... } }, CreateMessageResultSchema); return { content: [{ type: 'text', text: 'done' }] }; }); // v2 server.setRequestHandler('tools/call', async (request, ctx) => { const r = await ctx.mcpReq.send({ method: 'sampling/createMessage', params: { ... } }); return { content: [{ type: 'text', text: 'done' }] }; }); ``` For **custom (non-spec)** methods, keep the result-schema argument: `await client.request({ method: 'acme/search', params }, SearchResult)` — only drop the schema when calling a spec method. **Forwarding arbitrary methods (gateways / proxies).** Dropping the schema changes semantics, not just the signature: a schema-less spec-method call now **enforces** the spec result schema (a non-conforming upstream result is rejected locally with `SdkError(SdkErrorCode.InvalidResult)` and a conforming one is re-serialized in schema key order), and a schema-less call for a **non-spec** method throws a `TypeError` at the call site (`'…' is not a spec method; pass a result schema`). A relay that forwards `{ method, params }` it does not understand must keep passing an explicit result schema. The v1 idiom survives with an import-path change: ```typescript import { ResultSchema } from '@modelcontextprotocol/core'; const result = await upstream.request({ method, params }, ResultSchema); // v1-identical passthrough ``` For byte-exact forwarding (member order preserved), pass your own accept-anything Standard Schema instead. Check call sites whose `method` is **not a literal** — the codemod may have dropped the schema argument there; restore it. The return type is inferred from the method name via `ResultTypeMap` (e.g. `client.request({ method: 'tools/call', ... })` returns `Promise`). ### Server registration API The deprecated variadic `.tool()`, `.prompt()`, `.resource()` are removed. Use `registerTool` / `registerPrompt` / `registerResource` with an explicit config object. The codemod converts the call shape and wraps `inputSchema` / `outputSchema` / `argsSchema` / `uriSchema` raw shapes. ```typescript // v1 — raw shape, variadic server.tool('greet', 'Greet a user', { name: z.string() }, async ({ name }) => { return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; }); // v2 — config object, Standard Schema server.registerTool('greet', { description: 'Greet a user', inputSchema: z.object({ name: z.string() }) }, async ({ name }) => { return { content: [{ type: 'text', text: `Hello, ${name}!` }] }; }); ``` `registerResource` requires a `metadata` argument — pass `{}` if you have none. #### Standard Schema objects (raw shapes deprecated) v2 expects schema objects implementing the [Standard Schema spec](https://standardschema.dev/) for `inputSchema`, `outputSchema`, and `argsSchema`. Raw `{ field: z.string() }` shapes are still **accepted via `@deprecated` overloads** on `registerTool`/`registerPrompt` (auto-wrapped with `z.object()`), and `completable()` accepts any `StandardSchemaV1`; prefer wrapping explicitly. Zod v4, ArkType, and Valibot all implement the spec. **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 runtime, when the first registration throws — under a spawning harness that surfaces as an opaque child exit two hops from the cause. A Zod v3 schema hard-errors with a pointer at `fromJsonSchema()`. Zod **≥4.2.0** self-converts via `~standard.jsonSchema` — the supported path. Zod **4.0–4.1** lacks it, so the SDK falls back to its bundled Zod's `z.toJSONSchema()` with a one-time `[mcp-sdk]` console warning; and because `.describe()` field descriptions live in the _authoring_ Zod's registry, the fallback **drops them** from the generated JSON Schema. Fix ladder: (1) upgrade to `zod ^4.2.0`; (2) if you must pin an older or separate Zod, attach a `~standard.jsonSchema` provider backed by _your_ Zod's `toJSONSchema` so conversion (and descriptions) run through your instance; (3) author the schema as raw JSON Schema via `fromJsonSchema()`. (Raw shapes are wrapped with the SDK's **bundled** Zod — built with a foreign Zod they fail at registration or at the first `tools/list`; pass `z.object()`-wrapped schemas from your own Zod instead.) 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({}) ``` 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 also removed — and the codemod does **not** rewrite its import (expect `TS2307`). If you build `Tool` / `Prompt` advertisements yourself, use your schema library's native conversion: zod 4's `z.toJSONSchema(schema, { io: 'input', target: 'draft-2020-12' })` 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`. ```typescript // v1 const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers: { Authorization: 'Bearer token' } } }); const sessionId = extra.requestInfo?.headers['mcp-session-id']; // v2 const transport = new StreamableHTTPClientTransport(url, { requestInit: { headers: new 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'); ``` `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 proxy/gateway routing. `hostHeaderValidation()` and `localhostHostValidation()` moved to `@modelcontextprotocol/express`. The `(allowedHostnames: string[])` signature is the same as every released v1.x — only the import path changes. Framework-agnostic helpers (`validateHostHeader`, `localhostAllowedHostnames`, `hostHeaderValidationResponse`) are in `@modelcontextprotocol/server`. ### Errors The SDK now distinguishes three error kinds: 1. **`ProtocolError`** (renamed from `McpError`) — protocol errors that cross the wire as JSON-RPC error responses. Uses `ProtocolErrorCode` (renamed from `ErrorCode`). 2. **`SdkError`** — local SDK errors that never cross the wire. Uses `SdkErrorCode`. 3. **`SdkHttpError`** (extends `SdkError`) — HTTP transport errors with typed `.status` and `.statusText`. The codemod renames `McpError` → `ProtocolError`, `ErrorCode` → `ProtocolErrorCode` (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: | Scenario | v1 | v2 | | ------------------------------------------------ | ----------------------------------------- | ------------------------------------------------------------------ | | Request timeout | `McpError` + `ErrorCode.RequestTimeout` | `SdkError` + `SdkErrorCode.RequestTimeout` | | Connection closed | `McpError` + `ErrorCode.ConnectionClosed` | `SdkError` + `SdkErrorCode.ConnectionClosed` | | Capability not supported | `new Error(...)` | `SdkError` + `SdkErrorCode.CapabilityNotSupported` | | Not connected | `new Error('Not connected')` | `SdkError` + `SdkErrorCode.NotConnected` | | Response result fails schema | raw `ZodError` | `SdkError` + `SdkErrorCode.InvalidResult` | | Invalid params (server response) | `McpError` + `ErrorCode.InvalidParams` | `ProtocolError` + `ProtocolErrorCode.InvalidParams` | | HTTP transport error | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttp*` | | Failed to open SSE stream | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToOpenStream` | | 401 after re-auth (circuit break) | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | | `SSEClientTransport.send()` 401 after re-auth | `UnauthorizedError` | `SdkHttpError` + `SdkErrorCode.ClientHttpAuthentication` | | 403 `insufficient_scope` after step-up retry cap | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpForbidden` | | Unexpected content type | `StreamableHTTPError` | `SdkError` + `SdkErrorCode.ClientHttpUnexpectedContent` | | Session termination failed | `StreamableHTTPError` | `SdkHttpError` + `SdkErrorCode.ClientHttpFailedToTerminateSession` | ```typescript // v1 if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) { ... } if (error instanceof StreamableHTTPError) { console.log('HTTP status:', error.code); } // v2 import { SdkError, SdkHttpError, SdkErrorCode, ProtocolError, ProtocolErrorCode } from '@modelcontextprotocol/client'; if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) { ... } if (error instanceof SdkHttpError) { console.log('HTTP status:', error.status, error.statusText); switch (error.code) { case SdkErrorCode.ClientHttpAuthentication: case SdkErrorCode.ClientHttpForbidden: case SdkErrorCode.ClientHttpFailedToOpenStream: case SdkErrorCode.ClientHttpNotImplemented: break; } } ``` `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 `SdkErrorCode` string). The codemod renames `instanceof StreamableHTTPError`, but a status read that never named the class is invisible to it. Watch the inconsistency: `SseError` still carries its HTTP status on numeric `.code`, so one duck-typed `.code === 401` that caught both transports in v1 now catches only SSE. ```typescript // v1 — one duck-typed check caught both Streamable HTTP and SSE if ('code' in e && (e.code === 401 || e.code === 403)) reauth(); // v2 — match each explicitly if (e instanceof SdkHttpError && (e.status === 401 || e.status === 403)) reauth(); // Streamable HTTP if (e instanceof SseError && (e.code === 401 || e.code === 403)) reauth(); // SSE still uses .code ``` Silent at runtime (no compile error) — grep for `.code ===` status comparisons. **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 it usually targeted are now **string** `SdkErrorCode` values: | v1 numeric | v2 | | --------------------------- | -------------------------------------------- | | `-32000` (ConnectionClosed) | `SdkError` + `SdkErrorCode.ConnectionClosed` | | `-32001` (RequestTimeout) | `SdkError` + `SdkErrorCode.RequestTimeout` | 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`. **Dual-role processes: `instanceof` does not cross the packages.** `@modelcontextprotocol/client` and `@modelcontextprotocol/server` each bundle their own copy of these error classes, so in a process that uses both — a gateway, a host, an in-process test — an error constructed by one package fails `instanceof` against the class imported from the other, silently. When an error may originate from the other 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. **Constructing the error (test stubs, custom transports).** v1 `new StreamableHTTPError(code, message)` becomes `new SdkHttpError(code, message, data)`: the first argument is now a `SdkErrorCode` string (pick the branch from the scenario table above) and the HTTP status moves into the third argument — `new SdkHttpError(SdkErrorCode.ClientHttpNotImplemented, 'Not Found', { status: 404, statusText: 'Not Found' })`. v1's implicit `Streamable HTTP error: ` message prefix is gone; pass the full message you want. #### `SdkErrorCode` enum (complete) | Code | When thrown | | ------------------------------------- | -------------------------------------------------------------------------- | | `NotConnected` | Transport is not connected | | `AlreadyConnected` | Transport is already connected | | `NotInitialized` | Protocol is not initialized | | `CapabilityNotSupported` | Required capability is not supported | | `RequestTimeout` | Request timed out waiting for response | | `ConnectionClosed` | Connection was closed | | `SendFailed` | Failed to send message | | `InvalidResult` | Response result failed local schema validation | | `UnsupportedResultType` | A 2026-era response carried an unrecognized `resultType` | | `InputRequiredRoundsExceeded` | Multi-round-trip auto-fulfilment hit `maxRounds` | | `ListPaginationExceeded` | No-arg `list*()` aggregate walk hit `listMaxPages` | | `MethodNotSupportedByProtocolVersion` | Outbound spec method does not exist on the negotiated protocol version | | `EraNegotiationFailed` | `connect()` could not negotiate a protocol era (probe failed / no overlap) | | `ClientHttpNotImplemented` | HTTP POST request failed | | `ClientHttpAuthentication` | Server returned 401 after re-authentication | | `ClientHttpForbidden` | Server returned 403 `insufficient_scope` after step-up retry cap | | `ClientHttpUnexpectedContent` | Unexpected content type in HTTP response | | `ClientHttpFailedToOpenStream` | Failed to open SSE stream | | `ClientHttpFailedToTerminateSession` | Failed to terminate session | #### 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 (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 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. ### Auth #### OAuth error consolidation The individual OAuth error classes are replaced with a single `OAuthError` + `OAuthErrorCode`. The `OAUTH_ERRORS` constant is removed. The codemod does not rewrite `instanceof` checks on these classes — switch on `error.code` instead. | v1 class | v2 equivalent | | ------------------------------ | ------------------------------------------------------- | | `InvalidRequestError` | `OAuthError` + `OAuthErrorCode.InvalidRequest` | | `InvalidClientError` | `OAuthError` + `OAuthErrorCode.InvalidClient` | | `InvalidGrantError` | `OAuthError` + `OAuthErrorCode.InvalidGrant` | | `UnauthorizedClientError` | `OAuthError` + `OAuthErrorCode.UnauthorizedClient` | | `UnsupportedGrantTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedGrantType` | | `InvalidScopeError` | `OAuthError` + `OAuthErrorCode.InvalidScope` | | `AccessDeniedError` | `OAuthError` + `OAuthErrorCode.AccessDenied` | | `ServerError` | `OAuthError` + `OAuthErrorCode.ServerError` | | `TemporarilyUnavailableError` | `OAuthError` + `OAuthErrorCode.TemporarilyUnavailable` | | `UnsupportedResponseTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedResponseType` | | `UnsupportedTokenTypeError` | `OAuthError` + `OAuthErrorCode.UnsupportedTokenType` | | `InvalidTokenError` | `OAuthError` + `OAuthErrorCode.InvalidToken` | | `MethodNotAllowedError` | `OAuthError` + `OAuthErrorCode.MethodNotAllowed` | | `TooManyRequestsError` | `OAuthError` + `OAuthErrorCode.TooManyRequests` | | `InvalidClientMetadataError` | `OAuthError` + `OAuthErrorCode.InvalidClientMetadata` | | `InsufficientScopeError` | `OAuthError` + `OAuthErrorCode.InsufficientScope` ¹ | | `InvalidTargetError` | `OAuthError` + `OAuthErrorCode.InvalidTarget` | | `CustomOAuthError` | `new OAuthError(customCode, message)` | ¹ Unrelated to the new transport-layer `InsufficientScopeError` (SEP-2350) exported from `@modelcontextprotocol/client`, which carries an RFC 6750 challenge from the resource server and extends `OAuthClientFlowError`, **not** `OAuthError`. Do not rewrite that one. ```typescript // v1 if (error instanceof InvalidClientError) { ... } // v2 import { OAuthError, OAuthErrorCode } from '@modelcontextprotocol/client'; if (error instanceof OAuthError && error.code === OAuthErrorCode.InvalidClient) { ... } ``` ⚠ **Token verifiers must throw the v2 `OAuthError`.** `requireBearerAuth` (from `@modelcontextprotocol/express`) classifies the error your `OAuthTokenVerifier.verifyAccessToken()` throws: a v2 `OAuthError(OAuthErrorCode.InvalidToken)` produces the proper `401` + `WWW-Authenticate` challenge, while the legacy `InvalidTokenError` (from `server-legacy`) or a generic `Error` falls through as unexpected — **invalid tokens 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`. **`AuthProvider`** is a new minimal interface — `{ token(): Promise; onUnauthorized?(ctx): Promise }` — for static-token / non-OAuth bearer auth. Transports call `token()` before every request and `onUnauthorized()` on 401 (then retry once). Existing `OAuthClientProvider` implementations need no changes — transports adapt them internally via the new `adaptOAuthProvider()` export. Also exported: `isOAuthClientProvider()` (type guard) and `handleOAuthUnauthorized()` (the standard OAuth `onUnauthorized` behavior, for composing your own adapter). #### OAuth client flow — behavioral changes - **Resolved scope passed to DCR (SEP-835).** `auth()` now computes the resolved scope once (WWW-Authenticate → PRM `scopes_supported` → `clientMetadata.scope`) and passes it to **both** the DCR POST body and the authorization request. `registerClient()` gained an optional `scope` parameter that overrides `clientMetadata.scope` in the registration body. - **OAuth error on HTTP 200.** `exchangeAuthorization()` / `refreshAuthorization()` now throw `OAuthError` when the AS returns HTTP 200 with a JSON `{error: ...}` body (e.g. GitHub). v1 surfaced this as a Zod parse failure on the tokens schema. - **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. #### OAuth client flow errors (new) The OAuth client flow now throws dedicated classes from `@modelcontextprotocol/client` (all extend `OAuthClientFlowError`, **not** `OAuthError` — `auth()`'s `OAuthError` retry path will not catch them): | Throw site | v2 class | | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | | `registerClient()` rejected by AS (⚠ `@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) | `RegistrationRejectedError` (`status`, `body`, `submittedMetadata`) | | Token-exchange / refresh / `fetchToken` / Cross-App grant on a non-`https:` token endpoint | `InsecureTokenEndpointError` (`tokenEndpoint`) | | RFC 9207 `iss` mismatch / RFC 8414 §3.3 issuer-echo mismatch | `IssuerMismatchError` (`kind`, `expected`, `received`) | | 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`) | #### `auth()` options are now `AuthOptions` The inline options object on `auth()` is now the named `AuthOptions` type. New fields: `iss?: string` (the form-urldecoded `iss` from the authorization callback — pass it alongside `authorizationCode` for RFC 9207 validation), `skipIssuerMetadataValidation?: boolean` (security-weakening opt-out of the RFC 8414 §3.3 issuer-echo check), and `forceReauthorization?: boolean` (skip the refresh-token branch — set by the transport's step-up path; hosts driving step-up themselves set it under the same condition). #### Authorization-server mix-up defense (RFC 9207 / RFC 8414 §3.3) — action required `transport.finishAuth()` and `auth()` now validate `iss` from the authorization callback against the issuer recorded from validated AS metadata. A mismatched `iss` throws `IssuerMismatchError` before the code is exchanged regardless of advertised support; a **missing** `iss` throws only when the AS advertised `authorization_response_iss_parameter_supported: true`. Pass the callback URL's `URLSearchParams` so the SDK can read `iss` alongside `code`. The SDK does **not** validate `state`; compare it yourself before calling `finishAuth`: ```typescript const params = new URL(callbackUrl).searchParams; if (params.get('state') !== expectedState) throw new Error('state mismatch'); await transport.finishAuth(params); // SDK reads `code` + `iss` ``` `transport.finishAuth(code, iss)` remains supported. Do **not** display `error` / `error_description` / `error_uri` from a callback that failed `iss` validation — those values are attacker-controlled in a mix-up attack. `discoverAuthorizationServerMetadata()` now rejects metadata whose `issuer` does not exactly match the URL it was fetched for (RFC 8414 §3.3). Set `skipIssuerMetadataValidation: true` only as a temporary workaround for a known-misconfigured AS. (`@modelcontextprotocol/server-legacy` AS implementers: `mcpAuthRouter()` now advertises `authorization_response_iss_parameter_supported: true` by default and the bundled authorize handler appends `iss` to every redirect issued via `res.redirect(...)` on the supplied `res`. If you emit `Location` another way, append `params.issuer` as `iss` yourself; if your callback is issued by an upstream AS you proxy to, set `authorizationResponseIssParameterSupported = false` so the metadata does not over-claim.) #### Dynamic Client Registration defaults (SEP-837, SEP-2207) `auth()` now resolves `provider.clientMetadata` once via `resolveClientMetadata()` and applies defaults to the DCR body: `grant_types` defaults to `['authorization_code', 'refresh_token']`; `application_type` is derived from `redirect_uris` (loopback / custom URI scheme → `'native'`, else `'web'`). A field you set explicitly is never overwritten. The `grant_types` default applies to the DCR body only — it does **not** drive the `offline_access` / `prompt=consent` augmentation on the authorize request; statically-registered and CIMD clients that want that augmentation must set `clientMetadata.grant_types` explicitly. Non-interactive providers (no `redirectUrl`) get no `grant_types` default. Direct `registerClient()` callers (⚠ `@deprecated` — see [§Deprecated in v2](#deprecated-in-v2-sep-2577)) wanting the same defaults pass `resolveClientMetadata(provider)` as `clientMetadata`. DCR rejection now throws `RegistrationRejectedError` (carrying `status`, `body`, `submittedMetadata`). #### Token endpoint must use TLS (SEP-2207) `exchangeAuthorization()`, `refreshAuthorization()`, `fetchToken()`, and the Cross-App Access helpers throw `InsecureTokenEndpointError` when the token endpoint is not `https:` (loopback `localhost` / `127.0.0.1` / `::1` exempt). `auth()` surfaces this on every path including refresh — switch any plain-`http:` AS on a non-loopback host to TLS; there is no opt-out. Storage confidentiality of `refresh_token` remains your `saveTokens()` implementation's responsibility. #### Scope step-up on `403 insufficient_scope` (SEP-2350) `StreamableHTTPClientTransport` accepts `onInsufficientScope: 'reauthorize' | 'throw'` (default `'reauthorize'`). On `'reauthorize'` the transport re-authorizes with the **union** of the previously-requested and challenged scope (`computeScopeUnion`); when that union strictly exceeds the current token's granted scope (`isStrictScopeSuperset`), the SDK bypasses the refresh-token branch and forces a fresh authorization request. On `'throw'` the transport raises `InsufficientScopeError` and does not re-authorize — set this for `client_credentials` / m2m clients where re-authorization can't widen scope, or to gate the consent prompt behind UX. Step-up retries are hard-capped per send (`maxStepUpRetries`, default 1). With a non-OAuth [`AuthProvider`](#authprovider--non-oauth-bearer-auth-and-the-widened-authprovider-option), a `403 insufficient_scope` now throws `InsufficientScopeError` instead of the previous `SdkHttpError(ClientHttpNotImplemented)`. The GET listen-stream open path applies the same handling as the POST send path. #### Credentials bound to the issuing authorization server (SEP-2352) `auth()` stamps an `issuer` field onto every value it passes to `saveTokens()` / `saveClientInformation()` and threads `{ issuer }` as the `ctx` argument to those methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer` names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. **Round-trip the stored object verbatim and you're protected** — single-slot storage works. The failure modes differ: a stamp naming a **different** AS reads back as `undefined` and the flow re-registers / re-authorizes. A **missing** stamp (a `saveTokens()` that rebuilds the object field-by-field and drops `issuer`, or pre-upgrade storage) is used **as-is** with a `[mcp-sdk]` console warning — SEP-2352 isolation is silently inactive for that read; `auth()` re-stamps on first use where the provider can persist it. If you see that warning repeatedly, your provider is not round-tripping the stored object. To hold credentials for several authorization servers at once, key your storage on `ctx.issuer` (treat **`ctx === undefined` as "return the most-recently-saved token set"** — the transport's per-request `Authorization: Bearer` read calls `tokens()` with no `ctx`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation` add an optional `issuer?: string` field on top of the wire types. `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are `@deprecated` (still written for back-compat, never read by the SDK). The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and `CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define `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`). #### Conformance obligations for `OAuthClientProvider` implementers The SDK enforces every authorization MUST that lands in SDK code. The following live in **your** implementation and the SDK structurally cannot enforce them: - **Round-trip the `issuer` stamp** on persisted credentials (SEP-2352). Persist the value verbatim from `saveTokens` / `saveClientInformation` and return it verbatim. - **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. - **Extract `iss` from the callback URL** and pass it to `finishAuth` (SEP-2468); when `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. - **Resource-server operators: do not advertise `offline_access`** in `WWW-Authenticate` `scope` or PRM `scopes_supported` (SEP-2207). ### Types & schemas #### Zod `*Schema` constants moved to `@modelcontextprotocol/core` The Zod schemas (`CallToolResultSchema`, `ListToolsResultSchema`, …) that v1 exported from `types.js` now live in a separate **`@modelcontextprotocol/core`** package. Neither `@modelcontextprotocol/client` nor `@modelcontextprotocol/server` re-exports them — both packages stay Zod-free in their public surface. The v1→v2 change is just an import-path swap — `.parse()` / `.safeParse()` keep working unchanged: ```typescript // v1 import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; if (CallToolResultSchema.safeParse(value).success) { ... } // v2 — same Zod schema, new package import { CallToolResultSchema } from '@modelcontextprotocol/core'; if (CallToolResultSchema.safeParse(value).success) { ... } ``` `@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 raw schemas directly. If you would rather keep your project Zod-free, the **`isSpecType` / `specTypeSchemas`** alternatives are exported from `@modelcontextprotocol/client` and `…/server`: ```typescript import { isSpecType, specTypeSchemas } from '@modelcontextprotocol/client'; if (isSpecType.CallToolResult(value)) { ... } const blocks = mixed.filter(isSpecType.ContentBlock); const result = specTypeSchemas.CallToolResult['~standard'].validate(value); ``` `isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync` (`validate()` is synchronous). `validate()` returns `{ value }` or `{ issues }` and never throws — unlike `.parse()` on the real schema; code that caught a `ZodError` should inspect `result.issues` (or keep `.parse()` on the schema imported from `@modelcontextprotocol/core`). The pre-existing `isCallToolResult(value)` guard still works. **`specTypeSchemas.X` is `StandardSchemaV1`, not `ZodType`.** Zod-specific composition — `.extend()`, `.pick()`, `.omit()`, `.merge()`, `.shape`, `.passthrough()`, `.parseAsync()` — does **not** compile on a `specTypeSchemas` entry; reach for the real Zod schema from `@modelcontextprotocol/core` when you need to derive a tolerant variant of a spec schema (e.g. `ListToolsResultSchema.extend({ tools: ToolSchema.omit({ outputSchema: true }).array() })`). The Zod-specific `AnySchema` / `SchemaOutput` types from `…/zod-compat.js` are removed — replace with `StandardSchemaV1` / `StandardSchemaV1.InferOutput` (the codemod's removal message says the same). 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. #### Removed type aliases | Removed | Replacement | | --------------------------------------------------------------- | --------------------------------------------------------------- | | `JSONRPCError` | `JSONRPCErrorResponse` | | `JSONRPCErrorSchema` | `JSONRPCErrorResponseSchema` | | `isJSONRPCError` | `isJSONRPCErrorResponse` | | `isJSONRPCResponse` (deprecated in v1) | `isJSONRPCResultResponse` ² | | `JSONRPCResponseSchema` (result-only in v1) | `JSONRPCResultResponseSchema` ² | | `JSONRPCResponse` (result-only in v1) | `JSONRPCResultResponse` ² | | `ResourceReference` / `ResourceReferenceSchema` | `ResourceTemplateReference` / `ResourceTemplateReferenceSchema` | | `IsomorphicHeaders` | Web Standard `Headers` | | `RequestHandlerExtra` | `ServerContext` / `ClientContext` / `BaseContext` | | `ResourceTemplate` (the spec wire **type** from `sdk/types.js`) | `ResourceTemplateType` ³ | ² v2 introduces **new** `isJSONRPCResponse` / `JSONRPCResponse` / `JSONRPCResponseSchema` with corrected semantics — they match **both** result and error responses (the schema is `z.union([JSONRPCResultResponseSchema, JSONRPCErrorResponseSchema])`). v1's symbols only matched results. To preserve v1 behavior, rename to `isJSONRPCResultResponse` / `JSONRPCResultResponse` / `JSONRPCResultResponseSchema` (the codemod does this). ³ The `ResourceTemplate` URI-template helper **class** (from `sdk/server/mcp.js`) is **unchanged** — keep `new ResourceTemplate(...)` as-is. Only the like-named spec wire type from `types.js` was renamed to `ResourceTemplateType` to resolve the v1 collision; the codemod scopes the rename to imports from `sdk/types.js` only. All other symbols from `@modelcontextprotocol/sdk/types.js` retain their original names — import the TypeScript types, error classes, enums, and type guards from `@modelcontextprotocol/client` or `@modelcontextprotocol/server`, and the Zod `*Schema` constants from `@modelcontextprotocol/core`. 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. #### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020` instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`. `CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to `unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is `!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now). External `$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile, surfaced per-tool on `callTool`). | v1 pattern | Mechanical fix | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `result.structuredContent.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | | `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | | relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | | draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | | `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`, etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward every era a non-object `structuredContent` with no `text` block of its own gets a `JSON.stringify(...)` `text` block auto-appended. See [support-2026-07-28.md › Per-era wire codecs](./support-2026-07-28.md#per-era-wire-codecs) for how the codec applies these per era. **Your advertised tool schemas change shape on the wire.** The same `registerTool` calls produce `tools/list` entries whose generated `inputSchema` differs from v1: JSON Schema 2020-12 idioms (zod 4 conversion), different `additionalProperties` handling (no `additionalProperties: false` by default; passthrough objects emit `"additionalProperties": {}` instead of `true`), and no `execution.taskSupport` member. Golden tests, transcript pins, and strict client-side validators of your advertised tool list need re-baselining — the new shapes are spec-conformant. ### Behavioral changes These are runtime-behavior changes that may affect tests and assertions; no source rewrite required unless noted. #### Error-shape changes (every era) - **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 rejected promise instead. - **The `MCP error : ` message prefix is gone.** v1 prefixed relayed JSON-RPC error messages (`MCP error -32602: …`); v2's `ProtocolError.message` carries the peer's message verbatim. Tests and log scrapers that matched the prefix or the numeric code in rendered text should match `error.code` instead. - **In-flight request handlers are aborted on transport close** — `ctx.mcpReq.signal` fires (v1 let them run to completion). `InMemoryTransport.close()` no longer double-fires `onclose` on the initiating side. - **`Protocol.request()` with an already-aborted signal** rejects with `SdkError(SdkErrorCode.RequestTimeout, reason)` instead of throwing the raw `signal.reason`, matching the in-flight-abort path. - **OAuth discovery (`discoverOAuthProtectedResourceMetadata` / `discoverOAuthMetadata`, transitively `auth()`) throws on fetch `TypeError`** (DNS failure, `ECONNREFUSED`, invalid URL) in Node and Cloudflare Workers instead of swallowing it as a CORS miss → `undefined`. The CORS-swallow remains browser-only. #### Client connection & dispatch - **`connect()` skips the `initialize` handshake when the transport already exposes a `sessionId`** — it assumes it is reconnecting to an existing session (v1 always initialized). 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. - **The typed verbs dispatch after async pre-work.** `Protocol.request()` itself still hands the frame to the transport before its first `await` (v1-compatible). The typed verbs on top of it — `callTool()` and the cacheable list verbs — perform async work first (header-mirroring scan, response-cache freshness, output-validator resolution), so an abort fired in the same tick can land before the frame is ever sent: the call rejects with `SdkError(RequestTimeout, reason)` and **no `notifications/cancelled` is emitted** (nothing was in flight). v1 sent the frame synchronously from these verbs. Once the frame is on the wire, aborting still sends `notifications/cancelled` before rejecting. - **Protocol-version pinning is a first-class option.** `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)). v1 had no public equivalent (`SUPPORTED_PROTOCOL_VERSIONS` was a fixed constant) — replace any workaround that patched the offered version with this option. #### stdio transport - A configurable `maxBufferSize` (default **10 MB**) caps the stdio read buffer. A single message that would push the buffer past the limit emits `onerror` and **closes the connection** (v1 buffered unbounded). Configure via `new StdioClientTransport({ ..., maxBufferSize })` / `new StdioServerTransport(stdin, stdout, { maxBufferSize })`. - `ReadBuffer.readMessage()` now **silently skips non-JSON stdout lines** instead of throwing `SyntaxError` → `onerror`. Hot-reload tools (tsx, nodemon) that write debug output to stdout no longer break the transport. Lines that parse as JSON but fail JSON-RPC schema validation still throw. - `StdioClientTransport` always sets `windowsHide: true` when spawning the server process on Windows (previously Electron-only). Prevents stray console windows in non-Electron Windows hosts. #### Client list methods - `listPrompts()`, `listResources()`, `listResourceTemplates()`, `listTools()` return **empty results** when the server didn't advertise the corresponding capability, instead of sending the request. Set `enforceStrictCapabilities: true` in `ClientOptions` to restore the v1 throw. - Called **without a `cursor`**, the same methods now **auto-aggregate every page** and return `nextCursor: undefined`. Passing `{ cursor }` still fetches one page. Manual pagination loops keep working (the first iteration returns everything); replace them with the bare no-arg call. The walk is capped at `ClientOptions.listMaxPages` (default 64); overrun throws `SdkError(ListPaginationExceeded)`. There is no way to fetch only the **first** page through the typed verbs — for page-level observation (pagination tooling, per-page stats) drop to `client.request({ method: 'tools/list', params })`, which never aggregates. - Output-schema validator compilation is now **lazy** — validators compile on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. In v1, `listTools()` threw on an uncompilable `outputSchema`; now `listTools()` succeeds and the compile failure surfaces when `callTool()` is invoked on the affected tool, as `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")`, before the request is sent. Validation is never silently skipped. - On a 2026-07-28 connection the cacheable verbs honour the server-stamped `ttlMs` / `cacheScope` (SEP-2549) and may return a still-fresh cached entry without a round trip. Per-call override: `{ cacheMode: 'refresh' | 'bypass' }`. New `ClientOptions`: `cachePartition`, `defaultCacheTtlMs`. `ResponseCacheStore` gained `delete(key)`; `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512). #### 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. - 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`. #### 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 localhost allowlist; validation cannot be disabled for localhost binds). Requests without an `Origin` header are unaffected; a present `Origin` that cannot be parsed — including the opaque **`Origin: null`** sent by sandboxed iframes, `file://` pages, and cross-origin redirects — is **rejected with 403** and cannot be allowlisted via `allowedOrigins`. Framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) are in `@modelcontextprotocol/server`; `@modelcontextprotocol/node` ships `hostHeaderValidation` / `originValidation` request guards for plain `node:http`. #### 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 declared capabilities. - **`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. - **`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 smuggled a `cacheHint` key through resource metadata should rename it. #### `ctx.mcpReq.log()` is request-related on every era `ctx.mcpReq.log()` now emits its `notifications/message` request-related (it rides the in-flight exchange like progress) on every era. On a 2025-era sessionful Streamable HTTP transport this moves handler-emitted logs from the standalone GET stream onto the per-request POST response stream — a spec-conformance correction. The session-scoped `logging/setLevel` filter applies as before on 2025-era connections. (On 2026-07-28 requests, the per-request `_meta.logLevel` envelope key is the filter — see [support-2026-07-28.md](./support-2026-07-28.md#serving-the-2026-07-28-revision).) #### Wire tightening (every era) - **`CallToolResult.content` is required at the wire boundary.** The `content.default([])` affordance was removed. Tool handlers MUST include `content` (the TypeScript surface always required it; `content: []` is fine). A handler result without it is rejected with `-32602`. - **`ElicitResult.content` values are typed and validated as `string | number | boolean | string[]`.** v1's TypeScript surface accepted `Record` content values; an elicitation handler returning arbitrary objects now fails to compile (and fails schema validation) — narrow to the primitives the elicitation spec allows. - **Custom (3-arg) handlers receive `_meta`.** `setRequestHandler(method, {params}, handler)` used to delete `params._meta` before validation; it now passes `_meta` through (minus the reserved `io.modelcontextprotocol/*` envelope keys). If your params schema is strict, add an optional `_meta` member. - **`specTypeSchemas` validate the neutral model.** Result entries no longer accept `resultType`; the validators for the 2025-only task message types and `RequestMetaEnvelope` left the public set (`SpecTypeName` narrowed accordingly). - **Sampling `hasTools` discriminant** now keys on `tools || toolChoice` (previously `tools` only) when selecting the with-tools `CreateMessageResult` variant, on every era. #### 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`, `protocol.taskManager`, `RequestOptions.task` / `relatedTask`, `BaseContext.task`, `assertTaskCapability` / `assertTaskHandlerCapability`, `*.experimental.tasks.*` accessors and `Experimental{Client,Server,McpServer}Tasks`, `requestStream` / `callToolStream` / `createMessageStream` / `elicitInputStream` and the `ResponseMessage` types they yielded, `registerToolTask`, `ToolTaskHandler`, `TaskRequestHandler`, `CreateTaskRequestHandler`, `TaskMessageQueue`, `InMemoryTaskMessageQueue`, `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 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 ### Automatic JSON Schema validator selection by runtime The SDK auto-selects the validator: Node.js → AJV; Cloudflare Workers (workerd) → `@cfworker/json-schema`. Cloudflare Workers users can remove explicit `jsonSchemaValidator` configuration. You don't need to install `ajv`, `ajv-formats`, or `@cfworker/json-schema` for the default path. To customize the built-in backend, import 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 (`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 breaks. --- ## Unchanged APIs 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. - `StdioClientTransport` and `StdioServerTransport` — **import path moved** to the `./stdio` subpath and gained an optional `maxBufferSize` ([Imports & transports](#imports--transports)). - All TypeScript **type** definitions from `types.ts` (except the aliases listed under [Removed type aliases](#removed-type-aliases)). - 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` > constants are **not** part of the unchanged surface — they moved to > `@modelcontextprotocol/core` ([Types & schemas](#types--schemas)). --- ## Need help? - The codemod's [`@mcp-codemod-error`](../../packages/codemod/README.md) markers point at every site it could not safely rewrite. - The [FAQ](../faq.md) covers common v2 questions. - Runnable [examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) for every subsystem. - Open an issue on [GitHub](https://github.com/modelcontextprotocol/typescript-sdk/issues).