From 63291a0818bd96f240d3e93f4ee8e5173793f802 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 8 Oct 2026 09:33:24 +0300 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20publish=20the=20v1=E2=86=92v2=20upg?= =?UTF-8?q?rade=20guide=20as=20an=20agent=20skill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the guide's text into skills/mcp-typescript-sdk-upgrade-to-v2/ (SKILL.md plus one reference file per subsystem) and assemble docs/migration/upgrade-to-v2.md from it with VitePress includes. The rendered page is unchanged. Add pnpm check:skills to validate the skill's frontmatter and internal links. --- CONTRIBUTING.md | 2 +- docs/.vitepress/config.mts | 3 + docs/.vitepress/llms.ts | 33 +- docs/.vitepress/skill.ts | 29 + docs/behavior-surface-pins.md | 2 +- docs/migration/index.md | 10 +- docs/migration/upgrade-to-v2.md | 1857 +---------------- package.json | 5 +- scripts/check-skills.ts | 103 + .../mcp-typescript-sdk-upgrade-to-v2/SKILL.md | 167 ++ .../references/auth.md | 286 +++ .../references/behavioral-changes.md | 259 +++ .../references/enhancements.md | 32 + .../references/errors.md | 203 ++ .../references/http-headers.md | 56 + .../references/imports-transports.md | 86 + .../references/packaging-runtime.md | 181 ++ .../references/protocol-ctx.md | 202 ++ .../references/server-registration.md | 155 ++ .../references/types-schemas.md | 195 ++ .../references/unchanged-apis.md | 54 + 21 files changed, 2069 insertions(+), 1851 deletions(-) create mode 100644 docs/.vitepress/skill.ts create mode 100644 scripts/check-skills.ts create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/SKILL.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/auth.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/enhancements.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/errors.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/http-headers.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/imports-transports.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/packaging-runtime.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/protocol-ctx.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/types-schemas.md create mode 100644 skills/mcp-typescript-sdk-upgrade-to-v2/references/unchanged-apis.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0291cd4e40..fe774efe39 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -118,7 +118,7 @@ Details about this repository that neither the code nor the tools will tell you. - **Tests** — Tests live under each package's `test/` directory; a test file next to its source never runs. - **Adapters** — The packages under `packages/middleware/` are thin adapters, versioned separately from client and server, so a user can run a new server with an older adapter. - **Package roots** — A package's root entry stays runtime-neutral so browser and Workers bundlers can use it; Node-only code lives at a subpath export such as `./stdio`. -- **Migration guide** — `docs/migration/upgrade-to-v2.md` covers the upgrade from v1 only; mechanical renames go in `packages/codemod/src/migrations/v1-to-v2/mappings/`. +- **Migration guide** — `docs/migration/upgrade-to-v2.md` covers the upgrade from v1 only. Its text lives in the agent skill `skills/mcp-typescript-sdk-upgrade-to-v2/` (one `references/` file per subsystem), which the page includes, so edit it there; mechanical renames go in `packages/codemod/src/migrations/v1-to-v2/mappings/`. ### Running Examples diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 6596651903..34c820b52f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -6,6 +6,7 @@ import { defineConfig, type DefaultTheme } from 'vitepress'; import { generateLlmsArtifacts } from './llms'; import { guideSidebar } from './nav'; +import { rewriteSkillLink } from './skill'; const docsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -45,6 +46,8 @@ export default defineConfig({ const href = tokens[idx].attrGet('href'); if (href?.startsWith('/specification/')) { tokens[idx].attrSet('href', `https://modelcontextprotocol.io${href}`); + } else if (href) { + tokens[idx].attrSet('href', rewriteSkillLink(href, env.relativePath, siteUrl)); } return orig(tokens, idx, options, env, self); }; diff --git a/docs/.vitepress/llms.ts b/docs/.vitepress/llms.ts index 6db2c592dc..ba50a206c6 100644 --- a/docs/.vitepress/llms.ts +++ b/docs/.vitepress/llms.ts @@ -4,6 +4,7 @@ import { dirname, join, posix } from 'node:path'; import type { DefaultTheme } from 'vitepress'; import { guideSidebar } from './nav'; +import { rewriteSkillLink } from './skill'; /** * LLM-facing renditions of the docs, generated into the site output at build @@ -68,6 +69,29 @@ function stripFrontmatter(markdown: string): string { return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\n+/, ''); } +/** + * Expand VitePress `` directives (paths relative to + * `file`) as the rendered page does, but throw on a missing file or region where + * VitePress silently leaves the comment. The including page owns the spacing, so + * included content is trimmed of edge blank lines. + */ +function expandIncludes(markdown: string, file: string): string { + return markdown.replace(//g, (_match, spec: string) => { + const [path, region] = spec.split('#'); + const target = join(dirname(file), path!); + const raw = readFileSync(target, 'utf8'); + let content = stripFrontmatter(raw); + if (region) { + const lines = raw.split('\n'); + const start = lines.indexOf(``); + const end = lines.indexOf(``); + if (start === -1 || end < start) throw new Error(`${file}: include region #${region} not found in ${path}`); + content = lines.slice(start + 1, end).join('\n'); + } + return expandIncludes(content.replace(/^\s*\n/, '').replace(/\n\s*$/, ''), target); + }); +} + /** Drop the `source="…"` wiring attribute from fence info lines. */ function stripFenceAttributes(markdown: string): string { return markdown.replace(/^```(\w+)\s+source="[^"]*"\s*$/gm, '```$1'); @@ -103,10 +127,15 @@ function absolutizeLinks(markdown: string, pageDir: string, site: string, source } function renderPage(docsDir: string, page: Page, site: string): { markdown: string; description?: string } { - const raw = readFileSync(join(docsDir, page.sourcePath), 'utf8'); + const file = join(docsDir, page.sourcePath); + const raw = readFileSync(file, 'utf8'); const pageDir = posix.dirname(page.sourcePath); + const body = expandIncludes(stripFrontmatter(raw), file).replace( + /\]\(([^)\s]+)\)/g, + (_match, target: string) => `](${rewriteSkillLink(target, page.sourcePath, site)})` + ); return { - markdown: absolutizeLinks(stripFenceAttributes(stripFrontmatter(raw)), pageDir === '.' ? '' : pageDir, site, page.sourcePath), + markdown: absolutizeLinks(stripFenceAttributes(body), pageDir === '.' ? '' : pageDir, site, page.sourcePath), description: frontmatterDescription(raw) }; } diff --git a/docs/.vitepress/skill.ts b/docs/.vitepress/skill.ts new file mode 100644 index 0000000000..438d351387 --- /dev/null +++ b/docs/.vitepress/skill.ts @@ -0,0 +1,29 @@ +import { readdirSync } from 'node:fs'; +import { dirname, posix, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * `migration/upgrade-to-v2.md` is assembled with `` from the + * v1→v2 upgrade agent skill in `skills/mcp-typescript-sdk-upgrade-to-v2/`, its + * source. The skill's links work wherever it is installed — cross-file + * (`references/auth.md#auth`, `../SKILL.md#…`) and absolute site URLs — so on + * the assembled page they are mapped back to in-page anchors and relative links. + */ +export const SKILL_PAGE = 'migration/upgrade-to-v2.md'; + +const skillDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../skills/mcp-typescript-sdk-upgrade-to-v2'); +const references = new Set(readdirSync(resolve(skillDir, 'references'))); + +/** Rewrite one link target on the skill-assembled page; every other page and link passes through. */ +export function rewriteSkillLink(href: string, page: string, site: string): string { + if (page !== SKILL_PAGE) return href; + const hashIndex = href.indexOf('#'); + const path = hashIndex === -1 ? href : href.slice(0, hashIndex); + const hash = hashIndex === -1 ? '' : href.slice(hashIndex); + if (path.startsWith(`${site}/`)) { + const relative = posix.relative(posix.dirname(page), path.slice(site.length + 1)); + return `${relative.startsWith('../') ? relative : `./${relative}`}${hash}`; + } + const file = /^(?:\.\.\/)?SKILL\.md$/.test(path) ? 'SKILL.md' : path.replace(/^references\//, ''); + return hash && (file === 'SKILL.md' || references.has(file)) ? hash : href; +} diff --git a/docs/behavior-surface-pins.md b/docs/behavior-surface-pins.md index 1c690d7a69..402e72933b 100644 --- a/docs/behavior-surface-pins.md +++ b/docs/behavior-surface-pins.md @@ -16,7 +16,7 @@ surface-visible and must be deliberate: accidental break. 2. Update the pin in the same PR. 3. Add a changeset if the surface is consumer-facing. -4. Update `docs/migration/upgrade-to-v2.md` (or `docs/migration/support-2026-07-28.md` if 2026-only) where consumer-facing. +4. Update the v1→v2 upgrade guide in `skills/mcp-typescript-sdk-upgrade-to-v2/references/` (or `docs/migration/support-2026-07-28.md` if 2026-only) where consumer-facing. Never weaken a pin (loosen an exact match, delete an assertion) just to make CI pass — that reopens the silent-drift hole the pin exists to close. diff --git a/docs/migration/index.md b/docs/migration/index.md index 9da2a935b1..2fb377e45c 100644 --- a/docs/migration/index.md +++ b/docs/migration/index.md @@ -43,9 +43,13 @@ wire-only members (`resultType`, envelope keys) directly. ## Using an LLM agent to migrate -[upgrade-to-v2.md](./upgrade-to-v2.md) is the agent skill — it carries skill -frontmatter and is structured for mechanical application. Point the agent at -the codemod first; the guide is the codemod's companion for what's left. +[upgrade-to-v2.md](./upgrade-to-v2.md) is also published as an agent skill, with +one reference file per subsystem so the agent reads only what your code needs. It +runs the codemod first and uses the guide for what's left: + +```bash +npx skills add modelcontextprotocol/typescript-sdk --skill mcp-typescript-sdk-upgrade-to-v2 +``` ## See also diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index d7b1485d84..39eb4122a5 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1,42 +1,10 @@ --- title: Upgrading from v1.x to v2 -name: migrate-v1-to-v2 +# Assembled from skills/mcp-typescript-sdk-upgrade-to-v2/ (SKILL.md and references/): edit those files, not this page. 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+. v2 is ESM-first but ships a CommonJS build too, so - both `import` and `require('@modelcontextprotocol/…')` resolve natively. -2. **Run the codemod.** - ```bash - npx @modelcontextprotocol/codemod@latest v1-to-v2 . - ``` - Run it at the **package root** (`.`), not `./src` — it also rewrites `package.json`, - and real projects import the SDK from `test/`, `scripts/`, and fixtures too. -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.** - -Migrating a large codebase gradually instead of in one pass? See -[Migrating in stages (large codebases)](#migrating-in-stages-large-codebases). + ## Contents @@ -58,1829 +26,34 @@ Migrating a large codebase gradually instead of in one pass? See --- -## What the codemod handles - -The codemod ([`@modelcontextprotocol/codemod`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) | -| Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) | -| `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) | -| `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` property remap | [`mappings/contextPropertyMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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()`, 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 - `@modelcontextprotocol/core` (mixed imports are split; `.parse()` / `.safeParse()` - calls are left untouched). Task-handler schema constants - (`GetTaskRequestSchema` etc.) used as `setRequestHandler` args are **not** rewritten - — the experimental tasks feature was removed (SEP-2663), so each such registration - is marked with an action-required diagnostic instead (see - [Experimental tasks interception removed](#experimental-tasks-interception-removed)). -- Renames `ErrorCode` → `ProtocolErrorCode` and routes the local-only members - (`RequestTimeout`, `ConnectionClosed`) to `SdkErrorCode` — rewriting an all-SDK - condition's `instanceof ProtocolError` guard to `SdkError`, and marking guards - that mix the two enums. -- Renames every `StreamableHTTPError` reference to `SdkHttpError` and adds the import - (constructor calls are marked for review — argument shape changed). -- 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. -- 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. -- Rewrites `Protocol` / `mergeCapabilities` imports from `shared/protocol.js` to the - client or server package root, like the module's other symbols. - -## What the codemod does NOT handle - -Each of these maps to a manual section below. The codemod marks every site it -recognized but could not safely rewrite with an `@mcp-codemod-error` comment. - -- **Node 20 / ESM** — pre-flight, not a code rewrite. → [Packaging & runtime](#packaging--runtime) -- **Header-read `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` - and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but - converting that bracket access to `.get()` is manual. (Headers you _pass in_ via - `requestInit.headers` need no rewrite — plain objects remain valid.) - → [HTTP & headers](#http--headers) -- **`ctx.mcpReq.send()` schema-arg drop** — the codemod drops the schema arg from - `client.request()` / `client.callTool()` but leaves nested `ctx.mcpReq.send()` calls - alone. → [Low-level protocol](#low-level-protocol--handler-context-ctx) -- **OAuth error-class consolidation** — `instanceof InvalidGrantError` → `OAuthError` + - `OAuthErrorCode` is a judgment rewrite. → [Auth](#auth) -- **`SdkErrorCode` branch selection** — the codemod renames `StreamableHTTPError` → - `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is - judgment. → [Errors](#errors) -- **Namespace schema access** — `import * as t from '…/types.js'` + - `t.CallToolResultSchema.parse(…)` can't be split per-symbol; the codemod flags it - action-required — re-import the schema from `@modelcontextprotocol/core` by hand. - → [Types & schemas](#types--schemas) -- **Import-less (injected) SDK surfaces** — the codemod is import-driven: a file that - receives the SDK surface as a parameter (dependency injection, factory seams) and has - no SDK import is never rewritten, and the v1 idioms there fail at **runtime**, not - compile time — e.g. the v1 schema-first `setRequestHandler(Schema, …)` form throws a - `TypeError` at registration. Grep such seams for v1 API tokens beyond import - statements (`setRequestHandler(`, `ErrorCode.`, `extra.`) and apply the - [handler-registration](#setrequesthandler--setnotificationhandler-use-method-strings) - and [Errors](#errors) sections by hand. - → [Low-level protocol](#low-level-protocol--handler-context-ctx) -- **Behavioral adaptation** — list auto-aggregation, capability empties, lazy validator - compilation, output-schema validation rules. → [Behavioral changes](#behavioral-changes) - ---- - -## Manual changes (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, 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](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts).) - -The framework adapter packages declare their framework as a **peer dependency** -(`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the -`@modelcontextprotocol/*` packages your imports use, but does not add the framework -peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` -depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) and -installs `hono` as a regular dependency only because `@hono/node-server` requires it as -a peer — the adapter does not use the `hono` framework itself. - -v2 requires **Node.js 20+**. It is ESM-first but ships a **CommonJS build alongside -ESM**, so CommonJS projects can `require('@modelcontextprotocol/…')` directly — no -dynamic `import()` shim required. - -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 two stacked ways: the literal usually lives in a -content-hashed sibling chunk (`dist/sse-.mjs`), not the subpath's entry module, -so fixed-path reads do not survive a rebuild — scan the package's `dist/` directory -for the literal instead; and the emitted quote style differs from v1, so a -quote-anchored pattern misses silently — match either quote. The build layout also -changed: v2 emits `.mjs`/`.cjs` siblings in a flat `dist/`, so v1's `/dist/cjs/` ↔ -`/dist/esm/` flavor-pair path swaps have no equivalent. - -#### Registry availability - -All v2 packages are published on the public npm registry. Two notes: - -- As of `2.0.0-beta.1` all v2 packages share one version number (earlier alphas - did not). The codemod writes ranges that match what is published, so prefer its - manifest output over hand-pinning every package. -- 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) - -v2 ships a CommonJS build, so CJS test runners resolve the packages natively through the -`require` export condition — Jest (including `next/jest` setups) no longer needs a -`moduleNameMapper` workaround to import `@modelcontextprotocol/*`. If you carried a -v1-era mapping that pinned these packages to their `dist/*.mjs` files, remove it. Vitest -and native Node ESM are unaffected. - -#### 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 -[`importMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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). 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 - 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). -- **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`; the runtime-neutral core (`requireBearerAuth` - for web-standard `fetch` hosts, `verifyBearerToken`, `bearerAuthChallengeResponse`, - `OAuthTokenVerifier`, and the discovery serving `oauthMetadataResponse` / - `buildOAuthProtectedResourceMetadata` / `getOAuthProtectedResourceMetadataUrl`) - is also exported from `@modelcontextprotocol/server`. 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`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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. - 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`) - -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`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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; the member itself is also optional — defined only when the transport has an `eventStore` AND the client's negotiated protocol version supports resumable close (2025-11-25+); an `eventStore` transport serving a 2025-06-18 client still leaves it `undefined`. Call as `ctx.http?.closeSSE?.()` | -| `extra.closeStandaloneSSEStream` | `ctx.http?.closeStandaloneSSE` | `ServerContext` only; member optional as above — `ctx.http?.closeStandaloneSSE?.()` | -| `extra.taskStore` / `taskId` / `taskRequestedTtl` | _removed_ | see [Experimental tasks](#experimental-tasks-interception-removed) | - -The transport-level seam behind `ctx.http?.authInfo` is unchanged from v1: a transport -that passes `{ authInfo }` as the second argument to `onmessage(message, extra)` — e.g. -an `InMemoryTransport` test seam — still surfaces it as `ctx.http?.authInfo` on any -transport, and `ctx.http` is defined whenever `authInfo` is supplied, even without an -HTTP transport. - -`BaseContext` is the common base; `ServerContext` and `ClientContext` extend it. None -of the three takes type parameters — v1's `RequestHandlerExtra` -arguments selected request/notification unions that the v2 context carries -intrinsically, so their removal loses no type information; review only handlers that -passed custom (non-standard) unions, whose `sendRequest` / `sendNotification` typing -was narrowed by them. `ServerContext.mcpReq` adds convenience methods that replace -calling `server.*` from inside a handler: - -| `ctx.mcpReq.*` (new) | Replaces (inside a handler) | -| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ctx.mcpReq.log(level, data, logger?)` | `server.sendLoggingMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577); the notification also becomes request-related on every era — see [§`ctx.mcpReq.log()` is request-related on every era](#ctxmcpreqlog-is-request-related-on-every-era) | -| `ctx.mcpReq.elicitInput(params, options?)` | `server.elicitInput(...)` | -| `ctx.mcpReq.requestSampling(params, options?)` | `server.createMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577) | - -#### Deprecated in v2 (SEP-2577) - -The roots, sampling, and logging subsystems are deprecated as of protocol version -2026-07-28 (SEP-2577). Everything below is **still fully functional in v2** and marked -`@deprecated` for removal in a later major; on a 2026-07-28 connection prefer the -[multi-round-trip `input_required` pattern](./support-2026-07-28.md#multi-round-trip-requests) -instead. - -- **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, - `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and - the `ctx.mcpReq.log` / `ctx.mcpReq.requestSampling` handler-context helpers. Outside a - handler, `McpServer` users reach the `Server.*` methods via the unchanged - [`mcpServer.server` accessor](#unchanged-apis). -- **Capability fields**: the `roots`, `sampling`, and `logging` capability schema fields. -- **Type stacks**: the full Logging stack (`LoggingLevel`, `SetLevelRequest`, - `LoggingMessageNotification` and params), the full Sampling stack - (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, - `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), - and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, - `RootsListChangedNotification`). -- **`registerClient`** (Dynamic Client Registration) — deprecated via spec PR - [modelcontextprotocol#2858](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2858) - rather than SEP-2577 (listed here because the `@deprecated` annotations landed in - the same sweep); prefer Client ID Metadata Documents per SEP-991. - -The deprecation is annotation-only — JSDoc `@deprecated` markers were added, nothing -else: every deprecated runtime API keeps its v1 call signature (e.g. -`Server.sendLoggingMessage(params, sessionId?)` keeps the two-argument form) and its -wire behavior, and remains functional for at least the twelve-month deprecation window. - -#### `setRequestHandler` / `setNotificationHandler` use method strings - -The low-level handler registration takes a **method string** instead of a Zod schema. -The codemod rewrites every spec-method registration via -[`schemaToMethodMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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) => { ... }); -``` - -The custom form also covers **spec method names carried with custom payloads**: a v1 -integration that reused a spec method string for its own payload shape (e.g. -`notifications/message` notifications carrying a proprietary params object) registers -it with the 3-arg form and its own schema. The overloads are selected by the arguments' -shape, not by the method name — a schemas object as the second argument always selects -the custom form, which validates against **your** schema (the spec schema is not -applied) and hands the handler the parsed params rather than the envelope. - -**Spec notifications** use the 2-arg form `setNotificationHandler(method, handler)`. -Unlike the 3-arg custom form, the spec-form handler receives the **full notification -envelope** (`{ method, params }`), parsed against the spec schema — read -`notification.params`: - -```typescript -client.setNotificationHandler('notifications/tools/list_changed', async notification => { - console.log(notification.method, notification.params); -}); -``` - -The two overloads are selected by the method string's **type**: the spec form binds the -method to the `NotificationMethod` union (`RequestMethod` on the request side — both -exported), so a method string computed at runtime must be typed as `NotificationMethod` -to select it; an untyped `string` lands on the custom-schema overload and fails to -compile without a schemas argument. `Parameters[0]` -also resolves to the custom `string` overload by design — name `NotificationMethod` -directly instead. The request side has the same trap one slot over: -`Parameters` (and `typeof`-indexed casts over the overload -set) resolve against the 3-arg custom-method overload, so index `[1]` is the -`{ params, result }` schemas object, **not** the handler — v1 signature-erasing handler -casts derived positionally change meaning with no runtime symptom. Name the exported -types (`RequestMethod` and your own handler/param types) instead of deriving them -positionally. Generic helpers that v1 parameterized on a notification schema need -this conversion by hand; the codemod only warns on them. - -**Handler returns are spec-typed.** In v1 the handler's return type flowed from the -schema you registered; v2 types it from the method name (`'tools/list'` → -`ListToolsResult`, and so on). Tool tables kept as plain object literals surface two -recurring compile errors: an unannotated literal widens `type: 'object'` to `string` -and no longer satisfies the spec type's `type: 'object'` literal member (fix: -`type: 'object' as const`, or annotate the table as `Tool[]`); and a heterogeneous -table whose inferred union carries `prop?: undefined` members does not satisfy the spec -types' `Record` index signatures, since `undefined` is not a -`JSONValue` (fix: annotate the handler's return type — -`async (req): Promise => …` — or the table itself, so each literal is -checked against the target type instead of being inferred and widened first). - -#### `request()`, `ctx.mcpReq.send()`, and `callTool()` no longer require a schema for spec methods - -For **spec** methods, drop the result-schema argument; the SDK resolves it from the -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 **inbound half** — a relay re-emitting an upstream JSON-RPC error from its own -handler — has a supported surface too: reconstruct the typed error with -`ProtocolError.fromError(code, message, data)` and throw it; the encode seam serializes -it back to the wire shape (see [Typed `ProtocolError` subclasses](#typed-protocolerror-subclasses)). -Note this is typed reconstruction, not byte-exact relay: legacy codes are normalized at -the encode seam (`-32002` re-emits as `-32602`) and the typed subclasses keep only their -schema-defined `data` members, so extra upstream data keys are dropped. Throwing a plain -object carrying `.code` / `.message` / `.data` happens to work today, but it is -unspecified behavior — prefer `fromError`. - -The return type is inferred from the method name via `ResultTypeMap` (e.g. -`client.request({ method: 'tools/call', ... })` returns `Promise`). -v1 call sites that passed `CreateMessageResultWithToolsSchema` explicitly need no -replacement: the schema-less send resolves to -`CreateMessageResult | CreateMessageResultWithTools`, and validation selects the -with-tools variant when the request set `tools` or `toolChoice`. - -### Server registration API - -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. - -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/) -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. - -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 -runtime — and quietly: registration does not convert the schema, the server starts -and connects normally, and the first `tools/list` (so `client.listTools()`) answers -with an error pointing at `fromJsonSchema()` while the process keeps running. (Only the -deprecated unwrapped raw-shape form with zod-3 field values throws at registration, -with a message pointing at `zod/v4`.) 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.) - -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. Its -default draft-07 `$schema` stamp is fine as-is — the default validator -[honors declared draft-07/06 dialects](#json-schema-2020-12-posture-sep-1613-sep-2106). - -How a too-old zod surfaces depends on which entry point your code imports. With -main-entry `import { z } from 'zod'` on a zod-3 range, the project **typechecks cleanly -and fails at the first `tools/list`** (the quiet runtime path above). With -`import * as z from 'zod/v4'` — or any zod whose _typings_ predate -`~standard.jsonSchema` (zod 4.0–4.1, and zod 3.25.x via the `zod/v4` subpath) — the -same code **runs** through the bundled fallback but **fails to compile**: -`registerTool`/`registerPrompt` reject the schema with `TS2769: No overload matches -this call` listing both overloads. The real cause is buried in the first overload's -elaboration — `Property 'jsonSchema' is missing in type …` (that property is -`~standard.jsonSchema`, added in zod 4.2.0) — and a follow-on implicit-`any` error on -the handler's arguments usually appears below it. If you see that two-overload error on -a registration call with a zod schema, check the installed zod version before anything -else; both symptoms resolve identically with step (1) of the ladder. - -Projects that must stay below zod 4.2 and accept the documented runtime fallback can -resolve the remaining registration compile errors with an explicit assertion to the -registration schema type — `inputSchema: schema as unknown as -StandardSchemaWithJSON` — or a small typed wrapper that attaches a -`~standard.jsonSchema` provider (step (2) of the ladder, which changes runtime -conversion but not the schema's static type) and returns the asserted type. The -fallback caveats (one-time warning, dropped `.describe()` descriptions) still apply -unless the provider is attached. - -The forced zod-4 bump also surfaces zod's **own** type-level API changes in consumer -annotations: `z.ZodTypeDef` no longer exists and `z.ZodType`'s generic parameters -changed, so v3-era annotations like `z.ZodType` fail to -compile — see [zod's v3-to-v4 changelog](https://zod.dev/v4/changelog). Consumer-only -schemas can keep compiling via zod's v3 compat subpath (`zod/v3`), but anything passed -to the SDK must be a zod-4 (or other Standard Schema) schema. - -The deprecated raw-shape overloads exist only on `registerTool` / `registerPrompt`. -`RegisteredTool.update()` / `RegisteredPrompt.update()` take **schema objects** -(`paramsSchema` / `outputSchema`: `StandardSchemaWithJSON`) — a raw shape passed to -`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 - -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 -const transport = new StreamableHTTPClientTransport(url, { - requestInit: { headers: { Authorization: 'Bearer token' } } -}); -const sessionId = extra.requestInfo?.headers['mcp-session-id']; - -// v2 — requestInit is unchanged; only the header *read* changes -const transport = new StreamableHTTPClientTransport(url, { - 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 -proxy/gateway routing. - -Transport-managed headers now take precedence over same-named entries in -`requestInit.headers`: `Authorization` when `authProvider` yields a token, -`mcp-protocol-version`, and (Streamable HTTP) `mcp-session-id`. v1 let the configured -header win, so a static `Authorization` placeholder kept overriding the OAuth token even -after the provider obtained one. A configured `Authorization` value is still sent while -the provider has no token, which is what lets a static API key fall back to OAuth. - -`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`. - -Server entries validate the request `Content-Type` by its **parsed media type**, not a -substring: every POST whose media type is not `application/json` answers -`415 Unsupported Media Type`. Previously any value merely containing the substring -passed (for example `text/plain; a=application/json`), case variants were wrongly -rejected, and the 2026-07-28 entry did not inspect `Content-Type` at all — so -hand-rolled clients that omit the header (or send a non-JSON type) must now set -`Content-Type: application/json`. Parameters (`; charset=utf-8`) and unambiguous -values with malformed parameter sections (`application/json;`) keep working; SDK -clients always sent the correct header and are unaffected. Custom entries that -compose `classifyInboundRequest` / `PerRequestHTTPServerTransport` directly must -apply the same validation themselves — use the exported `isJsonContentType(header)`. - -### 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`. - -These classes (and `OAuthError`, the client's `SseError`, `UnauthorizedError`, and the -OAuth-client-flow error family) brand-match under `instanceof`, so checks work across -separately bundled copies of the SDK — e.g. a process using both -`@modelcontextprotocol/client` and `@modelcontextprotocol/server`. Each branded -hierarchy also exposes the same check as an explicit static guard -(`SdkError.isInstance(err)`, `ProtocolError.isInstance(err)`, …) that narrows in -TypeScript — use whichever style your codebase prefers; both read the same brand. -Fine print (applies equally to `instanceof` and `isInstance`): - -- **Version skew** — matching needs _both_ copies at a brand-aware release; against an - older copy, behavior degrades to plain prototype `instanceof` (false across bundles). - During mixed-version rollouts, recognize errors without class identity: match - `error.name` plus the class's discriminant field (`code`, `status`), or reconstruct - typed protocol errors with `ProtocolError.fromError(code, message, data)`. -- **Worker boundaries** — `structuredClone`/`postMessage` drop the (symbol-keyed) brand, - so a rehydrated error no longer brand-matches; recognize forwarded errors by - `code`/`data` instead. -- **Brands assert identity, not shape** — a matched instance from another SDK version - may lack newer fields; read fields defensively. -- **Re-bundling with property mangling** (`mangle.props` and similar) breaks the brand - statics; default esbuild/webpack/terser settings are safe. + -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. + -**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 -it usually targeted are now **string** `SdkErrorCode` values: - -| v1 numeric | v2 | -| --------------------------- | -------------------------------------------- | -| `-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`. - -**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 — `2.0.0-alpha.3` and earlier predate the mapping and still emit -`-32002` on the wire, so accept both if peers may run those alphas; `2.0.0-alpha.4` -and later emit `-32602`). 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 — the version-agnostic path: it also works on plain wire shapes and -against SDK copies that predate brand-matched `instanceof`. -The default message text changed alongside: v1's unknown-resource error read -`Resource not found`; v2's `ResourceNotFoundError` default is -`Resource not found: ` (the code is unchanged). Tests pinning the exact string -need re-baselining — prefer matching `error.code` plus a URI substring (or the typed -`error.uri`). - -Custom **non-spec** codes pass through untouched: a handler that throws a -`ProtocolError` with a custom code (e.g. `-1`) and `data` reaches the peer as a -JSON-RPC error with that code and `data` unchanged — the encode seam rewrites only the -legacy `-32002` code; `data` is sent verbatim for every thrown error (the typed -subclasses shape their `data` at construction, not at encode time). Construct via -`ProtocolError.fromError(code, message, data)`. - -### Auth - -#### 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`, or from `@modelcontextprotocol/server` on -web-standard hosts) 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. -- **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. -- **Token persistence failures after a refresh now propagate.** v1 wrapped both - `refreshAuthorization()` and the `saveTokens()` that persists its result in one - `try`/`catch`, so a provider's persistence error was discarded alongside AS-side refresh - failures and `auth()` fell through to a fresh authorization request, returning - `'REDIRECT'`. Only the refresh call is guarded now — persisting runs after it and rejects - to the caller. Against an AS that rotates refresh tokens this was destructive rather than - merely quiet: the exchange has already succeeded, so the old refresh token is invalidated - server-side the moment the new one is issued, and dropping the new token set leaves - nothing usable on either side. A provider whose `saveTokens()` can throw (transient - storage errors, file-lock contention) must handle the rejection from `auth()` — and from - the transport 401-retry paths built on it — where v1 silently re-authorized. Refresh - failures themselves keep their control flow: a `ServerError` or an unknown error still - falls through to a new authorization request, and `invalid_grant` / `invalid_client` / - `unauthorized_client` are still recovered by discarding stored credentials and retrying. - Both routes now emit a `console.warn` naming the cause, so an unexplained re-auth prompt - can be traced to the failure that triggered it. - -#### 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()` on a provider that cannot re-register, or `fetchToken()`: client information stamped for a different AS | `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 })); -} -``` - -This direct `instanceof` check works in every version-negotiation mode: under the -probing modes (`versionNegotiation: { mode: 'auto' }`, with or without a pin) the -connect-time `UnauthorizedError` also propagates unchanged from `connect()`. Older -releases wrapped it as `SdkError(SdkErrorCode.EraNegotiationFailed)` with the error at -`error.data.cause` — that unwrap is no longer needed. See the -[client OAuth guide](../clients/oauth.md). - -#### `auth()` options are now `AuthOptions` - -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` / `*.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 -(or throws `AuthorizationServerMismatchError` when the provider has no `saveClientInformation()`). -**Round-trip the stored object verbatim and you're protected** — single-slot storage -works. Dropping the stamp is easy to miss: a `saveTokens()` implementation that -rebuilds the object field-by-field and drops `issuer` leaves the value unstamped — -reads still succeed and refresh keeps working, the per-AS issuer check simply does not -apply to that credential, and every read logs an `[mcp-sdk]` warning (`auth()` -re-stamps on first use where the provider can persist it). If you see that warning -repeating after upgrading, check this first. 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`). `OAuthTokensSchema` / `OAuthClientInformationSchema` keep the optional `issuer`, so -reading storage back through them is fine; the `StoredOAuthTokens` / `StoredOAuthClientInformation` -aliases name the stored shape. - -`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` (omitting it is deprecated) 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`). Both methods are optional on -`OAuthClientProvider` and may be sync or async; `OAuthDiscoveryState` (exported from -`@modelcontextprotocol/client`) extends `OAuthServerInfo` with the optional -`resourceMetadataUrl` the protected-resource metadata was found at: - -```typescript -import type { OAuthDiscoveryState } from '@modelcontextprotocol/client'; - -// On OAuthClientProvider: -saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; -discoveryState?(): OAuthDiscoveryState | undefined | Promise; -``` - -#### Conformance obligations for `OAuthClientProvider` implementers - -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. -- **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). - -### 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 arrives transitively as the shared runtime schema graph of `client` / -`server` — add it to your own `dependencies` only when 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). - -**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` and earlier 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`. If pinned to those alphas, narrow with the -`isCallToolResult` guard — the recommended discrimination tool anyway, per the next -paragraph; `2.0.0-alpha.4` and later are unaffected.) - -**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 - -| 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`. - -One type-level narrowing to note: client/server capability `experimental` payloads are -now typed as JSON-compatible objects (nested JSON values) rather than arbitrary -objects. A payload typed `Record` no longer assigns (`TS2322`) — give -the source a JSON-compatible type or cast at the boundary. - -The `Protocol` base class and `mergeCapabilities` moved: import them from the -`@modelcontextprotocol/client` or `@modelcontextprotocol/server` package root instead -of `shared/protocol.js`. Most code should not use `Protocol` directly — `Client` and -`Server` are the supported surfaces, and for observing unmatched inbound requests -prefer `client.fallbackRequestHandler` / `server.fallbackRequestHandler`. The codemod -rewrites `Protocol` and `mergeCapabilities` imports to the package root, like the -module's other symbols. One caveat: the client and server packages each bundle their -own compiled copy of the class, so the two roots' `Protocol` exports are distinct -classes — import it from one package consistently within a process. - -#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) - -The default validator dispatches on the schema's declared `$schema`: absent or 2020-12 -validates as **JSON Schema 2020-12** — on Node via `Ajv2020` instead of v1's draft-07 -`Ajv` (the Cloudflare Workers default was already 2020-12) — a declared 2019-09 -`$schema` validates with 2019-09 semantics (`Ajv2019`), and a declared draft-07 or -draft-06 `$schema` validates with draft-07 semantics. Schemas declaring any other -`$schema` are rejected with `Error("…unsupported dialect…")`. Two known draft-07 engine -differences: the Node engine (classic Ajv, same as v1's default) evaluates keywords -adjacent to `$ref`, stricter than draft-07's ignore-siblings rule, while the -browser/Workers engine ignores them per spec; and the browser/Workers engine does not -resolve a `$ref` inside a `dependencies` entry whose key collides with a JSON Schema -keyword (`type`, `default`, `format`, …) — validation throws `Unresolved $ref` when -that dependency triggers (surfaced as the SDK's typed validation error), while the -Node engine handles the same schema correctly. - -`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)` | -| draft-07 idioms **without** a declared `$schema` (a declared draft-07/06 `$schema` dispatches automatically) | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | -| undeclared 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) - -- **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)). The one exemption is the - `initialize` handshake: an aborted or timed-out `connect()` still rejects locally, but - no `notifications/cancelled` goes on the wire — the spec forbids cancelling - `initialize`, and v1 sent one anyway. v1 tests asserting that notification need - re-baselining. -- **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 - 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 (unchanged from - v1.x, where the same guard has existed since 1.10.0; recorded here because the - far-away symptom keeps surprising migrators). A custom or test transport that sets `sessionId` at construction - 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. -- **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 - -- 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. -- 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 - -- `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 an aggregate with no `nextCursor`. 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). - `CacheEntry.value` (and the `set()` entry value) is the JSON-serialized result - document (`string`, not `unknown`): persist and return it verbatim, `JSON.parse` - it to inspect. - -#### 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 — 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 - 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` keeps the v1 parse tolerance on the legacy era.** An - inbound result without `content` defaults to `[]` (deployed servers omit it - alongside `structuredContent`); 2026-07-28 connections stay strict. Authoring is - unchanged and era-independent: the TypeScript surface requires `content` on handler - results, and a content-less handler result is normalized to `content: []` before it - reaches the wire. One sharpening remains: a content-less body carrying another - result family's vocabulary (a task handle or an `input_required` round) is still - rejected loudly — tolerance never turns a different result kind into a silent empty - success. A body whose only foreign key was `resultType` strips to an empty object - and defaults, exactly as v1 parsed a payload-free body. -- **`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. -- **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 - -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 })` — connect from a cached era verdict - -Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to -every worker as `client.connect(transport, { prior: { kind: 'modern', discover } })`. -New exported types -`ConnectOptions` (extends `RequestOptions` with `prior?: PriorDiscovery`) -and `PriorDiscovery` — a cached era verdict: the modern arm wraps a `DiscoverResult` -(zero round trips), the legacy arm (`{ kind: 'legacy' }`) skips the probe and runs the -plain `initialize` handshake for servers known to be pre-2026. Freshness is the -supplying host's responsibility — date cached legacy verdicts in your own storage and -stop supplying them past your policy horizon (a stale one succeeds silently against an -upgraded server). - -### 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 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()`, 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 - ([Resume a dropped stream](../serving/sessions-state-scaling.md#resume-a-dropped-stream)). -- `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) 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)). 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)). + --- -## Need help? - -- The codemod's [`@mcp-codemod-error`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/README.md) markers point - at every site it could not safely rewrite. -- The [Troubleshooting](../troubleshooting.md) page covers common errors and their fixes. -- 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). + diff --git a/package.json b/package.json index 3072a5bfea..0be02df4ee 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "fetch:spec-examples": "tsx scripts/fetch-spec-examples.ts", "fetch:spec-types": "tsx scripts/fetch-spec-types.ts", "sync:snippets": "tsx scripts/sync-snippets.ts", + "check:skills": "tsx scripts/check-skills.ts", "examples:oauth-server:w": "pnpm --filter @mcp-examples/oauth exec tsx --watch server.ts", "run:examples": "tsx scripts/examples/run-examples.ts", "docs:examples": "tsx scripts/run-guide-examples.ts", @@ -37,9 +38,9 @@ "build:all": "pnpm -r build", "ci:publish": "pnpm run build:all && pnpm changeset publish", "prepack:all": "pnpm -r prepack", - "lint:all": "pnpm sync:snippets --check && pnpm -r lint", + "lint:all": "pnpm sync:snippets --check && pnpm check:skills && pnpm -r lint", "lint:fix:all": "pnpm sync:snippets && pnpm -r lint:fix", - "check:all": "pnpm sync:snippets --check && pnpm -r typecheck && pnpm -r lint && pnpm run docs:check", + "check:all": "pnpm sync:snippets --check && pnpm check:skills && pnpm -r typecheck && pnpm -r lint && pnpm run docs:check", "test:all": "pnpm -r test", "prepare": "npx --no-install lefthook install", "test:conformance:client": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:client", diff --git a/scripts/check-skills.ts b/scripts/check-skills.ts new file mode 100644 index 0000000000..dd8e08c383 --- /dev/null +++ b/scripts/check-skills.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env tsx +/** + * Check the agent skills under `skills/` against the Agent Skills spec + * (https://agentskills.io/specification) and their own links: + * + * - `name` matches the skill's directory and the spec's character rules; + * `description` is 1–1024 characters; SKILL.md stays under 500 lines. + * - Every relative link resolves inside the skill — file and `#anchor`, using + * GitHub's heading slugs as GitHub and skills.sh render them. A skill is + * installed on its own, so anything outside it is linked by absolute URL. + * + * Run: pnpm check:skills + */ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, join, relative, resolve } from 'node:path'; + +const ROOT = resolve(import.meta.dirname, '..'); +const SKILLS = join(ROOT, 'skills'); + +/** GitHub's heading slug (github-slugger) for a heading's markdown text. */ +function slug(heading: string): string { + return heading + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replaceAll('`', '') + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '') + .replaceAll(' ', '-'); +} + +/** Lines outside fenced code blocks. */ +function prose(markdown: string): string[] { + let fenced = false; + return markdown.split('\n').filter(line => { + if (/^\s*```/.test(line)) fenced = !fenced; + return !fenced && !/^\s*```/.test(line); + }); +} + +function anchors(file: string): Set { + return new Set(prose(readFileSync(file, 'utf8')).flatMap(line => (/^#{1,6} (.*)$/.exec(line) ? [slug(line.replace(/^#+ /, ''))] : []))); +} + +/** A single-line frontmatter value, unquoted. */ +function field(frontmatter: string, key: string): string | undefined { + const raw = new RegExp(`^${key}:[ \\t]*(.*)$`, 'm').exec(frontmatter)?.[1]?.trim(); + if (raw?.startsWith("'")) return raw.slice(1, -1).replaceAll("''", "'"); + if (raw?.startsWith('"')) return JSON.parse(raw) as string; + return raw; +} + +function markdownFiles(dir: string): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const path = join(dir, entry.name); + if (entry.isDirectory()) return markdownFiles(path); + return entry.name.endsWith('.md') ? [path] : []; + }); +} + +const errors: string[] = []; + +for (const name of readdirSync(SKILLS)) { + const dir = join(SKILLS, name); + const skillMd = join(dir, 'SKILL.md'); + if (!existsSync(skillMd)) { + errors.push(`skills/${name}: no SKILL.md`); + continue; + } + const text = readFileSync(skillMd, 'utf8'); + const frontmatter = /^---\n([\s\S]*?)\n---\n/.exec(text)?.[1] ?? ''; + const skillName = field(frontmatter, 'name'); + const description = field(frontmatter, 'description') ?? ''; + if (skillName !== name) errors.push(`skills/${name}/SKILL.md: name "${skillName}" must match the directory name`); + if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name) || name.length > 64) + errors.push(`skills/${name}: name must be ≤64 of a-z, 0-9 and single hyphens`); + if (description.length === 0 || description.length > 1024) + errors.push(`skills/${name}/SKILL.md: description is ${description.length} characters (1–1024)`); + const lines = text.split('\n').length; + if (lines > 500) errors.push(`skills/${name}/SKILL.md: ${lines} lines; move detail into references/ (limit 500)`); + + for (const file of markdownFiles(dir)) { + const where = relative(ROOT, file); + for (const line of prose(readFileSync(file, 'utf8'))) { + for (const [, target] of line.matchAll(/\]\(([^)\s]+)\)/g)) { + if (/^[a-z]+:/i.test(target!)) continue; + const [path, anchor] = target!.split('#'); + const linked = path ? resolve(dirname(file), path) : file; + if (relative(dir, linked).startsWith('..')) { + errors.push(`${where}: ${target} leaves the skill; link it by absolute URL`); + } else if (!existsSync(linked)) { + errors.push(`${where}: ${target} does not exist`); + } else if (anchor && !anchors(linked).has(anchor)) { + errors.push(`${where}: ${target} has no matching heading`); + } + } + } + } +} + +if (errors.length > 0) { + console.error(errors.join('\n')); + process.exit(1); +} +console.log(`[skills] ${readdirSync(SKILLS).length} skill(s) OK`); diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/SKILL.md b/skills/mcp-typescript-sdk-upgrade-to-v2/SKILL.md new file mode 100644 index 0000000000..2a0074d75a --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/SKILL.md @@ -0,0 +1,167 @@ +--- +name: mcp-typescript-sdk-upgrade-to-v2 +description: 'Upgrades TypeScript code from v1 of the MCP TypeScript SDK (`@modelcontextprotocol/sdk`) to v2 (`@modelcontextprotocol/client`, `/server`, `/core` and the middleware packages): runs the official codemod, then works through what it cannot rewrite. Use whenever a project depends on `@modelcontextprotocol/sdk` and the user wants to migrate, upgrade, port or bump it to v2, or is fixing v1-to-v2 breakage such as missing `McpError`, `RequestHandlerExtra` or `*Schema` exports, `@mcp-codemod-error` markers, or changed auth, error, transport or handler behavior, even if they do not say "migration".' +--- + + + +# 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](https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md) instead. + +## TL;DR — quick path + +1. **Prerequisites.** Node.js 20+. v2 is ESM-first but ships a CommonJS build too, so + both `import` and `require('@modelcontextprotocol/…')` resolve natively. +2. **Run the codemod.** + ```bash + npx @modelcontextprotocol/codemod@latest v1-to-v2 . + ``` + Run it at the **package root** (`.`), not `./src` — it also rewrites `package.json`, + and real projects import the SDK from `test/`, `scripts/`, and fixtures too. +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.** + +Migrating a large codebase gradually instead of in one pass? See +[Migrating in stages (large codebases)](references/packaging-runtime.md#migrating-in-stages-large-codebases). + + + + + +## What the codemod handles + +The codemod ([`@modelcontextprotocol/codemod`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts) | +| Symbol renames (`McpError` → `ProtocolError`, `JSONRPCError` → `JSONRPCErrorResponse`, …) | [`mappings/symbolMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/symbolMap.ts) | +| `setRequestHandler(Schema, …)` → `setRequestHandler('method/string', …)` | [`mappings/schemaToMethodMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/schemaToMethodMap.ts) | +| `extra.*` → `ctx.mcpReq.*` / `ctx.http?.*` property remap | [`mappings/contextPropertyMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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()`, 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 + `@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](references/behavioral-changes.md#experimental-tasks-interception-removed)). +- Renames `ErrorCode` → `ProtocolErrorCode` and routes the local-only members + (`RequestTimeout`, `ConnectionClosed`) to `SdkErrorCode` — rewriting an all-SDK + condition's `instanceof ProtocolError` guard to `SdkError`, and marking guards + that mix the two enums. +- Renames every `StreamableHTTPError` reference to `SdkHttpError` and adds the import + (constructor calls are marked for review — argument shape changed). +- 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. +- Inverts optional completable nesting — `completable(schema.optional(), cb)` becomes + `completable(schema, cb).optional()` (see + [Standard Schema objects](references/server-registration.md#standard-schema-objects-raw-shapes-deprecated)); shapes it + cannot invert get an `@mcp-codemod-error` marker. +- Rewrites `Protocol` / `mergeCapabilities` imports from `shared/protocol.js` to the + client or server package root, like the module's other symbols. + +## 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](references/packaging-runtime.md#packaging--runtime) +- **Header-read `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` + and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but + converting that bracket access to `.get()` is manual. (Headers you _pass in_ via + `requestInit.headers` need no rewrite — plain objects remain valid.) + → [HTTP & headers](references/http-headers.md#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](references/protocol-ctx.md#low-level-protocol--handler-context-ctx) +- **OAuth error-class consolidation** — `instanceof InvalidGrantError` → `OAuthError` + + `OAuthErrorCode` is a judgment rewrite. → [Auth](references/auth.md#auth) +- **`SdkErrorCode` branch selection** — the codemod renames `StreamableHTTPError` → + `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is + judgment. → [Errors](references/errors.md#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](references/types-schemas.md#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](references/protocol-ctx.md#setrequesthandler--setnotificationhandler-use-method-strings) + and [Errors](references/errors.md#errors) sections by hand. + → [Low-level protocol](references/protocol-ctx.md#low-level-protocol--handler-context-ctx) +- **Behavioral adaptation** — list auto-aggregation, capability empties, lazy validator + compilation, output-schema validation rules. → [Behavioral changes](references/behavioral-changes.md#behavioral-changes) + +--- + +## Manual changes (what the codemod does not handle) + + + +Each subsystem has its own reference file. Open the one that matches the type error, runtime failure or `@mcp-codemod-error` marker in front of you; most migrations need only a few of them. + +| Reference file | Read it when | +| -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [Packaging & runtime](references/packaging-runtime.md) | Choosing which v2 packages to install, or dealing with Node/ESM/CommonJS setup, monorepo workspaces, Jest, nested `zod` copies, migrating in stages, or a library that peer-depends on the SDK | +| [Imports & transports](references/imports-transports.md) | A transport import needs a runtime decision (Node vs web-standard Streamable HTTP) or a stdio import moved to a `./stdio` subpath | +| [Low-level protocol & handler context (`ctx`)](references/protocol-ctx.md) | Code uses `extra` / `ctx`, `setRequestHandler` / `setNotificationHandler`, `request()`, `ctx.mcpReq.send()` or `callTool()` | +| [Server registration API](references/server-registration.md) | Code registers tools, prompts or resources, or passes raw Zod shapes as schemas | +| [HTTP & headers](references/http-headers.md) | Code reads HTTP request headers | +| [Errors](references/errors.md) | Code throws or catches `McpError` / `ProtocolError`, `ErrorCode`, `SdkError` or `SdkHttpError` | +| [Auth](references/auth.md) | Code uses OAuth (`auth()`, `OAuthClientProvider`, OAuth error classes) or bearer-token auth | +| [Types & schemas](references/types-schemas.md) | Code imports Zod `*Schema` constants or removed type aliases, or depends on JSON Schema details | +| [Behavioral changes](references/behavioral-changes.md) | The code compiles but tests or runtime behavior differ | +| [Enhancements](references/enhancements.md) | Optional: v2 features worth adopting once the upgrade is done | +| [Unchanged APIs](references/unchanged-apis.md) | Checking whether an API changed at all before rewriting it | + + + +## Need help? + +- The codemod's [`@mcp-codemod-error`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/README.md) markers point + at every site it could not safely rewrite. +- The [Troubleshooting](https://ts.sdk.modelcontextprotocol.io/v2/troubleshooting.md) page covers common errors and their fixes. +- 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). + + diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/auth.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/auth.md new file mode 100644 index 0000000000..ebe0d44828 --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/auth.md @@ -0,0 +1,286 @@ +### 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`, or from `@modelcontextprotocol/server` on +web-standard hosts) 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. +- **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. +- **Token persistence failures after a refresh now propagate.** v1 wrapped both + `refreshAuthorization()` and the `saveTokens()` that persists its result in one + `try`/`catch`, so a provider's persistence error was discarded alongside AS-side refresh + failures and `auth()` fell through to a fresh authorization request, returning + `'REDIRECT'`. Only the refresh call is guarded now — persisting runs after it and rejects + to the caller. Against an AS that rotates refresh tokens this was destructive rather than + merely quiet: the exchange has already succeeded, so the old refresh token is invalidated + server-side the moment the new one is issued, and dropping the new token set leaves + nothing usable on either side. A provider whose `saveTokens()` can throw (transient + storage errors, file-lock contention) must handle the rejection from `auth()` — and from + the transport 401-retry paths built on it — where v1 silently re-authorized. Refresh + failures themselves keep their control flow: a `ServerError` or an unknown error still + falls through to a new authorization request, and `invalid_grant` / `invalid_client` / + `unauthorized_client` are still recovered by discarding stored credentials and retrying. + Both routes now emit a `console.warn` naming the cause, so an unexplained re-auth prompt + can be traced to the failure that triggered it. + +#### 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](protocol-ctx.md#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()` on a provider that cannot re-register, or `fetchToken()`: client information stamped for a different AS | `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 })); +} +``` + +This direct `instanceof` check works in every version-negotiation mode: under the +probing modes (`versionNegotiation: { mode: 'auto' }`, with or without a pin) the +connect-time `UnauthorizedError` also propagates unchanged from `connect()`. Older +releases wrapped it as `SdkError(SdkErrorCode.EraNegotiationFailed)` with the error at +`error.data.cause` — that unwrap is no longer needed. See the +[client OAuth guide](https://ts.sdk.modelcontextprotocol.io/v2/clients/oauth.md). + +#### `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](protocol-ctx.md#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` / `*.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 +(or throws `AuthorizationServerMismatchError` when the provider has no `saveClientInformation()`). +**Round-trip the stored object verbatim and you're protected** — single-slot storage +works. Dropping the stamp is easy to miss: a `saveTokens()` implementation that +rebuilds the object field-by-field and drops `issuer` leaves the value unstamped — +reads still succeed and refresh keeps working, the per-AS issuer check simply does not +apply to that credential, and every read logs an `[mcp-sdk]` warning (`auth()` +re-stamps on first use where the provider can persist it). If you see that warning +repeating after upgrading, check this first. 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`). `OAuthTokensSchema` / `OAuthClientInformationSchema` keep the optional `issuer`, so +reading storage back through them is fine; the `StoredOAuthTokens` / `StoredOAuthClientInformation` +aliases name the stored shape. + +`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` (omitting it is deprecated) 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`). Both methods are optional on +`OAuthClientProvider` and may be sync or async; `OAuthDiscoveryState` (exported from +`@modelcontextprotocol/client`) extends `OAuthServerInfo` with the optional +`resourceMetadataUrl` the protected-resource metadata was found at: + +```typescript +import type { OAuthDiscoveryState } from '@modelcontextprotocol/client'; + +// On OAuthClientProvider: +saveDiscoveryState?(state: OAuthDiscoveryState): void | Promise; +discoveryState?(): OAuthDiscoveryState | undefined | Promise; +``` + +#### Conformance obligations for `OAuthClientProvider` implementers + +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. +- **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). diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md new file mode 100644 index 0000000000..e12d54a800 --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/behavioral-changes.md @@ -0,0 +1,259 @@ +### Behavioral changes + +These are runtime-behavior changes that may affect tests and assertions; no source +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](https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md)). The one exemption is the + `initialize` handshake: an aborted or timed-out `connect()` still rejects locally, but + no `notifications/cancelled` goes on the wire — the spec forbids cancelling + `initialize`, and v1 sent one anyway. v1 tests asserting that notification need + re-baselining. +- **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 + 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 (unchanged from + v1.x, where the same guard has existed since 1.10.0; recorded here because the + far-away symptom keeps surprising migrators). A custom or test transport that sets `sessionId` at construction + 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](https://ts.sdk.modelcontextprotocol.io/v2/migration/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 + +- 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. +- 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 + +- `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 an aggregate with no `nextCursor`. 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). + `CacheEntry.value` (and the `set()` entry value) is the JSON-serialized result + document (`string`, not `unknown`): persist and return it verbatim, `JSON.parse` + it to inspect. + +#### 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 — 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 + 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](https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md#serving-the-2026-07-28-revision).) + +#### Wire tightening (every era) + +- **`CallToolResult.content` keeps the v1 parse tolerance on the legacy era.** An + inbound result without `content` defaults to `[]` (deployed servers omit it + alongside `structuredContent`); 2026-07-28 connections stay strict. Authoring is + unchanged and era-independent: the TypeScript surface requires `content` on handler + results, and a content-less handler result is normalized to `content: []` before it + reaches the wire. One sharpening remains: a content-less body carrying another + result family's vocabulary (a task handle or an `input_required` round) is still + rejected loudly — tolerance never turns a different result kind into a silent empty + success. A body whose only foreign key was `resultType` strips to an empty object + and defaults, exactly as v1 parsed a payload-free body. +- **`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. +- **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 + +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](https://ts.sdk.modelcontextprotocol.io/v2/migration/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. diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/enhancements.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/enhancements.md new file mode 100644 index 0000000000..a58e9b9bb4 --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/enhancements.md @@ -0,0 +1,32 @@ +## 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 })` — connect from a cached era verdict + +Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to +every worker as `client.connect(transport, { prior: { kind: 'modern', discover } })`. +New exported types +`ConnectOptions` (extends `RequestOptions` with `prior?: PriorDiscovery`) +and `PriorDiscovery` — a cached era verdict: the modern arm wraps a `DiscoverResult` +(zero round trips), the legacy arm (`{ kind: 'legacy' }`) skips the probe and runs the +plain `initialize` handshake for servers known to be pre-2026. Freshness is the +supplying host's responsibility — date cached legacy verdicts in your own storage and +stop supplying them past your policy horizon (a stale one succeeds silently against an +upgraded server). + +### 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](https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md)** — they are net-new in v2, not v1→v2 +breaks. diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/errors.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/errors.md new file mode 100644 index 0000000000..b76a8f4856 --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/errors.md @@ -0,0 +1,203 @@ +### 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`. + +These classes (and `OAuthError`, the client's `SseError`, `UnauthorizedError`, and the +OAuth-client-flow error family) brand-match under `instanceof`, so checks work across +separately bundled copies of the SDK — e.g. a process using both +`@modelcontextprotocol/client` and `@modelcontextprotocol/server`. Each branded +hierarchy also exposes the same check as an explicit static guard +(`SdkError.isInstance(err)`, `ProtocolError.isInstance(err)`, …) that narrows in +TypeScript — use whichever style your codebase prefers; both read the same brand. +Fine print (applies equally to `instanceof` and `isInstance`): + +- **Version skew** — matching needs _both_ copies at a brand-aware release; against an + older copy, behavior degrades to plain prototype `instanceof` (false across bundles). + During mixed-version rollouts, recognize errors without class identity: match + `error.name` plus the class's discriminant field (`code`, `status`), or reconstruct + typed protocol errors with `ProtocolError.fromError(code, message, data)`. +- **Worker boundaries** — `structuredClone`/`postMessage` drop the (symbol-keyed) brand, + so a rehydrated error no longer brand-matches; recognize forwarded errors by + `code`/`data` instead. +- **Brands assert identity, not shape** — a matched instance from another SDK version + may lack newer fields; read fields defensively. +- **Re-bundling with property mangling** (`mangle.props` and similar) breaks the brand + statics; default esbuild/webpack/terser settings are safe. + +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. + +**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](packaging-runtime.md#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 +it usually targeted are now **string** `SdkErrorCode` values: + +| v1 numeric | v2 | +| --------------------------- | -------------------------------------------- | +| `-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`. + +**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 — `2.0.0-alpha.3` and earlier predate the mapping and still emit +`-32002` on the wire, so accept both if peers may run those alphas; `2.0.0-alpha.4` +and later emit `-32602`). 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 — the version-agnostic path: it also works on plain wire shapes and +against SDK copies that predate brand-matched `instanceof`. +The default message text changed alongside: v1's unknown-resource error read +`Resource not found`; v2's `ResourceNotFoundError` default is +`Resource not found: ` (the code is unchanged). Tests pinning the exact string +need re-baselining — prefer matching `error.code` plus a URI substring (or the typed +`error.uri`). + +Custom **non-spec** codes pass through untouched: a handler that throws a +`ProtocolError` with a custom code (e.g. `-1`) and `data` reaches the peer as a +JSON-RPC error with that code and `data` unchanged — the encode seam rewrites only the +legacy `-32002` code; `data` is sent verbatim for every thrown error (the typed +subclasses shape their `data` at construction, not at encode time). Construct via +`ProtocolError.fromError(code, message, data)`. diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/http-headers.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/http-headers.md new file mode 100644 index 0000000000..285c569e16 --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/http-headers.md @@ -0,0 +1,56 @@ +### HTTP & headers + +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 +const transport = new StreamableHTTPClientTransport(url, { + requestInit: { headers: { Authorization: 'Bearer token' } } +}); +const sessionId = extra.requestInfo?.headers['mcp-session-id']; + +// v2 — requestInit is unchanged; only the header *read* changes +const transport = new StreamableHTTPClientTransport(url, { + 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 +proxy/gateway routing. + +Transport-managed headers now take precedence over same-named entries in +`requestInit.headers`: `Authorization` when `authProvider` yields a token, +`mcp-protocol-version`, and (Streamable HTTP) `mcp-session-id`. v1 let the configured +header win, so a static `Authorization` placeholder kept overriding the OAuth token even +after the provider obtained one. A configured `Authorization` value is still sent while +the provider has no token, which is what lets a static API key fall back to OAuth. + +`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`. + +Server entries validate the request `Content-Type` by its **parsed media type**, not a +substring: every POST whose media type is not `application/json` answers +`415 Unsupported Media Type`. Previously any value merely containing the substring +passed (for example `text/plain; a=application/json`), case variants were wrongly +rejected, and the 2026-07-28 entry did not inspect `Content-Type` at all — so +hand-rolled clients that omit the header (or send a non-JSON type) must now set +`Content-Type: application/json`. Parameters (`; charset=utf-8`) and unambiguous +values with malformed parameter sections (`application/json;`) keep working; SDK +clients always sent the correct header and are unaffected. Custom entries that +compose `classifyInboundRequest` / `PerRequestHTTPServerTransport` directly must +apply the same validation themselves — use the exported `isJsonContentType(header)`. diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/imports-transports.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/imports-transports.md new file mode 100644 index 0000000000..fa21316301 --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/imports-transports.md @@ -0,0 +1,86 @@ +### Imports & transports + +The codemod rewrites every `@modelcontextprotocol/sdk/...` import path via +[`importMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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.md#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). 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 + 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). +- **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`; the runtime-neutral core (`requireBearerAuth` + for web-standard `fetch` hosts, `verifyBearerToken`, `bearerAuthChallengeResponse`, + `OAuthTokenVerifier`, and the discovery serving `oauthMetadataResponse` / + `buildOAuthProtectedResourceMetadata` / `getOAuthProtectedResourceMetadataUrl`) + is also exported from `@modelcontextprotocol/server`. 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`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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. + 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. diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/packaging-runtime.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/packaging-runtime.md new file mode 100644 index 0000000000..397777c1a5 --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/packaging-runtime.md @@ -0,0 +1,181 @@ +### 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`](types-schemas.md#zod-schema-constants-moved-to-modelcontextprotocolcore) below. + +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](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/src/migrations/v1-to-v2/mappings/importMap.ts).) + +The framework adapter packages declare their framework as a **peer dependency** +(`express`, `hono`, `fastify`); v1 shipped them as direct deps. The codemod adds the +`@modelcontextprotocol/*` packages your imports use, but does not add the framework +peer — install it explicitly (`pnpm add express` etc.). `@modelcontextprotocol/node` +depends on `@hono/node-server` at runtime (Node HTTP ↔ Web Standard conversion) and +installs `hono` as a regular dependency only because `@hono/node-server` requires it as +a peer — the adapter does not use the `hono` framework itself. + +v2 requires **Node.js 20+**. It is ESM-first but ships a **CommonJS build alongside +ESM**, so CommonJS projects can `require('@modelcontextprotocol/…')` directly — no +dynamic `import()` shim required. + +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 two stacked ways: the literal usually lives in a +content-hashed sibling chunk (`dist/sse-.mjs`), not the subpath's entry module, +so fixed-path reads do not survive a rebuild — scan the package's `dist/` directory +for the literal instead; and the emitted quote style differs from v1, so a +quote-anchored pattern misses silently — match either quote. The build layout also +changed: v2 emits `.mjs`/`.cjs` siblings in a flat `dist/`, so v1's `/dist/cjs/` ↔ +`/dist/esm/` flavor-pair path swaps have no equivalent. + +#### Registry availability + +All v2 packages are published on the public npm registry. Two notes: + +- As of `2.0.0-beta.1` all v2 packages share one version number (earlier alphas + did not). The codemod writes ranges that match what is published, so prefer its + manifest output over hand-pinning every package. +- 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) + +v2 ships a CommonJS build, so CJS test runners resolve the packages natively through the +`require` export condition — Jest (including `next/jest` setups) no longer needs a +`moduleNameMapper` workaround to import `@modelcontextprotocol/*`. If you carried a +v1-era mapping that pinned these packages to their `dist/*.mjs` files, remove it. Vitest +and native Node ESM are unaffected. + +#### 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.md#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. diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/protocol-ctx.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/protocol-ctx.md new file mode 100644 index 0000000000..3eb218de8d --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/protocol-ctx.md @@ -0,0 +1,202 @@ +### 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`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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; 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](behavioral-changes.md#experimental-tasks-interception-removed) | + +The transport-level seam behind `ctx.http?.authInfo` is unchanged from v1: a transport +that passes `{ authInfo }` as the second argument to `onmessage(message, extra)` — e.g. +an `InMemoryTransport` test seam — still surfaces it as `ctx.http?.authInfo` on any +transport, and `ctx.http` is defined whenever `authInfo` is supplied, even without an +HTTP transport. + +`BaseContext` is the common base; `ServerContext` and `ClientContext` extend it. None +of the three takes type parameters — v1's `RequestHandlerExtra` +arguments selected request/notification unions that the v2 context carries +intrinsically, so their removal loses no type information; review only handlers that +passed custom (non-standard) unions, whose `sendRequest` / `sendNotification` typing +was narrowed by them. `ServerContext.mcpReq` adds convenience methods that replace +calling `server.*` from inside a handler: + +| `ctx.mcpReq.*` (new) | Replaces (inside a handler) | +| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ctx.mcpReq.log(level, data, logger?)` | `server.sendLoggingMessage(...)` — ⚠ **`@deprecated`**, see [§Deprecated in v2](#deprecated-in-v2-sep-2577); the notification also becomes request-related on every era — see [§`ctx.mcpReq.log()` is request-related on every era](behavioral-changes.md#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) + +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](https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md#multi-round-trip-requests) +instead. + +- **Runtime APIs**: `Server.createMessage` / `listRoots` / `sendLoggingMessage`, + `McpServer.sendLoggingMessage`, `Client.setLoggingLevel` / `sendRootsListChanged`, and + the `ctx.mcpReq.log` / `ctx.mcpReq.requestSampling` handler-context helpers. Outside a + handler, `McpServer` users reach the `Server.*` methods via the unchanged + [`mcpServer.server` accessor](unchanged-apis.md#unchanged-apis). +- **Capability fields**: the `roots`, `sampling`, and `logging` capability schema fields. +- **Type stacks**: the full Logging stack (`LoggingLevel`, `SetLevelRequest`, + `LoggingMessageNotification` and params), the full Sampling stack + (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, + `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), + and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, + `RootsListChangedNotification`). +- **`registerClient`** (Dynamic Client Registration) — deprecated via spec PR + [modelcontextprotocol#2858](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2858) + rather than SEP-2577 (listed here because the `@deprecated` annotations landed in + the same sweep); prefer Client ID Metadata Documents per SEP-991. + +The deprecation is annotation-only — JSDoc `@deprecated` markers were added, nothing +else: every deprecated runtime API keeps its v1 call signature (e.g. +`Server.sendLoggingMessage(params, sessionId?)` keeps the two-argument form) and its +wire behavior, and remains functional for at least the twelve-month deprecation window. + +#### `setRequestHandler` / `setNotificationHandler` use method strings + +The low-level handler registration takes a **method string** instead of a Zod schema. +The codemod rewrites every spec-method registration via +[`schemaToMethodMap.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/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) => { ... }); +``` + +The custom form also covers **spec method names carried with custom payloads**: a v1 +integration that reused a spec method string for its own payload shape (e.g. +`notifications/message` notifications carrying a proprietary params object) registers +it with the 3-arg form and its own schema. The overloads are selected by the arguments' +shape, not by the method name — a schemas object as the second argument always selects +the custom form, which validates against **your** schema (the spec schema is not +applied) and hands the handler the parsed params rather than the envelope. + +**Spec notifications** use the 2-arg form `setNotificationHandler(method, handler)`. +Unlike the 3-arg custom form, the spec-form handler receives the **full notification +envelope** (`{ method, params }`), parsed against the spec schema — read +`notification.params`: + +```typescript +client.setNotificationHandler('notifications/tools/list_changed', async notification => { + console.log(notification.method, notification.params); +}); +``` + +The two overloads are selected by the method string's **type**: the spec form binds the +method to the `NotificationMethod` union (`RequestMethod` on the request side — both +exported), so a method string computed at runtime must be typed as `NotificationMethod` +to select it; an untyped `string` lands on the custom-schema overload and fails to +compile without a schemas argument. `Parameters[0]` +also resolves to the custom `string` overload by design — name `NotificationMethod` +directly instead. The request side has the same trap one slot over: +`Parameters` (and `typeof`-indexed casts over the overload +set) resolve against the 3-arg custom-method overload, so index `[1]` is the +`{ params, result }` schemas object, **not** the handler — v1 signature-erasing handler +casts derived positionally change meaning with no runtime symptom. Name the exported +types (`RequestMethod` and your own handler/param types) instead of deriving them +positionally. Generic helpers that v1 parameterized on a notification schema need +this conversion by hand; the codemod only warns on them. + +**Handler returns are spec-typed.** In v1 the handler's return type flowed from the +schema you registered; v2 types it from the method name (`'tools/list'` → +`ListToolsResult`, and so on). Tool tables kept as plain object literals surface two +recurring compile errors: an unannotated literal widens `type: 'object'` to `string` +and no longer satisfies the spec type's `type: 'object'` literal member (fix: +`type: 'object' as const`, or annotate the table as `Tool[]`); and a heterogeneous +table whose inferred union carries `prop?: undefined` members does not satisfy the spec +types' `Record` index signatures, since `undefined` is not a +`JSONValue` (fix: annotate the handler's return type — +`async (req): Promise => …` — or the table itself, so each literal is +checked against the target type instead of being inferred and widened first). + +#### `request()`, `ctx.mcpReq.send()`, and `callTool()` no longer require a schema for spec methods + +For **spec** methods, drop the result-schema argument; the SDK resolves it from the +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 **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](errors.md#typed-protocolerror-subclasses)). +Note this is typed reconstruction, not byte-exact relay: legacy codes are normalized at +the encode seam (`-32002` re-emits as `-32602`) and the typed subclasses keep only their +schema-defined `data` members, so extra upstream data keys are dropped. Throwing a plain +object carrying `.code` / `.message` / `.data` happens to work today, but it is +unspecified behavior — prefer `fromError`. + +The return type is inferred from the method name via `ResultTypeMap` (e.g. +`client.request({ method: 'tools/call', ... })` returns `Promise`). +v1 call sites that passed `CreateMessageResultWithToolsSchema` explicitly need no +replacement: the schema-less send resolves to +`CreateMessageResult | CreateMessageResultWithTools`, and validation selects the +with-tools variant when the request set `tools` or `toolChoice`. diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md new file mode 100644 index 0000000000..898c80caad --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/server-registration.md @@ -0,0 +1,155 @@ +### 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. + +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/) +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. + +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 +runtime — and quietly: registration does not convert the schema, the server starts +and connects normally, and the first `tools/list` (so `client.listTools()`) answers +with an error pointing at `fromJsonSchema()` while the process keeps running. (Only the +deprecated unwrapped raw-shape form with zod-3 field values throws at registration, +with a message pointing at `zod/v4`.) 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.) + +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](packaging-runtime.md#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. Its +default draft-07 `$schema` stamp is fine as-is — the default validator +[honors declared draft-07/06 dialects](types-schemas.md#json-schema-2020-12-posture-sep-1613-sep-2106). + +How a too-old zod surfaces depends on which entry point your code imports. With +main-entry `import { z } from 'zod'` on a zod-3 range, the project **typechecks cleanly +and fails at the first `tools/list`** (the quiet runtime path above). With +`import * as z from 'zod/v4'` — or any zod whose _typings_ predate +`~standard.jsonSchema` (zod 4.0–4.1, and zod 3.25.x via the `zod/v4` subpath) — the +same code **runs** through the bundled fallback but **fails to compile**: +`registerTool`/`registerPrompt` reject the schema with `TS2769: No overload matches +this call` listing both overloads. The real cause is buried in the first overload's +elaboration — `Property 'jsonSchema' is missing in type …` (that property is +`~standard.jsonSchema`, added in zod 4.2.0) — and a follow-on implicit-`any` error on +the handler's arguments usually appears below it. If you see that two-overload error on +a registration call with a zod schema, check the installed zod version before anything +else; both symptoms resolve identically with step (1) of the ladder. + +Projects that must stay below zod 4.2 and accept the documented runtime fallback can +resolve the remaining registration compile errors with an explicit assertion to the +registration schema type — `inputSchema: schema as unknown as +StandardSchemaWithJSON` — or a small typed wrapper that attaches a +`~standard.jsonSchema` provider (step (2) of the ladder, which changes runtime +conversion but not the schema's static type) and returns the asserted type. The +fallback caveats (one-time warning, dropped `.describe()` descriptions) still apply +unless the provider is attached. + +The forced zod-4 bump also surfaces zod's **own** type-level API changes in consumer +annotations: `z.ZodTypeDef` no longer exists and `z.ZodType`'s generic parameters +changed, so v3-era annotations like `z.ZodType` fail to +compile — see [zod's v3-to-v4 changelog](https://zod.dev/v4/changelog). Consumer-only +schemas can keep compiling via zod's v3 compat subpath (`zod/v3`), but anything passed +to the SDK must be a zod-4 (or other Standard Schema) schema. + +The deprecated raw-shape overloads exist only on `registerTool` / `registerPrompt`. +`RegisteredTool.update()` / `RegisteredPrompt.update()` take **schema objects** +(`paramsSchema` / `outputSchema`: `StandardSchemaWithJSON`) — a raw shape passed to +`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. diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/types-schemas.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/types-schemas.md new file mode 100644 index 0000000000..b60abca71a --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/types-schemas.md @@ -0,0 +1,195 @@ +### 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 arrives transitively as the shared runtime schema graph of `client` / +`server` — add it to your own `dependencies` only when 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). + +**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](protocol-ctx.md#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.md#errors): the +`2.0.0-alpha.3` and earlier 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`. If pinned to those alphas, narrow with the +`isCallToolResult` guard — the recommended discrimination tool anyway, per the next +paragraph; `2.0.0-alpha.4` and later are unaffected.) + +**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 + +| 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`. + +One type-level narrowing to note: client/server capability `experimental` payloads are +now typed as JSON-compatible objects (nested JSON values) rather than arbitrary +objects. A payload typed `Record` no longer assigns (`TS2322`) — give +the source a JSON-compatible type or cast at the boundary. + +The `Protocol` base class and `mergeCapabilities` moved: import them from the +`@modelcontextprotocol/client` or `@modelcontextprotocol/server` package root instead +of `shared/protocol.js`. Most code should not use `Protocol` directly — `Client` and +`Server` are the supported surfaces, and for observing unmatched inbound requests +prefer `client.fallbackRequestHandler` / `server.fallbackRequestHandler`. The codemod +rewrites `Protocol` and `mergeCapabilities` imports to the package root, like the +module's other symbols. One caveat: the client and server packages each bundle their +own compiled copy of the class, so the two roots' `Protocol` exports are distinct +classes — import it from one package consistently within a process. + +#### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) + +The default validator dispatches on the schema's declared `$schema`: absent or 2020-12 +validates as **JSON Schema 2020-12** — on Node via `Ajv2020` instead of v1's draft-07 +`Ajv` (the Cloudflare Workers default was already 2020-12) — a declared 2019-09 +`$schema` validates with 2019-09 semantics (`Ajv2019`), and a declared draft-07 or +draft-06 `$schema` validates with draft-07 semantics. Schemas declaring any other +`$schema` are rejected with `Error("…unsupported dialect…")`. Two known draft-07 engine +differences: the Node engine (classic Ajv, same as v1's default) evaluates keywords +adjacent to `$ref`, stricter than draft-07's ignore-siblings rule, while the +browser/Workers engine ignores them per spec; and the browser/Workers engine does not +resolve a `$ref` inside a `dependencies` entry whose key collides with a JSON Schema +keyword (`type`, `default`, `format`, …) — validation throws `Unresolved $ref` when +that dependency triggers (surfaced as the SDK's typed validation error), while the +Node engine handles the same schema correctly. + +`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)` | +| draft-07 idioms **without** a declared `$schema` (a declared draft-07/06 `$schema` dispatches automatically) | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | +| undeclared 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](https://ts.sdk.modelcontextprotocol.io/v2/migration/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. diff --git a/skills/mcp-typescript-sdk-upgrade-to-v2/references/unchanged-apis.md b/skills/mcp-typescript-sdk-upgrade-to-v2/references/unchanged-apis.md new file mode 100644 index 0000000000..b2d3b8a316 --- /dev/null +++ b/skills/mcp-typescript-sdk-upgrade-to-v2/references/unchanged-apis.md @@ -0,0 +1,54 @@ +## Unchanged APIs + +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()`, 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](protocol-ctx.md#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.md#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](protocol-ctx.md#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 + ([Resume a dropped stream](https://ts.sdk.modelcontextprotocol.io/v2/serving/sessions-state-scaling.md#resume-a-dropped-stream)). +- `StdioClientTransport` and `StdioServerTransport` — **import path moved** to the + `./stdio` subpath and gained an optional `maxBufferSize` ([Imports & transports](imports-transports.md#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](https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md)). +- All TypeScript **type** definitions from `types.ts` (except the aliases listed under + [Removed type aliases](types-schemas.md#removed-type-aliases) and the `experimental` capability + payload narrowing — see [Types & schemas](types-schemas.md#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](protocol-ctx.md#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](https://ts.sdk.modelcontextprotocol.io/v2/migration/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.md#types--schemas)). From b1ec9d08919edf29933123fb6f651e00606274fa Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 8 Oct 2026 09:37:07 +0300 Subject: [PATCH 2/4] docs: resolve GitHub-style in-page anchors to VitePress heading ids The migration guides link headings GitHub-style (#packaging--runtime), which is what GitHub and skills.sh render, but VitePress slugs punctuation differently (#packaging-runtime): 24 of 56 in-page links on the upgrade guide and 6 on the 2026-07-28 guide went nowhere on the site. Map each such anchor to the heading id VitePress generated, in the same core rule that now resolves the skill's links, and share the slug function with check:skills. --- docs/.vitepress/config.mts | 27 ++++++++++++++++++++++++--- docs/.vitepress/skill.ts | 13 +++++++++++++ scripts/check-skills.ts | 16 +++++----------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 34c820b52f..a027bfaafb 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -6,7 +6,7 @@ import { defineConfig, type DefaultTheme } from 'vitepress'; import { generateLlmsArtifacts } from './llms'; import { guideSidebar } from './nav'; -import { rewriteSkillLink } from './skill'; +import { githubSlug, rewriteSkillLink } from './skill'; const docsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -46,11 +46,32 @@ export default defineConfig({ const href = tokens[idx].attrGet('href'); if (href?.startsWith('/specification/')) { tokens[idx].attrSet('href', `https://modelcontextprotocol.io${href}`); - } else if (href) { - tokens[idx].attrSet('href', rewriteSkillLink(href, env.relativePath, siteUrl)); } return orig(tokens, idx, options, env, self); }; + // Resolve skill links on the assembled page, then GitHub-style anchors to VitePress's heading ids. + md.core.ruler.push('page_links', state => { + const ids = new Set(); + const byGithubSlug = new Map(); + const seen = new Map(); + state.tokens.forEach((token, i) => { + const id = token.type === 'heading_open' ? token.attrGet('id') : null; + if (!id) return; + const slug = githubSlug(state.tokens[i + 1]!.content); + const n = seen.get(slug) ?? 0; + seen.set(slug, n + 1); + ids.add(id); + byGithubSlug.set(n ? `${slug}-${n}` : slug, id); + }); + for (const link of state.tokens.flatMap(token => token.children ?? [])) { + const href = link.type === 'link_open' ? link.attrGet('href') : null; + if (!href) continue; + const rewritten = rewriteSkillLink(href, state.env.relativePath, siteUrl); + const anchor = /^#(.+)$/.exec(rewritten)?.[1]; + const id = anchor && !ids.has(anchor) ? byGithubSlug.get(decodeURIComponent(anchor)) : undefined; + link.attrSet('href', id ? `#${id}` : rewritten); + } + }); } }, buildEnd(siteConfig) { diff --git a/docs/.vitepress/skill.ts b/docs/.vitepress/skill.ts index 438d351387..6335e2ddaf 100644 --- a/docs/.vitepress/skill.ts +++ b/docs/.vitepress/skill.ts @@ -14,6 +14,19 @@ export const SKILL_PAGE = 'migration/upgrade-to-v2.md'; const skillDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../skills/mcp-typescript-sdk-upgrade-to-v2'); const references = new Set(readdirSync(resolve(skillDir, 'references'))); +/** + * GitHub's heading slug (github-slugger) for a heading's markdown text — the + * anchor style the skill is written in, since GitHub and skills.sh render it. + */ +export function githubSlug(heading: string): string { + return heading + .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') + .replaceAll('`', '') + .toLowerCase() + .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '') + .replaceAll(' ', '-'); +} + /** Rewrite one link target on the skill-assembled page; every other page and link passes through. */ export function rewriteSkillLink(href: string, page: string, site: string): string { if (page !== SKILL_PAGE) return href; diff --git a/scripts/check-skills.ts b/scripts/check-skills.ts index dd8e08c383..f7665e4ff5 100644 --- a/scripts/check-skills.ts +++ b/scripts/check-skills.ts @@ -14,19 +14,11 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; +import { githubSlug } from '../docs/.vitepress/skill'; + const ROOT = resolve(import.meta.dirname, '..'); const SKILLS = join(ROOT, 'skills'); -/** GitHub's heading slug (github-slugger) for a heading's markdown text. */ -function slug(heading: string): string { - return heading - .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') - .replaceAll('`', '') - .toLowerCase() - .replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, '') - .replaceAll(' ', '-'); -} - /** Lines outside fenced code blocks. */ function prose(markdown: string): string[] { let fenced = false; @@ -37,7 +29,9 @@ function prose(markdown: string): string[] { } function anchors(file: string): Set { - return new Set(prose(readFileSync(file, 'utf8')).flatMap(line => (/^#{1,6} (.*)$/.exec(line) ? [slug(line.replace(/^#+ /, ''))] : []))); + return new Set( + prose(readFileSync(file, 'utf8')).flatMap(line => (/^#{1,6} (.*)$/.exec(line) ? [githubSlug(line.replace(/^#+ /, ''))] : [])) + ); } /** A single-line frontmatter value, unquoted. */ From 99346159fb0d3c71c99cabaabb2f72498c3d8fc7 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 8 Oct 2026 10:09:06 +0300 Subject: [PATCH 3/4] docs: show where the upgrade guide's source lives, and reword its description GitHub renders frontmatter as a table and does not render include comments, so a source: field tells GitHub readers to edit the skill files; the site and llms.ts do not display it. The description, which llms.txt shows, no longer reads as a skill trigger. --- docs/migration/upgrade-to-v2.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/migration/upgrade-to-v2.md b/docs/migration/upgrade-to-v2.md index 39eb4122a5..55123e68f6 100644 --- a/docs/migration/upgrade-to-v2.md +++ b/docs/migration/upgrade-to-v2.md @@ -1,7 +1,7 @@ --- title: Upgrading from v1.x to v2 -# Assembled from skills/mcp-typescript-sdk-upgrade-to-v2/ (SKILL.md and references/): edit those files, not this page. -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. +description: 'Upgrade from @modelcontextprotocol/sdk (v1.x) to the v2 packages: run the codemod, then make the changes it cannot.' +source: 'This page is assembled from skills/mcp-typescript-sdk-upgrade-to-v2/ (SKILL.md and references/), so edit those files. Read it rendered at https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2' --- From 007c199e4bb16f55bc2846eae4ccd7cc364bbf49 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 8 Oct 2026 12:11:41 +0300 Subject: [PATCH 4/4] docs: fix the last broken guide anchor, and correct the skills check's line count support-2026-07-28.md links into the upgrade guide with a GitHub-style anchor (#client-connection--dispatch). The page_links rule only maps in-page anchors, so the link still opened the guide at the top. It now uses the VitePress id. On GitHub, upgrade-to-v2.md shows only its Contents list, so the GitHub-style anchor no longer lands anywhere there. check:skills counted the empty element after the trailing newline, so it reported every SKILL.md one line long. It now counts the file's lines and enforces the spec's "under 500 lines". check:skills derives its root from import.meta.url, as skill.ts does: import.meta.dirname is undefined before Node 20.11, which engines allows, and the script runs in lint:all on pre-push. Three JSDoc blocks are now one line each. expandIncludes no longer claims that VitePress silently leaves the comment: that holds for a missing file, but for a missing region VitePress 1.6 includes the whole file. The throw catches both. Included content is trimmed of edge blank lines because the including page owns the spacing. --- docs/.vitepress/llms.ts | 7 +------ docs/.vitepress/skill.ts | 8 +------- docs/migration/support-2026-07-28.md | 2 +- scripts/check-skills.ts | 20 +++++--------------- 4 files changed, 8 insertions(+), 29 deletions(-) diff --git a/docs/.vitepress/llms.ts b/docs/.vitepress/llms.ts index ba50a206c6..c595053bdc 100644 --- a/docs/.vitepress/llms.ts +++ b/docs/.vitepress/llms.ts @@ -69,12 +69,7 @@ function stripFrontmatter(markdown: string): string { return end === -1 ? markdown : markdown.slice(end + 5).replace(/^\n+/, ''); } -/** - * Expand VitePress `` directives (paths relative to - * `file`) as the rendered page does, but throw on a missing file or region where - * VitePress silently leaves the comment. The including page owns the spacing, so - * included content is trimmed of edge blank lines. - */ +/** Expand `` directives (paths relative to `file`), throwing on a missing file or region. */ function expandIncludes(markdown: string, file: string): string { return markdown.replace(//g, (_match, spec: string) => { const [path, region] = spec.split('#'); diff --git a/docs/.vitepress/skill.ts b/docs/.vitepress/skill.ts index 6335e2ddaf..5817252a78 100644 --- a/docs/.vitepress/skill.ts +++ b/docs/.vitepress/skill.ts @@ -2,13 +2,7 @@ import { readdirSync } from 'node:fs'; import { dirname, posix, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -/** - * `migration/upgrade-to-v2.md` is assembled with `` from the - * v1→v2 upgrade agent skill in `skills/mcp-typescript-sdk-upgrade-to-v2/`, its - * source. The skill's links work wherever it is installed — cross-file - * (`references/auth.md#auth`, `../SKILL.md#…`) and absolute site URLs — so on - * the assembled page they are mapped back to in-page anchors and relative links. - */ +/** The docs page assembled with `` from the skill in `skills/mcp-typescript-sdk-upgrade-to-v2/`. */ export const SKILL_PAGE = 'migration/upgrade-to-v2.md'; const skillDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../skills/mcp-typescript-sdk-upgrade-to-v2'); diff --git a/docs/migration/support-2026-07-28.md b/docs/migration/support-2026-07-28.md index c7b087a3ef..163cccecb8 100644 --- a/docs/migration/support-2026-07-28.md +++ b/docs/migration/support-2026-07-28.md @@ -58,7 +58,7 @@ client.getProtocolEra(); // 'modern' | 'legacy' `ProtocolOptions.supportedProtocolVersions` — the same option that pins what the legacy `initialize` handshake offers (see -[upgrade-to-v2.md › Client connection & dispatch](./upgrade-to-v2.md#client-connection--dispatch)) +[upgrade-to-v2.md › Client connection & dispatch](./upgrade-to-v2.md#client-connection-dispatch)) — shapes `'auto'`: the modern candidates are the option's modern entries (when it lists any; otherwise the SDK's default modern set), and legacy fallback is available only if the list has a pre-2026 entry. A `{ pin }` is honored as given — it must name a modern diff --git a/scripts/check-skills.ts b/scripts/check-skills.ts index f7665e4ff5..3166b903aa 100644 --- a/scripts/check-skills.ts +++ b/scripts/check-skills.ts @@ -1,22 +1,12 @@ #!/usr/bin/env tsx -/** - * Check the agent skills under `skills/` against the Agent Skills spec - * (https://agentskills.io/specification) and their own links: - * - * - `name` matches the skill's directory and the spec's character rules; - * `description` is 1–1024 characters; SKILL.md stays under 500 lines. - * - Every relative link resolves inside the skill — file and `#anchor`, using - * GitHub's heading slugs as GitHub and skills.sh render them. A skill is - * installed on its own, so anything outside it is linked by absolute URL. - * - * Run: pnpm check:skills - */ +/** Check each skill under `skills/` against https://agentskills.io/specification, and that its links resolve inside it. */ import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { githubSlug } from '../docs/.vitepress/skill'; -const ROOT = resolve(import.meta.dirname, '..'); +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const SKILLS = join(ROOT, 'skills'); /** Lines outside fenced code blocks. */ @@ -68,8 +58,8 @@ for (const name of readdirSync(SKILLS)) { errors.push(`skills/${name}: name must be ≤64 of a-z, 0-9 and single hyphens`); if (description.length === 0 || description.length > 1024) errors.push(`skills/${name}/SKILL.md: description is ${description.length} characters (1–1024)`); - const lines = text.split('\n').length; - if (lines > 500) errors.push(`skills/${name}/SKILL.md: ${lines} lines; move detail into references/ (limit 500)`); + const lines = text.replace(/\n$/, '').split('\n').length; + if (lines >= 500) errors.push(`skills/${name}/SKILL.md: ${lines} lines; move detail into references/ (keep it under 500)`); for (const file of markdownFiles(dir)) { const where = relative(ROOT, file);