# MCP TypeScript SDK > The official TypeScript SDK for the Model Context Protocol (MCP): build MCP servers and clients on Node.js, Bun, Deno, and Workers. This is the v2 beta documentation, tracking the 2026-07-28 spec revision. Every page below is also served as plain markdown at its `.md` URL — fetch any page directly. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/index.md ================================================================================ # MCP TypeScript SDK ::: info v2 beta This is the documentation for **v2** of the SDK, currently in **beta**: the API is settling but can still change before the stable release alongside the [2026-07-28 spec](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/). [Tell us what you find](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml) — and if you need the stable v1, its documentation is at [ts.sdk.modelcontextprotocol.io](https://ts.sdk.modelcontextprotocol.io/). ::: The **Model Context Protocol** (MCP) is an open standard that connects AI applications to the systems where your data and tools live. You write a **server** that exposes tools, resources, and prompts; any MCP **host** — Claude Code, VS Code, Cursor, your own application — connects to it and lets a model use them. The protocol is defined by [the MCP specification](https://modelcontextprotocol.io/specification/latest); this SDK is its TypeScript implementation, on Node.js, Bun, and Deno. A complete server is one file: ```ts import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; serveStdio(() => { const server = new McpServer({ name: 'weather', version: '1.0.0' }); server.registerTool( 'get-forecast', { description: 'Get the weather forecast for a city', inputSchema: z.object({ city: z.string() }) }, async ({ city }) => ({ content: [{ type: 'text', text: `Sunny in ${city} all week.` }] }) ); return server; }); ``` Any MCP host that launches this program lists and calls `get-forecast`; the SDK validates every call against that `z.object(...)` schema before your handler runs. [Build a server](https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server.md) installs the packages and runs it end to end. ## Pick a path - Expose your API or data to AI applications → **[Build a server](https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server.md)** - Build an application that talks to MCP servers → **[Build a client](https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-client.md)** - Coming from v1 (`@modelcontextprotocol/sdk`) → **[Upgrade](https://ts.sdk.modelcontextprotocol.io/v2/migration/index.md)** - Drop MCP into the app you already run → **[Express](https://ts.sdk.modelcontextprotocol.io/v2/serving/express.md)** · **[Hono](https://ts.sdk.modelcontextprotocol.io/v2/serving/hono.md)** · **[Fastify](https://ts.sdk.modelcontextprotocol.io/v2/serving/fastify.md)** · **[Workers](https://ts.sdk.modelcontextprotocol.io/v2/serving/web-standard.md)** For exact signatures, go to the [API reference](https://ts.sdk.modelcontextprotocol.io/v2/api/). ## Recap - MCP connects AI applications to the systems where your tools and data live; you build one side, a host brings the model. - `registerTool(name, config, handler)` with a `z.object(...)` `inputSchema` defines a tool; `serveStdio` serves it over stdio. - Four starting points: build a server, build a client, upgrade from v1, or drop into Express, Hono, Fastify, or Workers. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server.md ================================================================================ # Build your first server Build an MCP **server** — a program that exposes tools a model can call — and call its one tool, a US weather-alert lookup, from a client. ## Set up the project You need Node.js 20 or later and nothing else. Create the project and install the SDK. ```sh mkdir weather && cd weather npm init -y npm pkg set type=module npm install @modelcontextprotocol/server zod tsx mkdir src ``` `type=module` matters — the SDK ships ES modules only. `tsx` runs TypeScript directly, so there is no build step. ## Register a tool Create `src/index.ts`: a `createServer` factory that builds an `McpServer` and registers one **tool** — a function the connected model can call. ```ts import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; import * as z from 'zod/v4'; const NWS_API = 'https://api.weather.gov'; interface AlertsResponse { features: { properties: { event?: string; headline?: string } }[]; } function createServer(): McpServer { const server = new McpServer({ name: 'weather', version: '1.0.0' }); server.registerTool( 'get-alerts', { description: 'Get the active weather alerts for a US state', inputSchema: z.object({ state: z.string().length(2).describe('Two-letter US state code, e.g. CA') }) }, async ({ state }) => { const code = state.toUpperCase(); const url = `${NWS_API}/alerts/active?area=${code}`; const res = await fetch(url, { headers: { 'User-Agent': 'mcp-weather-tutorial/1.0' } }); if (!res.ok) { return { content: [{ type: 'text', text: `NWS API error: HTTP ${res.status}` }], isError: true }; } const { features } = (await res.json()) as AlertsResponse; if (features.length === 0) { return { content: [{ type: 'text', text: `No active alerts for ${code}.` }] }; } const lines = features.map(f => f.properties.headline ?? f.properties.event ?? 'Unnamed alert'); return { content: [{ type: 'text', text: lines.join('\n') }] }; } ); return server; } ``` `registerTool` takes a name, a config, and an async handler. `inputSchema` is a Zod schema — the only schema you write. From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. The handler returns **content**, a list of typed blocks — one `text` block here. `isError: true` marks a failed result the model can read and react to. ::: tip Call `get-alerts` with `{ "state": "California" }` and the SDK rejects it before your handler runs. The result is the failure the model sees: ```text Input validation error: Invalid arguments for tool get-alerts: state: Too big: expected string to have <=2 characters ``` ::: ## Serve it over stdio At the end of the file, hand the factory to `serveStdio`. ```ts void serveStdio(createServer); console.error('weather MCP server running on stdio'); ``` `serveStdio` owns the **stdio transport**: it reads requests on stdin, writes responses to stdout, and calls `createServer` to build the instance that serves the connection. ::: warning stdout is the protocol channel. Log with `console.error` — one `console.log` corrupts the JSON-RPC stream. ::: ## Run it Start the server from the project root. ```sh npx tsx src/index.ts ``` The banner lands on stderr, leaving stdout for the protocol: ```text weather MCP server running on stdio ``` Nothing else happens: an stdio server waits on stdin for a client to start the conversation. Stop it with `Ctrl+C`. ## Call the tool The **MCP Inspector** is a local web app for calling a server's tools directly — it launches the command you give it and connects over stdio. ```sh npx @modelcontextprotocol/inspector npx tsx src/index.ts ``` In the browser tab it opens, click **Connect**, open the **Tools** tab, select `get-alerts`, enter a two-letter state code such as `TX`, and run it. The text block in the result lists each active alert headline for that state — the same content a model receives when it calls your tool. ## Pick a transport Your server speaks stdio because a host launches it as a local process and owns its lifetime. To host one endpoint that many clients connect to, serve the same `createServer` factory over [HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md) instead. Next on this path, [Plug into a real host](https://ts.sdk.modelcontextprotocol.io/v2/get-started/real-host.md) registers this server in VS Code, Claude Code, and Cursor; [Tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md) goes deeper on what a tool can return. ## Recap - `registerTool(name, config, handler)` registers a tool; `inputSchema` is the one Zod schema you write. - The SDK validates every call against that schema and rejects bad arguments before your handler runs. - `serveStdio(createServer)` builds the server from your factory and serves it on stdin/stdout. - stdout carries the protocol; log to stderr. - `npx @modelcontextprotocol/inspector ` exercises any stdio server without a host. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/get-started/real-host.md ================================================================================ # Plug into a real host A **host** is an application with a model in it — VS Code with Copilot, Claude Code, Cursor. Register the weather server from [Build your first server](https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server.md) in all three and watch the assistant call `get-alerts` on its own. ## Hand the host a launch command To attach your server, a host needs one thing — a command it can launch as a child process and talk to over that process's stdin and stdout — and `src/index.ts` already ends with the entry that speaks to those two pipes. ```ts void serveStdio(createServer); console.error('weather MCP server running on stdio'); ``` So the launch command is the one you already use: `npx tsx src/index.ts`, run from the project root. There is no build step, and there is no new server code on this page — every host below gets that one command. ::: warning stdout is the protocol channel. One `console.log` and the host drops the connection — log with `console.error`. ::: ## Register the server in VS Code Create `.vscode/mcp.json` in the `weather` project root, with one stdio entry that holds the launch command. ```json { "servers": { "weather": { "type": "stdio", "command": "npx", "args": ["tsx", "src/index.ts"] } } } ``` VS Code runs the command from the workspace root — the same directory you run it from by hand — and prompts you to trust the new server. Confirm, then run **MCP: List Servers** from the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`): `weather` shows a running status. ::: info You need VS Code 1.99 or later with the **GitHub Copilot** extension installed and signed in. [Copilot Free](https://github.com/features/copilot/plans) is enough. ::: ## Call the tool from Copilot Chat Open **Copilot Chat** (`Ctrl+Alt+I` / `Ctrl+Cmd+I`), switch the mode selector at the top of the panel to **Agent** — the only Copilot mode that calls tools — and ask about the one thing your server knows. ```text What are the active weather alerts in Texas? ``` Copilot stops to show the call it wants to make — `get-alerts` with a two-letter `state` — and waits for you to approve it. Approve, and the handler you wrote in `src/index.ts` runs; the answer in the chat is written from the text block it returned. Nothing in the prompt names the tool. The model picked `get-alerts` from its name, its description, and the JSON Schema the SDK derived from your `inputSchema`. ::: tip Click the **Tools** button in the chat panel to see the list the model is choosing from — `get-alerts` is in it, described exactly as you registered it. ::: ## Trace the round trip That one answer is six steps. 1. VS Code sends your question to the model, along with the name, description, and input schema of every available tool. 2. The model decides `get-alerts` answers the question and emits a call with the arguments it chose. 3. VS Code — the MCP **client** inside the host — sends a `tools/call` request to your server over stdio. 4. The SDK validates the arguments against your `inputSchema` and runs your handler. 5. The handler's `content` goes back over stdout as the `tools/call` result. 6. The model reads the text block and writes the answer you see in the chat. ## Connect other hosts Every MCP host launches a stdio server from the same command and arguments. Only where you put them differs. ### Claude Code Register the server from the project root; everything after `--` is the launch command. ```sh claude mcp add weather -- npx tsx src/index.ts ``` Run `/mcp` inside a Claude Code session in that directory: `weather` is connected, with `get-alerts` listed under it. The same prompt drives the same tool call. ### Cursor Create `.cursor/mcp.json` in the project root. ```json { "mcpServers": { "weather": { "command": "npx", "args": ["tsx", "src/index.ts"] } } } ``` The entry is the same `command` plus `args`; only the wrapper key and the file name change. The server appears under Cursor's MCP settings with `get-alerts` listed, and agent chat calls it the same way. ## Fix a host that does not see your tools Run the launch command by hand, from the project root, before you change any host config. ```sh npx tsx src/index.ts ``` It prints one line to stderr and then waits. ```text weather MCP server running on stdio ``` Anything else is the bug, and it has one of three causes. - The process exits or crashes: the host has nothing to attach to. Fix the command here, where you can read the error, then re-register it. - Anything besides JSON-RPC reaches stdout: the host reads it as a corrupt message and drops the connection. Find the `console.log` and make it `console.error`. - The server runs but Copilot never calls it: confirm the chat is in **Agent** mode, then run **MCP: Reset Cached Tools** from the Command Palette. A command that prints that one line to stderr and waits is one every host on this page can attach to. ## Take the server further [Tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md) covers structured output, annotations, and everything else a handler can return. [HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md) serves the same `createServer` factory as one endpoint many clients share. [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) drives it from an in-memory client — no host, no approval click. ## Recap - A host launches your server as a child process from a command plus arguments; `npx tsx src/index.ts` is that command, with no build step. - One `.vscode/mcp.json` entry — `type: stdio`, `command`, `args` — registers the server in VS Code. - In agent mode the model picks `get-alerts` from its name, description, and input schema; you never name it. - Claude Code and Cursor take the same launch command; only the config file differs. - stdout belongs to JSON-RPC — log to stderr or the host drops the connection. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-client.md ================================================================================ # Build your first client Build an MCP **client** — the program that launches a server, lists its tools, and calls them — against the weather server from [Build your first server](https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server.md). ## Connect to a server In the weather project, add the client package — it ships separately from `@modelcontextprotocol/server`. ```sh npm install @modelcontextprotocol/client ``` Create `src/client.ts`. A `Client` plus one transport is a complete MCP client. ```ts import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; const client = new Client({ name: 'my-first-client', version: '1.0.0' }); const transport = new StdioClientTransport({ command: 'npx', args: ['tsx', 'src/index.ts'] }); await client.connect(transport); ``` `connect()` spawns `npx tsx src/index.ts` as a child process, speaks JSON-RPC over its stdin and stdout, and completes the **initialize** handshake. The client owns that process from here: it lives exactly as long as the transport. ::: tip Never start `src/index.ts` yourself — `connect()` does. A `spawn npx ENOENT` error here means `command` is not an executable on your `PATH`. ::: stdio is the transport local hosts use; [Connect to a server](https://ts.sdk.modelcontextprotocol.io/v2/clients/connect.md) covers HTTP for remote servers. ## List the server's tools `listTools` returns every tool the server registered, with the JSON Schema it derived for each one's arguments. ```ts const { tools } = await client.listTools(); for (const tool of tools) { console.log(tool.name, '—', tool.description); } ``` Run what you have so far — `npx tsx src/client.ts` from the project root. The first line is the server's banner, forwarded from the child's stderr; the second is your loop. ```text weather MCP server running on stdio get-alerts — Get the active weather alerts for a US state ``` The script does not exit on its own — the client still owns a live server process. Stop it with `Ctrl+C` for now; [Close the connection](#close-the-connection) ends it properly. ## Call a tool `callTool` takes the tool's name and an `arguments` object that must satisfy its `inputSchema`. ```ts const result = await client.callTool({ name: 'get-alerts', arguments: { state: 'CA' } }); for (const block of result.content) { if (block.type === 'text') console.log(block.text); } ``` A tool result is a list of typed **content** blocks; `get-alerts` returns one `text` block. Its text is the live answer from the National Weather Service — one headline per active California alert, or `No active alerts for CA.` when there are none — so your output differs from anyone else's. A handler that throws, and arguments the `inputSchema` rejects, come back in this same shape with `isError: true` set. A tool name the server never registered is a protocol-level failure, and that one does throw out of `await callTool` — [Errors](https://ts.sdk.modelcontextprotocol.io/v2/servers/errors.md) draws the line. ::: tip Change the argument to `{ state: 'California' }` and the SDK rejects it before the handler (and the network request inside it) ever runs: ```text Input validation error: Invalid arguments for tool get-alerts: state: Too big: expected string to have <=2 characters ``` The rejection is an ordinary `isError: true` result, so a model reads the message and retries with arguments that fit. ::: ## Add a resource and read it The weather server registers no **resources** yet — a resource is data a client reads by URI, where a tool is an action it invokes. In `src/index.ts`, register one above the `return server` line. ```ts server.registerResource('about', 'weather://about', { title: 'About this server', mimeType: 'text/plain' }, async uri => ({ contents: [{ uri: uri.href, text: 'Alert data comes from the US National Weather Service.' }] })); ``` The read handler returns `contents` — a list, because one read can return several text or binary parts. [Resources](https://ts.sdk.modelcontextprotocol.io/v2/servers/resources.md) covers templates, binary contents, and subscriptions. Back in `src/client.ts`, list the resources and read the new one by its `uri`. ```ts const { resources } = await client.listResources(); console.log(resources); const { contents } = await client.readResource({ uri: 'weather://about' }); console.log(contents); ``` Run it again. After the lines you already have, the two new logs are: ``` [ { name: 'about', title: 'About this server', uri: 'weather://about', mimeType: 'text/plain' } ] [ { uri: 'weather://about', text: 'Alert data comes from the US National Weather Service.' } ] ``` `listResources` advertises the metadata you registered; `readResource` returns the handler's `contents` unchanged. ## Close the connection End the file with `close`. ```ts await client.close(); ``` `close()` ends the spawned server's stdin and kills the process if it does not exit on its own. Run the finished script once more: it prints everything above and exits without `Ctrl+C`. ::: tip In a client that can throw between `connect` and `close`, put `close()` in a `finally` block — otherwise a crash leaves the server process running. ::: ## Hand the tool list to a model Nothing on this page calls a model. The handoff is `listTools()`: each entry's `name`, `description`, and `inputSchema` — plain JSON Schema — map one-to-one onto the tool definition every tool-calling LLM API takes. Send the conversation with that list; when the model returns a tool call, pass its `name` and `arguments` to `callTool` unchanged and append `result.content` as the tool result. A host — an application with a model in it — runs that loop for you, through a client of its own. [Plug into a real host](https://ts.sdk.modelcontextprotocol.io/v2/get-started/real-host.md) registers the weather server in VS Code, Claude Code, and Cursor with no client code; `examples/cli-client` in the SDK repository is a complete, provider-neutral host built from the calls on this page. ## Recap - A `Client` plus one transport is a complete MCP client; `connect()` runs the initialize handshake. - `StdioClientTransport` spawns and owns the server process — never start it yourself. - `listTools`, `callTool`, `listResources`, and `readResource` are the client verbs; each returns a typed result. - A failed handler or rejected arguments come back as an ordinary result with `isError: true` set. - `close()` tears down the transport and the spawned process. - A model consumes `listTools()` output unchanged: `name`, `description`, `inputSchema`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/get-started/packages.md ================================================================================ # Packages and subpath exports The SDK is published as nine npm packages. Most projects install exactly one of them. ## Start from one package Everything in [Build your first server](https://ts.sdk.modelcontextprotocol.io/v2/get-started/first-server.md) came from a single install, `@modelcontextprotocol/server` — through two import paths. ```ts import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; ``` The first path is the package's root entry. The second is a **subpath export** — a separate entry point inside the same package, declared in its `exports` map. ## Pick the package for your side of the protocol Install the package for the side of the protocol you are building. ```sh npm install @modelcontextprotocol/server # expose tools, resources, prompts npm install @modelcontextprotocol/client # connect to servers and call them ``` Those two are the starting point for almost everything; a process that plays both roles installs both. The full published set is nine packages: - `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — the two starting points, one per side. - `@modelcontextprotocol/node`, `@modelcontextprotocol/express`, `@modelcontextprotocol/hono`, `@modelcontextprotocol/fastify` — optional adapters for serving over HTTP. - `@modelcontextprotocol/core` — the raw Zod wire schemas. - `@modelcontextprotocol/server-legacy` and `@modelcontextprotocol/codemod` — migration surfaces for v1 code. A tenth package in the repository, `@modelcontextprotocol/core-internal`, is private: `server` and `client` bundle it at build time, so it never appears in your dependency tree. ## Keep Node-only code behind the `./stdio` subpath `StdioClientTransport` spawns the server as a child process, so it is exported from `./stdio`, never from the root entry. ```ts // Runs anywhere: browsers, Workers, Node. import { Client } from '@modelcontextprotocol/client'; // Spawns a child process — Node-only, so it lives behind the subpath. import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; ``` The root entry of every package is **runtime-neutral**: its module graph never reaches `node:child_process` or any other Node builtin a browser or Cloudflare Workers bundler cannot resolve. Importing `./stdio` is the explicit opt-in to a process runtime. `@modelcontextprotocol/server/stdio` is the server-side counterpart and exports `serveStdio` and `StdioServerTransport`. ::: info Coming from v1? v1's single `@modelcontextprotocol/sdk` package exposed deep file paths such as `@modelcontextprotocol/sdk/server/mcp.js`. The v2 packages declare an `exports` map, so only the subpaths each package names resolve — the codemod rewrites the imports for you. ::: ## Add a framework adapter when you serve over HTTP `createMcpHandler` from `@modelcontextprotocol/server` already serves MCP over web-standard `Request` and `Response` objects. An adapter package wires that handler into a specific runtime or framework; install the adapter next to the framework it adapts. ```sh npm install @modelcontextprotocol/express express ``` Four adapters exist: `@modelcontextprotocol/node` for Node's built-in `http` server, and one each for Express, Hono, and Fastify. They are thin layers over `createMcpHandler` and add no MCP behavior of their own. [Serve over HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md) covers the handler itself; [Express](https://ts.sdk.modelcontextprotocol.io/v2/serving/express.md), [Hono](https://ts.sdk.modelcontextprotocol.io/v2/serving/hono.md), and [Fastify](https://ts.sdk.modelcontextprotocol.io/v2/serving/fastify.md) each have a recipe. ## Reach for `core` only to validate raw wire JSON `@modelcontextprotocol/core` exports the Zod schema constants the SDK validates protocol payloads against, for code that handles raw JSON-RPC payloads itself — gateways, proxies, log pipelines. Neither `server` nor `client` exports a Zod schema, and the matching TypeScript types ship with both, so if you only call `registerTool` and `callTool` you never install it. [Wire schemas](https://ts.sdk.modelcontextprotocol.io/v2/advanced/wire-schemas.md) is the how-to. ## Leave `server-legacy` and `codemod` to the migration guide `@modelcontextprotocol/server-legacy` is a frozen copy of v1's server-side SSE transport and OAuth Authorization Server helpers, published so a v1 deployment can move to v2 without replacing everything at once. `@modelcontextprotocol/codemod` is the command-line tool that rewrites v1 imports and call sites to their v2 forms. Neither belongs in a new project — the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md) covers both. ## Recap - `@modelcontextprotocol/server` and `@modelcontextprotocol/client` are the two packages you install, one per side of the protocol. - Package root entries are runtime-neutral; code that spawns processes lives at the `./stdio` subpath and only enters your bundle when you import it. - The HTTP adapters — `node`, `express`, `hono`, `fastify` — are optional thin layers over `createMcpHandler`; install at most one. - `@modelcontextprotocol/core` exports only Zod schema constants, for code that validates raw wire JSON itself. - `@modelcontextprotocol/server-legacy` and `@modelcontextprotocol/codemod` exist for migration from v1, not for new projects. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/get-started/examples.md ================================================================================ # Examples Looking for code that shows how to do something specific? Browse the [example library](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) — every example is a complete client–server pair that CI runs on every pull request, so they stay correct as the SDK changes. Or [suggest an addition](https://github.com/modelcontextprotocol/typescript-sdk/issues/new?template=v2-feedback.yml). [`cli-client/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/cli-client) and [`todos-server/`](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/todos-server) are the reference pair — a full LLM chat host and the server it drives — if you want to see everything working together in one place. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md ================================================================================ # Tools A **tool** is an action a connected client — and the model driving it — can invoke on your server. ## Add a tool `registerTool` takes a name, a config, and a handler. `inputSchema` is a Zod schema — the only schema you write. ```ts import { McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const catalog = [ { name: 'Espresso cup', price: 12 }, { name: 'Travel mug', price: 24 }, { name: 'Mug rack', price: 36 } ]; const server = new McpServer({ name: 'catalog', version: '1.0.0' }); server.registerTool( 'search', { description: 'Search the product catalog', inputSchema: z.object({ query: z.string().describe('Substring to match against product names'), limit: z.number().int().max(50).optional() }) }, async ({ query, limit }) => { const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase())); const names = hits.slice(0, limit ?? 10).map(product => product.name); return { content: [{ type: 'text', text: names.join('\n') }] }; } ); ``` From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. `tools/list` now advertises `search`, and the SDK has already parsed every call that reaches your handler. ::: tip `.describe()` survives the conversion: the JSON Schema advertised for `query` carries `Substring to match against product names` as its `description` — the only documentation the model gets for that argument. ::: ::: info Coming from v1? `registerTool` replaces `tool()` — run the codemod, then see the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: ## Call it Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring — and an MCP host does the same over stdio or HTTP. Call the tool with valid arguments. ```ts const result = await client.callTool({ name: 'search', arguments: { query: 'mug' } }); console.log(result.content); ``` The handler's `content` comes back unchanged: ``` [ { type: 'text', text: 'Travel mug\nMug rack' } ] ``` ## Send arguments the schema rejects Change one argument: a `limit` the schema caps at 50. ```ts const rejected = await client.callTool({ name: 'search', arguments: { query: 'mug', limit: 999 } }); console.log(rejected); ``` The SDK rejects the arguments before your handler runs: ``` { content: [ { type: 'text', text: 'Input validation error: Invalid arguments for tool search: limit: Too big: expected number to be <=50' } ], isError: true } ``` The rejection is an ordinary tool result with `isError: true`, so the model reads the message and retries with arguments that fit the schema. Thrown errors and protocol-level failures are their own topic — see [Errors](https://ts.sdk.modelcontextprotocol.io/v2/servers/errors.md). ## Return structured output Add `outputSchema` and return the matching value as `structuredContent`, next to the human-readable `content`. ```ts server.registerTool( 'product-details', { description: 'Look up one product by its exact name', inputSchema: z.object({ name: z.string() }), outputSchema: z.object({ name: z.string(), price: z.number() }) }, async ({ name }) => { const product = catalog.find(candidate => candidate.name === name); if (!product) throw new Error(`No product named ${name}`); const output = { name: product.name, price: product.price }; return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output }; } ); ``` The SDK validates `structuredContent` against `outputSchema` before the result leaves your server, and advertises the derived JSON Schema in `tools/list` so clients can validate it too. Calling `product-details` with `{ name: 'Travel mug' }` returns both renderings: ``` { content: [ { type: 'text', text: '{"name":"Travel mug","price":24}' } ], structuredContent: { name: 'Travel mug', price: 24 } } ``` The wire encoding of structured results differs by protocol era — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Annotate the tool `title` is the display name; `annotations` are behavior hints for the client. ```ts server.registerTool( 'clear-catalog', { title: 'Clear the catalog', description: 'Remove every product from the catalog', annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true } }, async () => { catalog.length = 0; return { content: [{ type: 'text', text: 'Catalog cleared' }] }; } ); ``` A tool that takes no arguments omits `inputSchema`. Annotations never change how the SDK runs the tool — clients use them to decide what to put in front of the end user: a host can auto-approve a read-only tool and require confirmation before a destructive one. ## Recap - `registerTool(name, config, handler)` registers a tool; `inputSchema` is a Zod object schema. - The one schema yields the advertised JSON Schema, argument validation, and the handler's argument types. - Arguments that fail the schema come back as an `isError: true` tool result; the handler never runs. - `outputSchema` plus `structuredContent` add machine-readable results, validated before they leave the server. - `title` and `annotations` describe the tool to clients and never change execution. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/resources.md ================================================================================ # Resources A **resource** is read-only data — a file, a database row, a rendered report — that a connected client lists, reads, and attaches as context for the model. The client decides what to read: resources are application-controlled, where [tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md) are model-controlled. ## Register a static resource `registerResource` takes a name, a fixed URI, metadata, and a read callback. ```ts import { McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; const server = new McpServer({ name: 'workspace', version: '1.0.0' }); server.registerResource( 'config', 'config://app', { title: 'Application Config', description: 'Application configuration data', mimeType: 'text/plain' }, async uri => ({ contents: [{ uri: uri.href, text: 'log_level=info\nregion=eu-west-1' }] }) ); ``` `resources/list` now advertises `config://app` with that metadata, and `resources/read` on `config://app` runs the callback. ::: info Coming from v1? `registerResource` replaces `resource()` — run the codemod, then see the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: ## Return the contents from the read callback The callback returns `{ contents: [...] }`. Add a resource whose contents hold two items: each one echoes the `uri` it answers for and carries either `text` or a base64 `blob`. ```ts // A 1x1 PNG; a production server reads these bytes from disk or object storage. const chartPng = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII='; server.registerResource( 'report', 'report://latest', { title: 'Latest usage report', description: 'Weekly usage summary with a rendered chart', mimeType: 'text/markdown' }, async uri => ({ contents: [ { uri: uri.href, mimeType: 'text/markdown', text: 'Active installs grew 12% week over week.' }, { uri: uri.href, mimeType: 'image/png', blob: chartPng } ] }) ); ``` Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring — and an MCP host does the same over stdio or HTTP. Read the resource. ```ts const { contents } = await client.readResource({ uri: 'report://latest' }); console.log(contents); ``` The callback's array comes back unchanged, one entry per item: ``` [ { uri: 'report://latest', mimeType: 'text/markdown', text: 'Active installs grew 12% week over week.' }, { uri: 'report://latest', mimeType: 'image/png', blob: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=' } ] ``` The `mimeType` on each item describes that item; the `mimeType` in the registration config describes the resource as a whole in `resources/list`. ## Add a resource template A `ResourceTemplate` registers a whole URI pattern instead of one URI. `list` is a required key — pass `undefined` when the instances are unbounded. ```ts server.registerResource( 'user-profile', new ResourceTemplate('users://{userId}/profile', { list: undefined }), { title: 'User Profile', description: 'Profile data for one user', mimeType: 'application/json' }, async (uri, { userId }) => ({ contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ userId, plan: 'pro' }) }] }) ); ``` The matched variables arrive parsed as the read callback's second argument. Read any URI the pattern matches. ```ts const profile = await client.readResource({ uri: 'users://7/profile' }); console.log(profile.contents); ``` The callback ran with `userId` bound to `'7'`: ``` [ { uri: 'users://7/profile', mimeType: 'application/json', text: '{"userId":"7","plan":"pro"}' } ] ``` ## List the template's instances `users://{userId}/profile` is readable but never appears in `resources/list` — with `list: undefined` there is nothing to enumerate. Register a template over an enumerable set and give it a `list` callback. ```ts server.registerResource( 'team-roster', new ResourceTemplate('teams://{teamId}/roster', { list: async () => ({ resources: [ { uri: 'teams://core/roster', name: 'Core team roster' }, { uri: 'teams://growth/roster', name: 'Growth team roster' } ] }) }), { description: 'Members of one team', mimeType: 'text/plain' }, async (uri, { teamId }) => ({ contents: [{ uri: uri.href, text: `Members of team ${teamId}` }] }) ); ``` `resources/list` merges the static resources with every template's `list` results: ```ts const { resources } = await client.listResources(); console.log(resources.map(resource => resource.uri)); ``` Both `teams://` rosters are discoverable; the `users://` template contributes nothing: ``` [ 'config://app', 'report://latest', 'teams://core/roster', 'teams://growth/roster' ] ``` `resources/templates/list` still advertises both URI patterns (`client.listResourceTemplates()`), so a client that already knows a `userId` builds the concrete URI itself. ## Sanitize file-backed paths A template variable that becomes a filesystem path is client-controlled input. Resolve it to a real path and reject anything outside the root before you read. ```ts import { readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; const DOCS_ROOT = path.resolve('./docs'); server.registerResource( 'doc', new ResourceTemplate('docs://{file}', { list: undefined }), { description: 'A markdown page from the docs directory', mimeType: 'text/markdown' }, async (uri, { file }) => { const requested = await realpath(path.join(DOCS_ROOT, String(file))); if (!requested.startsWith(DOCS_ROOT + path.sep)) { throw new Error(`${uri.href} resolves outside the docs root`); } return { contents: [{ uri: uri.href, text: await readFile(requested, 'utf8') }] }; } ); ``` `realpath` collapses `..` segments and symlinks to the path that is actually on disk; the `startsWith` check then rejects anything that escaped `DOCS_ROOT`. Throw on rejection — [Errors](https://ts.sdk.modelcontextprotocol.io/v2/servers/errors.md) covers how a thrown error reaches the client. ::: warning Never pass a template variable or a client-supplied URI to a filesystem API unchecked. `..` arrives raw and percent-encoded, and a symlink inside the root can point outside it — compare resolved real paths, never the strings the client sent. ::: ## Tell clients when a resource changes Registering, enabling, disabling, or removing a resource already sends `notifications/resources/list_changed`. Send it yourself when the set changes for a reason the SDK cannot see. ```ts server.sendResourceListChanged(); ``` The notification tells connected clients to call `resources/list` again. A change to one resource's content is a different signal, `notifications/resources/updated` — [Notifications](https://ts.sdk.modelcontextprotocol.io/v2/servers/notifications.md) covers both. ## Serve per-resource subscriptions A 2025-era client opts into `notifications/resources/updated` for one URI with `resources/subscribe`. The SDK routes the verb; the bookkeeping is yours: advertise the capability, track the URIs per connection, and send the notification to subscribers only. ```ts let deployStatus = 'idle'; const deploys = new McpServer({ name: 'deploys', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); deploys.registerResource( 'deploy-status', 'deploys://status', { description: 'The current deploy state', mimeType: 'text/plain' }, async uri => ({ contents: [{ uri: uri.href, text: deployStatus }] }) ); // The SDK routes the two verbs; which URIs this connection watches is yours to track. const subscribedUris = new Set(); deploys.server.setRequestHandler('resources/subscribe', request => { subscribedUris.add(request.params.uri); return {}; }); deploys.server.setRequestHandler('resources/unsubscribe', request => { subscribedUris.delete(request.params.uri); return {}; }); async function setDeployStatus(status: string): Promise { deployStatus = status; if (subscribedUris.has('deploys://status')) { await deploys.server.sendResourceUpdated({ uri: 'deploys://status' }); } } ``` The `Set` belongs to one server instance, and each connection gets its own instance from your factory — a subscription never leaks across connections. Send `resources/updated` only to connections that subscribed; unsolicited per-resource updates are wrong on 2025-era connections. The pattern needs a connection that outlives the subscribe call: over stdio (and any sessionful wiring) the instance and its `Set` live as long as the connection. Behind `createMcpHandler`'s stateless legacy fallback each POST gets a fresh instance, so `resources/subscribe` succeeds and the `Set` is discarded with it — no update can ever be delivered on that posture. [Support legacy clients](https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients.md) covers the serving postures. ::: info On [2026-07-28](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md) connections the verb does not exist: clients name resource URIs in their `subscriptions/listen` filter, and the entry filters delivery itself — `serveStdio` routes the instance's own `sendResourceUpdated` call onto matching streams, and `createMcpHandler` delivers what you publish on its notifier ([Notifications](https://ts.sdk.modelcontextprotocol.io/v2/servers/notifications.md#publish-a-resource-update-through-the-handler)). ::: A dual-era server therefore still calls `sendResourceUpdated` on 2026-07-28 connections, where the subscribe set is always empty — gate on the connection's era as well as the set. The [`resources` example](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples/resources) guards with `reqCtx.era === 'modern' || subscribedUris.has(uri)` in its factory and runs as a self-verifying pair: delivery is asserted over stdio on both eras and over HTTP on the 2026-07-28 listen path; the stateless legacy HTTP leg asserts only that the subscribe calls succeed. ## Recap - `registerResource(name, uri, config, readCallback)` registers a resource at a fixed URI. - The read callback returns `{ contents: [...] }`; each item echoes the `uri` and carries `text` or a base64 `blob`. - A `ResourceTemplate` registers a URI pattern; the matched variables arrive parsed as the read callback's second argument. - A template's `list` callback is what makes its instances appear in `resources/list`. - Resolve file-backed paths to their real location and reject anything outside the root before reading. - Registration changes emit `notifications/resources/list_changed` automatically. - `resources/subscribe` bookkeeping is the server's: advertise `resources: { subscribe: true }`, track URIs per connection, send `resources/updated` to subscribers only. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/prompts.md ================================================================================ # Prompts A **prompt** is a message template a connected client invokes by name. Clients surface prompts directly to people — slash commands, menu entries — where [tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md) are picked by the model. ## Register a prompt `registerPrompt` takes a name, a config, and a callback that returns the messages. `argsSchema` is a Zod object schema describing the arguments. ```ts import { McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const server = new McpServer({ name: 'review', version: '1.0.0' }); server.registerPrompt( 'review-code', { title: 'Code Review', description: 'Review code for best practices and potential issues', argsSchema: z.object({ code: z.string().describe('The code to review') }) }, ({ code }) => ({ messages: [ { role: 'user' as const, content: { type: 'text' as const, text: `Review this code:\n\n${code}` } } ] }) ); ``` `prompts/list` now advertises `review-code` with one required argument, `code`. ::: tip `.describe()` survives the conversion: `prompts/list` carries `The code to review` as the `code` argument's `description` — what a client shows next to the input field. ::: ::: info Coming from v1? `registerPrompt` replaces `prompt()` — run the codemod, then see the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring — and an MCP host does the same when someone picks the prompt. Fetch it with `getPrompt`. ```ts const result = await client.getPrompt({ name: 'review-code', arguments: { code: 'let x = 1' } }); console.log(result.messages); ``` The callback's messages come back with the argument filled in: ``` [ { role: 'user', content: { type: 'text', text: 'Review this code:\n\nlet x = 1' } } ] ``` ## Validate the arguments with the schema Drop the required argument. ```ts import type { ProtocolError } from '@modelcontextprotocol/client'; try { await client.getPrompt({ name: 'review-code', arguments: {} }); } catch (error) { const { code, message } = error as ProtocolError; console.log(code, message); } ``` The SDK rejects the request before your callback runs: ``` -32602 Invalid arguments for prompt review-code: code: Invalid input: expected string, received undefined ``` A failed prompt validation is a protocol error — `getPrompt` rejects with a `ProtocolError` carrying code `-32602` (Invalid params). A [tool](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md) argument rejection comes back as an `isError: true` result instead. From that one schema the SDK derives the argument list `prompts/list` advertises, validates `prompts/get` arguments before your callback runs, and infers the callback's argument types. ## Build the messages The callback returns `{ messages }`. Each message names a `role` — `'user'` or `'assistant'` — and one `content` block; add an `assistant` message after the `user` message to seed how the reply starts. ```ts server.registerPrompt( 'explain-error', { description: 'Explain a compiler error and suggest the smallest fix', argsSchema: z.object({ error: z.string() }) }, ({ error }) => ({ messages: [ { role: 'user' as const, content: { type: 'text' as const, text: `Explain this compiler error:\n\n${error}` } }, { role: 'assistant' as const, content: { type: 'text' as const, text: 'The one-line cause:' } } ] }) ); ``` The host hands the messages to the model in order, so the trailing `assistant` message becomes the start of its reply. `content` accepts the same union a tool result does: `text`, `image`, `audio`, `resource_link`, and `resource`. ## Embed a resource in a message `type: 'resource'` puts a resource's contents inside a message. Register the resource as usual — see [Resources](https://ts.sdk.modelcontextprotocol.io/v2/servers/resources.md) — and embed the same `uri`, `mimeType`, and `text` in the prompt. ```ts const styleGuide = '- Prefer const over let.\n- No single-letter identifiers.'; server.registerResource('style-guide', 'doc://style-guide', { mimeType: 'text/markdown' }, async uri => ({ contents: [{ uri: uri.href, mimeType: 'text/markdown', text: styleGuide }] })); server.registerPrompt( 'review-against-style', { description: 'Review code against the team style guide', argsSchema: z.object({ code: z.string() }) }, ({ code }) => ({ messages: [ { role: 'user' as const, content: { type: 'resource' as const, resource: { uri: 'doc://style-guide', mimeType: 'text/markdown', text: styleGuide } } }, { role: 'user' as const, content: { type: 'text' as const, text: `Review this code against the style guide:\n\n${code}` } } ] }) ); ``` `prompts/get` returns the style guide inline as the first message, so the client never makes a second `resources/read` round trip: ``` { role: 'user', content: { type: 'resource', resource: { uri: 'doc://style-guide', mimeType: 'text/markdown', text: '- Prefer const over let.\n- No single-letter identifiers.' } } } ``` The `uri` tells the client which registered resource the embedded copy came from. ## Offer argument autocompletion Wrap an argument with `completable()` to suggest values while a client fills in the form. ```ts import { completable } from '@modelcontextprotocol/server'; server.registerPrompt( 'translate', { description: 'Translate a snippet into another language', argsSchema: z.object({ language: completable(z.string(), value => ['typescript', 'python', 'rust', 'go'].filter(language => language.startsWith(value)) ), code: z.string() }) }, ({ language, code }) => ({ messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `Translate to ${language}:\n\n${code}` } }] }) ); ``` The client sends `completion/complete` with the characters typed so far; the SDK runs your function and returns the matching values. [Completion](https://ts.sdk.modelcontextprotocol.io/v2/servers/completion.md) covers the request flow and context-aware suggestions. ## Recap - `registerPrompt(name, config, callback)` registers a prompt; clients discover it through `prompts/list`. - `argsSchema` is one Zod object: the advertised argument list, argument validation, and the callback's argument types. - Arguments that fail the schema reject `prompts/get` with a `-32602` protocol error; the callback never runs. - The callback returns `{ messages }`; each message names a `role` and one `content` block. - A message can embed a registered resource's contents with `type: 'resource'`. - `completable()` adds per-argument autocompletion. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/completion.md ================================================================================ # Completion **Completion** is server-side autocomplete for prompt arguments and resource template variables: the client sends the partial value the user has typed so far, your callback returns the matching suggestions. ## Wrap an argument with `completable` `completable` wraps one field of a [prompt's](https://ts.sdk.modelcontextprotocol.io/v2/servers/prompts.md) `argsSchema` — the schema validates exactly as before, and the second argument suggests values for the field. ```ts import { completable, McpServer, ResourceTemplate } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const languages = ['typescript', 'javascript', 'python', 'rust', 'go']; const server = new McpServer({ name: 'review', version: '1.0.0' }); server.registerPrompt( 'review-code', { description: 'Review code for best practices', argsSchema: z.object({ language: completable(z.string().describe('Programming language'), value => languages.filter(language => language.startsWith(value)) ) }) }, ({ language }) => ({ messages: [ { role: 'user' as const, content: { type: 'text' as const, text: `Review this ${language} code for best practices.` } } ] }) ); ``` The first `completable` field also registers the server's `completion/complete` handler and advertises the **completions** capability — nothing to declare. A request for completions of `language` with the partial value `ty` now returns `typescript`. Every result quoted on this page comes from `client.complete()` on an in-memory `Client` connected to this server — [Try it from a client](#try-it-from-a-client) shows the call, and [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows the wiring. ## Return suggestions from the complete callback The callback receives the value typed so far and returns every match — `string[]`, or a promise of one when the lookup is async. Register a second prompt whose `repo` argument completes from an async list. ```ts const branchesByRepo: Record = { 'typescript-sdk': ['main', 'release/1.x', 'release/2.x'], 'python-sdk': ['main', 'release/1.x'], inspector: ['main'] }; async function listRepos(): Promise { return Object.keys(branchesByRepo); } server.registerPrompt( 'review-pr', { description: 'Review the open pull requests on one branch', argsSchema: z.object({ repo: completable(z.string().describe('Repository name'), async value => { const repos = await listRepos(); return repos.filter(repo => repo.startsWith(value)); }), branch: completable(z.string().describe('Target branch'), completeBranch) }) }, ({ repo, branch }) => ({ messages: [ { role: 'user' as const, content: { type: 'text' as const, text: `Review the open pull requests on ${repo}@${branch}.` } } ] }) ); ``` Return the full match list; the SDK truncates `values` to 100 entries and fills in `total` and `hasMore`. Completing `repo` with the value `ty` returns: ``` { values: [ 'typescript-sdk' ], total: 1, hasMore: false } ``` `branch` points at `completeBranch`, defined in the next section. ## Use the other arguments for context The callback's optional second parameter carries `arguments`: the values the client has already filled in for the prompt's other arguments. Use it to make one field's suggestions depend on another's. ```ts async function completeBranch(value: string, context?: { arguments?: Record }): Promise { const repo = context?.arguments?.repo; if (!repo) return []; return (branchesByRepo[repo] ?? []).filter(branch => branch.startsWith(value)); } ``` With `repo: 'typescript-sdk'` already filled in, completing `branch` with the value `rel` returns: ``` { values: [ 'release/1.x', 'release/2.x' ], total: 2, hasMore: false } ``` Clients are not required to send `context` — return an empty list when it is missing, never throw. ## Complete a resource template variable [Resource template](https://ts.sdk.modelcontextprotocol.io/v2/servers/resources.md) variables complete through the template's `complete` map — one callback per URI variable, with the same `(value, context?)` signature. `completable` is for prompt arguments only. ```ts server.registerResource( 'readme', new ResourceTemplate('repo://{repo}/readme', { list: undefined, complete: { repo: async value => { const repos = await listRepos(); return repos.filter(repo => repo.startsWith(value)); } } }), { description: 'The README of one repository' }, async (uri, { repo }) => ({ contents: [{ uri: uri.href, text: `Repository: ${repo}` }] }) ); ``` The completion request targets the template by its URI pattern: `ref: { type: 'ref/resource', uri: 'repo://{repo}/readme' }` with `argument: { name: 'repo', value: 'py' }` returns `python-sdk`. ## Try it from a client `Client.complete()` sends `completion/complete`. Complete `language` on `review-code` with an empty value to get the whole list. ```ts const result = await client.complete({ ref: { type: 'ref/prompt', name: 'review-code' }, argument: { name: 'language', value: '' } }); console.log(result.completion); ``` The server's callback ran once and produced every value: ``` { values: [ 'typescript', 'javascript', 'python', 'rust', 'go' ], total: 5, hasMore: false } ``` ::: tip A host issues the same request as the end user types: MCP Inspector requests completions while you fill in a prompt's arguments and shows the returned `values` as suggestions. ::: ## Recap - `completable(schema, callback)` attaches autocompletion to one prompt argument; the schema validates exactly as before. - The callback receives the partial value and returns the full match list, synchronously or as a promise; the SDK caps `values` at 100 and sets `total` and `hasMore`. - `context.arguments` carries the prompt's already-filled arguments, so one field's suggestions can depend on another's. - Resource template variables complete through the template's `complete` map, not `completable`. - The first completable registration advertises the `completions` capability; `Client.complete()` sends the request. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/logging-progress-cancellation.md ================================================================================ # Logging, progress, and cancellation Every handler receives a **context** as its second argument; the request-scoped helpers — progress, logging, and the cancellation signal — live on `ctx.mcpReq`. ## Report progress from a handler A client that wants progress puts a `progressToken` in the request's `_meta`. Read it from `ctx.mcpReq._meta` and send each update with `ctx.mcpReq.notify`. ```ts server.registerTool( 'process-files', { description: 'Process files with progress updates', inputSchema: z.object({ files: z.array(z.string()) }) }, async ({ files }, ctx) => { const progressToken = ctx.mcpReq._meta?.progressToken; for (let i = 0; i < files.length; i++) { // ... process files[i] ... if (progressToken !== undefined) { await ctx.mcpReq.notify({ method: 'notifications/progress', params: { progressToken, progress: i + 1, total: files.length, message: `Processed ${files[i]}` } }); } } return { content: [{ type: 'text', text: `Processed ${files.length} files` }] }; } ); ``` Every call on this page comes from an in-memory `Client` connected to this server — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring. Pass an `onprogress` callback and the SDK puts the `progressToken` in `_meta` for you. ```ts const result = await client.callTool( { name: 'process-files', arguments: { files: ['a.csv', 'b.csv', 'c.csv'] } }, { onprogress: update => console.log(update) } ); console.log(result.content); ``` The callback fires once per file, then the call resolves: ``` { progress: 1, total: 3, message: 'Processed a.csv' } { progress: 2, total: 3, message: 'Processed b.csv' } { progress: 3, total: 3, message: 'Processed c.csv' } [ { type: 'text', text: 'Processed 3 files' } ] ``` ## Skip progress when the client did not ask Drop `onprogress` and the same request arrives with no `progressToken`, so the guard in the handler sends nothing. ```ts const quiet = await client.callTool({ name: 'process-files', arguments: { files: ['d.csv', 'e.csv'] } }); console.log(quiet.content); ``` Only the result comes back: ``` [ { type: 'text', text: 'Processed 2 files' } ] ``` `progress` must increase on every notification for the same token; `total` and `message` are optional. ## Log to the client ::: warning Deprecated — SEP-2577 Log to `stderr` (stdio servers) or use OpenTelemetry instead. **MCP logging** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional through the deprecation window (at least twelve months) — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). ::: Declare the `logging` capability when you construct the server. ```ts const server = new McpServer({ name: 'file-processor', version: '1.0.0' }, { capabilities: { logging: {} } }); ``` `ctx.mcpReq.log(level, data)` then sends a `notifications/message` from inside any handler — `data` is any JSON value. ```ts server.registerTool( 'validate-records', { description: 'Validate records before import', inputSchema: z.object({ records: z.array(z.string()) }) }, async ({ records }, ctx) => { await ctx.mcpReq.log('info', `Validating ${records.length} records`); const invalid = records.filter(record => !record.endsWith('.csv')); if (invalid.length > 0) { await ctx.mcpReq.log('warning', { invalid }); } return { content: [{ type: 'text', text: `${records.length - invalid.length} of ${records.length} records are valid` }] }; } ); ``` The connected client surfaces each one through its `notifications/message` handler. ```ts client.setNotificationHandler('notifications/message', notification => { console.log(notification.params.level, notification.params.data); }); ``` Calling `validate-records` with one bad record delivers both log notifications before the result: ``` info Validating 2 records warning { invalid: [ 'b.txt' ] } [ { type: 'text', text: '1 of 2 records are valid' } ] ``` How the client's log level reaches `ctx.mcpReq.log` differs by protocol era — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Stop work when the request is cancelled `ctx.mcpReq.signal` is an `AbortSignal`. The SDK aborts it when the client sends `notifications/cancelled` for the request, and when the connection closes — check it before each unit of work. ```ts server.registerTool( 'scan-archive', { description: 'Scan every page of the archive', inputSchema: z.object({ pages: z.number().int() }) }, async ({ pages }, ctx) => { let scanned = 0; for (let page = 0; page < pages; page++) { if (ctx.mcpReq.signal.aborted) { console.error(`Stopped after ${scanned} of ${pages} pages: ${ctx.mcpReq.signal.reason}`); break; } await new Promise(resolve => setTimeout(resolve, 100)); // ... scan one page ... scanned++; } return { content: [{ type: 'text', text: `Scanned ${scanned} pages` }] }; } ); ``` Cancel from the client by aborting the signal you pass to the call. ```ts const controller = new AbortController(); const scan = client.callTool({ name: 'scan-archive', arguments: { pages: 40 } }, { signal: controller.signal }); // the end user clicks Stop while the scan runs setTimeout(() => controller.abort('the end user clicked Stop'), 5); await scan.catch(error => console.log(String(error))); ``` The call rejects on the client and the handler stops at its next check; the abort reason travels in the notification and comes out as `ctx.mcpReq.signal.reason`: ``` SdkError: the end user clicked Stop Stopped after 1 of 40 pages: the end user clicked Stop ``` The first line is the client's rejection, the second is the handler's `console.error` on the server. The SDK never sends a response for a cancelled request and discards whatever the handler still returns. ## Pass the signal to your own I/O Hand the same signal to `fetch` — or any API that accepts an `AbortSignal` — and cancellation propagates into the work the handler started. ```ts const SOURCE_URLS = { readme: 'https://example.com/sources/readme.md', changelog: 'https://example.com/sources/changelog.md' }; server.registerTool( 'fetch-source', { description: 'Download one of the known source files', inputSchema: z.object({ source: z.enum(['readme', 'changelog']) }) }, async ({ source }, ctx) => { const response = await fetch(SOURCE_URLS[source], { signal: ctx.mcpReq.signal }); return { content: [{ type: 'text', text: await response.text() }] }; } ); ``` On cancellation `fetch` rejects mid-download and the handler unwinds with it, so no work outlives the request. ::: warning Resolve an identifier against a fixed list, as `fetch-source` does. A tool that fetches a caller-supplied URL lets any connected client drive requests from your server's network position (server-side request forgery). ::: ## Recap - Every handler receives a context as its second argument; the request-scoped helpers live on `ctx.mcpReq`. - `ctx.mcpReq.notify` sends `notifications/progress` when the request carried a `progressToken`; `progress` must increase on each one. - `ctx.mcpReq.log(level, data)` sends `notifications/message` once the `logging` capability is declared; MCP logging is deprecated (SEP-2577). - `ctx.mcpReq.signal` aborts on cancellation and disconnect — check it in long loops and forward it to your own I/O. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/elicitation.md ================================================================================ # Elicitation A tool handler asks the end user a question mid-call with `ctx.mcpReq.elicitInput` — the connected client puts the question in front of them and the promise resolves with their answer. ## Ask for input with a form **Form mode** carries a `message` and a `requestedSchema`: a flat JSON Schema of primitive fields the client renders as a form. ```ts import { McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const server = new McpServer({ name: 'feedback', version: '1.0.0' }); server.registerTool( 'collect-feedback', { description: 'Ask the user how something went', inputSchema: z.object({ topic: z.string() }) }, async ({ topic }, ctx) => { const result = await ctx.mcpReq.elicitInput({ mode: 'form', message: `How was ${topic}?`, requestedSchema: { type: 'object', properties: { rating: { type: 'number', title: 'Rating (1-5)', minimum: 1, maximum: 5 }, comment: { type: 'string', title: 'Comment' } }, required: ['rating'] } }); if (result.action !== 'accept') { return { content: [{ type: 'text', text: `Feedback ${result.action}.` }] }; } return { content: [{ type: 'text', text: `Recorded: ${JSON.stringify(result.content)}` }] }; } ); ``` `result.action` records what the end user did — `accept`, `decline`, or `cancel` — and `result.content` carries the submitted fields on accept only. The SDK validates accepted content against `requestedSchema` before `elicitInput` resolves, so the fields you read match the schema you sent. ::: info On a 2026-07-28 connection `elicitInput` throws — a handler returns the request instead; see [Input required](https://ts.sdk.modelcontextprotocol.io/v2/servers/input-required.md) and [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ::: The answer comes from the connected client's `elicitation/create` handler. Every call on this page uses an in-memory client whose handler stands in for a real host's UI — [Handle requests from the server](https://ts.sdk.modelcontextprotocol.io/v2/clients/server-requests.md) covers the client side in full. ```ts const client = new Client({ name: 'feedback-host', version: '1.0.0' }, { capabilities: { elicitation: { form: {}, url: {} } } }); client.setRequestHandler('elicitation/create', async request => { if (request.params.mode === 'url') { // Open request.params.url in the user's browser; answer when they finish. return { action: 'accept' }; } // Render request.params.requestedSchema as a form; return what the user typed. return { action: 'accept', content: { rating: 5, comment: 'Smooth setup' } }; }); ``` Call `collect-feedback` and the elicitation round-trips through that handler inside the one tool call. ```ts const result = await client.callTool({ name: 'collect-feedback', arguments: { topic: 'the new editor' } }); console.log(result.content); ``` The handler resumes with the submitted fields and returns: ``` [ { type: 'text', text: 'Recorded: {"rating":5,"comment":"Smooth setup"}' } ] ``` ## Handle every action Return a distinct result for each `action` so the model knows whether the end user confirmed, refused, or never answered. ```ts server.registerTool( 'delete-dataset', { description: 'Delete a dataset after the user confirms', inputSchema: z.object({ name: z.string() }) }, async ({ name }, ctx) => { const result = await ctx.mcpReq.elicitInput({ mode: 'form', message: `Delete ${name}? This cannot be undone.`, requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean', title: 'Yes, delete it' } }, required: ['confirm'] } }); switch (result.action) { case 'accept': if (result.content?.confirm !== true) { return { content: [{ type: 'text', text: 'Box left unchecked - nothing deleted.' }] }; } return { content: [{ type: 'text', text: `Deleted ${name}.` }] }; case 'decline': return { content: [{ type: 'text', text: 'Declined - nothing deleted.' }] }; case 'cancel': return { content: [{ type: 'text', text: 'Dismissed - ask again later.' }] }; } } ); ``` `result.content` is end-user input: schema-valid, still untrusted — the `accept` branch checks that the box was actually ticked before acting. Decline the form and the tool answers from the `decline` branch: ``` [ { type: 'text', text: 'Declined - nothing deleted.' } ] ``` ## Send the end user to a URL **URL mode** replaces the form with a browser flow: pass `url` and a unique `elicitationId` instead of `requestedSchema`. ```ts server.registerTool( 'link-account', { description: 'Link a billing account through a hosted sign-in flow', inputSchema: z.object({ provider: z.string() }) }, async ({ provider }, ctx) => { const result = await ctx.mcpReq.elicitInput({ mode: 'url', message: `Sign in to ${provider} to link your account`, url: `https://billing.example.com/connect/${encodeURIComponent(provider)}`, elicitationId: crypto.randomUUID() }); if (result.action !== 'accept') { return { content: [{ type: 'text', text: `Sign-in ${result.action}.` }] }; } return { content: [{ type: 'text', text: `Linked ${provider}.` }] }; } ); ``` The client opens the URL and answers once the end user finishes there; whatever the page collects — credentials, payment details, API keys — stays in the browser and never crosses the MCP connection. The handler's `url` branch above accepts, so `link-account` returns: ``` [ { type: 'text', text: 'Linked github.' } ] ``` ## Keep secrets out of forms Form answers travel back through the client and land in the model's context like any other tool result. ::: warning Never collect sensitive information — passwords, API keys, payment details — through form elicitation. Use URL mode or an out-of-band flow instead. ::: ## Require the elicitation capability Elicitation only works against a client that declared the `elicitation` capability — per mode: `form`, `url` — when it connected. Against a client without it, `elicitInput` throws before anything reaches the wire, and the thrown message comes back as an ordinary `isError` tool result: ``` { content: [ { type: 'text', text: 'Client does not support form elicitation.' } ], isError: true } ``` ## Recap - `ctx.mcpReq.elicitInput` sends an `elicitation/create` request mid-handler and resolves with the end user's answer. - Form mode carries a `message` and a flat JSON-Schema `requestedSchema`; the SDK validates accepted content against it. - `result.action` is `accept`, `decline`, or `cancel`; `result.content` is present only on accept. - URL mode hands the end user a browser flow — use it for anything sensitive. - Calls against a client that never declared the `elicitation` capability fail before reaching the wire. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/sampling.md ================================================================================ # Sampling ::: warning Deprecated — SEP-2577 Call your LLM provider's API directly from your server instead. **Sampling** is deprecated as of protocol version 2026-07-28 (SEP-2577) and stays functional on 2025-era connections for at least twelve months — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). ::: ## Replace sampling with a direct provider call Sampling routes an LLM call through the connected client: a tool handler sends a prompt, the host runs it through a model it controls, and the handler resumes with the completion. The 2026-07-28 revision removes the server-to-client request channel that carries it. Migrate by importing your LLM provider's SDK into the server and calling it from the tool handler with your own API key. The handler keeps its shape; the `requestSampling` call is the only line that changes, and you stop depending on what the client supports. ## Request a completion from the client `ctx.mcpReq.requestSampling` sends a `sampling/createMessage` request to the connected client from inside a tool handler. The client runs the messages through its model and resolves the call with the completion. ```ts server.registerTool( 'summarize', { description: 'Summarize text using the client LLM', inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx) => { const response = await ctx.mcpReq.requestSampling({ messages: [{ role: 'user', content: { type: 'text', text: `Summarize in one sentence:\n\n${text}` } }], maxTokens: 500 }); return { content: [{ type: 'text', text: `Model (${response.model}): ${JSON.stringify(response.content)}` }] }; } ); ``` The handler blocks until the client answers, so your server never holds the key for the model that does the work — the host does. ::: info On a 2026-07-28 connection `requestSampling` throws. The replacement on that revision is returning an embedded `createMessage` request from the handler — [input_required](https://ts.sdk.modelcontextprotocol.io/v2/servers/input-required.md) owns that form. Era differences are listed in [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ::: ## Read the model's reply The response is a `CreateMessageResult`: the client decides which model fulfils the request and returns its name as `model`, plus the assistant `role` and one `content` block. The handler above folds it into its tool result, so calling `summarize` from a client whose model is named `host-model` returns: ``` [ { type: 'text', text: 'Model (host-model): {"type":"text","text":"Sampling lets a tool ask the client for a completion."}' } ] ``` ## Require the sampling capability `requestSampling` only works against a client that declared the `sampling` capability and registered a `sampling/createMessage` handler — [Handle requests from the server](https://ts.sdk.modelcontextprotocol.io/v2/clients/server-requests.md) covers that side. Pass `enforceStrictCapabilities: true` to the `McpServer` constructor and the SDK checks the client's declared capabilities before it sends any server-initiated request. Against a client that never declared `sampling`, `requestSampling` then throws inside your handler, and the call comes back as an ordinary `isError` tool result: ``` { content: [ { type: 'text', text: 'Client does not support sampling (required for sampling/createMessage)' } ], isError: true } ``` ## Recap - Sampling is deprecated (SEP-2577); the migration target is a direct LLM provider call from your server. - `ctx.mcpReq.requestSampling({ messages, maxTokens })` asks the connected client's model for a completion mid-handler. - The client picks the model; the result carries `model`, `role`, and `content`. - On a 2026-07-28 connection `requestSampling` throws; the embedded-request form lives on the input_required page. - The client must declare the `sampling` capability; `enforceStrictCapabilities: true` rejects the request before the wire when it did not. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/input-required.md ================================================================================ # input_required An **`input_required`** result is how a `tools/call`, `prompts/get`, or `resources/read` handler asks the connected client for input mid-call: the handler returns the embedded requests, the client answers them and retries the call, and the handler runs again with the responses. ## Return `input_required` instead of pushing a request The handler reads what already arrived with `acceptedContent`; while the answer is missing it returns `inputRequired(...)` instead of a tool result. ```ts const confirmationSchema = z.object({ confirm: z.boolean().meta({ title: 'Confirm deployment' }) }); server.registerTool( 'deploy', { description: 'Deploy after the operator confirms', inputSchema: z.object({ env: z.string() }) }, async ({ env }, ctx): Promise => { const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', confirmationSchema); if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: `Deploy to ${env}?`, requestedSchema: confirmationSchema }) } }); } return { content: [{ type: 'text', text: `Deployed to ${env}` }] }; } ); ``` The first round converts `confirmationSchema` to MCP's restricted elicitation JSON Schema and returns it inside `resultType: 'input_required'`. The client fulfils the request and retries `deploy`; on re-entry `acceptedContent` validates the answer with that same schema and the handler finishes. The restricted wire schema is a flat object of primitive properties, so only schemas that convert to that shape are accepted: strings (including the `email`, `uri`, `date`, and `date-time` formats — `z.email()`, `z.iso.date()`, and friends), numbers and their inclusive bounds (`.min()`/`.max()`; exclusive bounds like `.positive()` or `.gt()` do not convert), booleans, enums (`z.enum` or `z.literal(['a', 'b'])` — a union of literals does not convert), multi-select enum arrays, `.optional()`, and `.default()`. Anything the wire cannot express — nested objects, `.regex()` patterns, customized zod format patterns (`z.email({ pattern })`) — throws a `TypeError` when the request is built, before anything is sent. For non-zod libraries a pattern accompanying a supported format is treated as the library's own format regex and dropped from the wire. Constraints the wire cannot advertise at all (refinements, transforms) still hold on re-entry, because `acceptedContent` validates with the original schema. Every call on this page comes from an in-memory `Client` with an `elicitation/create` handler — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring. Calling `deploy` once produces both rounds: ``` [client] elicitation/create → Deploy to prod? { content: [ { type: 'text', text: 'Deployed to prod' } ] } ``` `inputRequired(spec)` throws a `TypeError` unless `spec` carries at least one of `inputRequests` or `requestState`. Each embedded request is checked against the capabilities the client declared; a missing capability rejects the call with `-32021` before anything reaches the wire. ::: info Coming from v1? `ctx.mcpReq.elicitInput` and `ctx.mcpReq.requestSampling` are the 2025-era push channels — they throw on a 2026-07-28 request. See [Elicitation](https://ts.sdk.modelcontextprotocol.io/v2/servers/elicitation.md) and the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: ## Read the responses on re-entry `ctx.mcpReq.inputResponses` comes from the client — treat it as untrusted. Pass a Zod schema as `acceptedContent`'s third argument and the value reaches your handler already validated and typed. ```ts server.registerTool( 'tag-release', { description: 'Tag a release after the operator confirms', inputSchema: z.object({ tag: z.string() }) }, async ({ tag }, ctx): Promise => { const view = inputResponse(ctx.mcpReq.inputResponses, 'confirm'); if (view.kind === 'elicit' && view.action !== 'accept') { return { content: [{ type: 'text', text: 'Tagging cancelled by the operator' }], isError: true }; } const confirmed = acceptedContent(ctx.mcpReq.inputResponses, 'confirm', z.object({ confirm: z.boolean() })); if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: `Tag ${tag}?`, requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } }) } }); } return { content: [{ type: 'text', text: `Tagged ${tag}` }] }; } ); ``` `acceptedContent` returns `undefined` for a missing, declined, or cancelled answer alike — re-issuing the request is the right move for all three only when the request is idempotent. `inputResponse` returns a discriminated view (`missing` / `elicit` / `sampling` / `roots`) when you need to tell a refusal from a first entry. A client that declines: ``` [client] elicitation/create → Tag v2.1.0? { content: [ { type: 'text', text: 'Tagging cancelled by the operator' } ], isError: true } ``` ## Write the handler write-once Write one handler that runs on every round: read each answer first, then request only the keys still missing. `inputRequests` is a map, so one round carries every outstanding request. ```ts server.registerTool( 'provision', { description: 'Provision a database', inputSchema: z.object({}) }, async (_args, ctx): Promise => { const name = acceptedContent(ctx.mcpReq.inputResponses, 'name', z.object({ name: z.string() })); const region = acceptedContent(ctx.mcpReq.inputResponses, 'region', z.object({ region: z.string() })); if (name === undefined || region === undefined) { return inputRequired({ inputRequests: { ...(name === undefined && { name: inputRequired.elicit({ message: 'Database name?', requestedSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } }) }), ...(region === undefined && { region: inputRequired.elicit({ message: 'Which region?', requestedSchema: { type: 'object', properties: { region: { type: 'string' } }, required: ['region'] } }) }) } }); } return { content: [{ type: 'text', text: `Provisioned ${name.name} in ${region.region}` }] }; } ); ``` Round one finds neither key, so both requests go out together; round two finds both and the handler returns. ``` [client] elicitation/create → Database name? [client] elicitation/create → Which region? { content: [ { type: 'text', text: 'Provisioned analytics in eu-west-1' } ] } ``` `inputResponses` holds only the latest round's answers, and nothing else on the server survives between rounds. A flow whose rounds must run in **sequence** carries what it has learned in `requestState`, below. ## Pick the embedded request kind Each value in `inputRequests` is one embedded request, named by the builder that constructs it: `inputRequired.elicit` (form), `inputRequired.elicitUrl` (out-of-band URL), `inputRequired.createMessage` (sampling), and `inputRequired.listRoots()`. ```ts const next = inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: 'Continue?', requestedSchema: { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'] } }), signin: inputRequired.elicitUrl({ message: 'Sign in to continue', url: 'https://example.com/auth' }), summary: inputRequired.createMessage({ messages: [{ role: 'user', content: { type: 'text', text: 'Summarize the diff' } }], maxTokens: 200 }), roots: inputRequired.listRoots() } }); ``` `acceptedContent` only reads accepted form elicitations; read the sampling and roots responses through `inputResponse`, which discriminates all four kinds. [Elicitation](https://ts.sdk.modelcontextprotocol.io/v2/servers/elicitation.md) covers `requestedSchema` and URL mode in full. ::: warning Sampling and roots are deprecated as of protocol revision 2026-07-28 (SEP-2577) — see [Sampling](https://ts.sdk.modelcontextprotocol.io/v2/servers/sampling.md). Reach for the elicitation builders first. ::: ## Carry state across rounds with `requestState` To run rounds in sequence, return an opaque `requestState` string alongside the requests. The client echoes it back byte-for-byte on the retry, and `ctx.mcpReq.requestState()` reads its decoded payload on re-entry. Mint it with the codec from the next section. ```ts server.registerTool( 'wipe-cache', { description: 'Confirm, then pick a scope, then wipe', inputSchema: z.object({}) }, async (_args, ctx): Promise => { const state = ctx.mcpReq.requestState<{ step: string }>(); if (state?.step !== 'confirmed') { const confirmed = acceptedContent<{ confirm: boolean }>(ctx.mcpReq.inputResponses, 'confirm'); if (confirmed?.confirm !== true) { return inputRequired({ inputRequests: { confirm: inputRequired.elicit({ message: 'Really wipe the cache?', requestedSchema: { type: 'object', properties: { confirm: { type: 'boolean' } }, required: ['confirm'] } }) } }); } // Mint only what the response above already proved: the operator confirmed. return inputRequired({ inputRequests: { scope: inputRequired.elicit({ message: 'Which scope?', requestedSchema: { type: 'object', properties: { scope: { type: 'string' } }, required: ['scope'] } }) }, requestState: await stateCodec.mint({ step: 'confirmed' }) }); } const scope = acceptedContent<{ scope: string }>(ctx.mcpReq.inputResponses, 'scope'); return { content: [{ type: 'text', text: `Wiped ${scope?.scope ?? 'all'}` }] }; } ); ``` Mint only what earlier rounds already proved. The token is bearer proof of whatever it claims: state minted as `{ step: 'confirmed' }` before the confirmation arrives grants that step to anyone who echoes it. One call drives all three entries: ``` [client] elicitation/create → Really wipe the cache? [client] elicitation/create → Which scope? { content: [ { type: 'text', text: 'Wiped sessions' } ] } ``` ## Protect `requestState` with the codec `requestState` round-trips through the client and comes back as attacker-controlled input; the SDK applies no protection of its own. `createRequestStateCodec` returns an HMAC-SHA256 `{ mint, verify }` pair — pass `verify` as `ServerOptions.requestState.verify` and it runs before every handler entry that carries state. ```ts const stateCodec = createRequestStateCodec<{ step: string }>({ key: crypto.getRandomValues(new Uint8Array(32)), // >= 32 bytes; share it across instances in a fleet ttlSeconds: 600 }); const server = new McpServer({ name: 'releases', version: '1.0.0' }, { requestState: { verify: stateCodec.verify } }); ``` With the hook in place, the accessor hands the handler `verify`'s decoded payload, and tampered or expired state never reaches the handler at all. Retrying `wipe-cache` with `requestState: 'tampered'` answers a wire-level protocol error: ``` -32602 Invalid or expired requestState ``` ::: warning The codec is signed, not encrypted — the client can base64url-decode the payload. Keep secrets out of it. ::: ## Let the shim serve older clients The handlers above already serve every connection. On a connection that predates 2026-07-28, the SDK's legacy shim — on by default — fulfils an `input_required` return by pushing real `elicitation/create`, `sampling/createMessage`, and `roots/list` requests over the session, then re-enters the handler with the collected responses and the byte-exact `requestState` echo. Every result quoted on this page came from such a connection. Set `ServerOptions.inputRequired.legacyShim: false` to fail loudly instead. Which revision a connection negotiates is covered in [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Recap - A handler asks for input by returning `inputRequired(...)`; the client answers the embedded requests and retries the call. - `inputRequired(spec)` needs at least one of `inputRequests` or `requestState`, and throws a `TypeError` without one. - `acceptedContent(ctx.mcpReq.inputResponses, key, schema)` validates the untrusted client answer before it reaches your code; `inputResponse` discriminates declines and the non-elicitation kinds. - A write-once handler re-derives its position on every entry and requests only what is still missing. - `requestState` is the only cross-round memory; protect it with `createRequestStateCodec` and mint only what earlier rounds proved. - The legacy shim serves the same handlers to pre-2026-07-28 clients. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/notifications.md ================================================================================ # Notifications A **notification** is a one-way message your server pushes to a connected client; change notifications tell clients that a list or a resource they cached is stale. ## Send a list-changed notification Start from a server with one tool. ```ts import { McpServer } from '@modelcontextprotocol/server'; const jobs = ['nightly-backup']; const server = new McpServer({ name: 'jobs', version: '1.0.0' }); server.registerTool('list-jobs', { description: 'List the configured jobs' }, async () => ({ content: [{ type: 'text', text: jobs.join('\n') }] })); ``` Every notification on this page is observed by an in-memory `Client` connected to the server above — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring — which logs each notification method it receives. Push a tool-list change yourself when the tool set changes for a reason the registration API cannot see. ```ts server.sendToolListChanged(); ``` The client receives one `notifications/tools/list_changed` and re-fetches `tools/list`: ``` notifications/tools/list_changed ``` `sendPromptListChanged()` and `sendResourceListChanged()` are the prompt and resource siblings. ## Let registration changes notify for you Hold on to the handle `registerTool` returns — every mutation through it sends the matching list-changed on its own. ```ts const report = server.registerTool('run-report', { description: 'Run the weekly report' }, async () => ({ content: [{ type: 'text', text: 'report queued' }] })); report.update({ description: 'Run the weekly report and email it' }); report.disable(); ``` Registering, updating, and disabling each sent a notification of its own — three more, none explicit: ``` notifications/tools/list_changed notifications/tools/list_changed notifications/tools/list_changed ``` `enable()` and `remove()` notify the same way, and the handles returned by `registerResource` and `registerPrompt` send `notifications/resources/list_changed` and `notifications/prompts/list_changed`. Most servers never call a `send*ListChanged()` method directly. ## Advertise the `listChanged` capability `McpServer` advertised `tools: { listChanged: true }` the moment you registered a tool, and does the same for prompts and resources. Only the [low-level `Server`](https://ts.sdk.modelcontextprotocol.io/v2/advanced/low-level-server.md) needs the capability declared up front. ```ts const lowLevel = new Server({ name: 'jobs', version: '1.0.0' }, { capabilities: { tools: { listChanged: true } } }); ``` The low-level `Server` refuses to send a notification its capabilities do not cover — `sendToolListChanged()` throws without a `tools` capability — and clients use the `listChanged` flag to decide which notification types to ask for. ## Publish a resource update through the handler On a 2026-07-28 connection, change notifications reach a client only on a [`subscriptions/listen`](https://ts.sdk.modelcontextprotocol.io/v2/clients/subscriptions.md) stream the client opens — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). Behind [`createMcpHandler`](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md) the `McpServer` instance is per-request, so publish through the handler, not the instance: `notify` is a typed facade over the handler's open subscription streams. ```ts const handler = createMcpHandler(() => buildJobsServer()); // After config://app changes: handler.notify.resourceUpdated('config://app'); ``` Every client whose stream listed `config://app` receives `notifications/resources/updated` carrying that URI; `notify.toolsChanged()`, `notify.promptsChanged()`, and `notify.resourcesChanged()` publish the three list-changed types the same way. Per-resource updates have one extra gate: the server your factory builds must advertise `resources: { subscribe: true }`. ::: tip On stdio, [`serveStdio`](https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.md) routes the instance's own `send*ListChanged()` and `sendResourceUpdated()` calls onto its open subscription stream — no `notify` facade needed. ::: ::: info Coming from 2025-era subscriptions A 2025-era connection delivers per-resource updates without a listen stream — [Resources](https://ts.sdk.modelcontextprotocol.io/v2/servers/resources.md#serve-per-resource-subscriptions) covers the server's subscription bookkeeping, [Subscriptions](https://ts.sdk.modelcontextprotocol.io/v2/clients/subscriptions.md#fall-back-to-legacy-per-resource-subscribe) the client call, and [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md) the era split. ::: ## Pick an event bus for multi-process deployments The `bus` option accepts any `ServerEventBus`; `InMemoryServerEventBus` is what `createMcpHandler` builds when you omit it. ```ts const bus = new InMemoryServerEventBus(); const shared = createMcpHandler(() => buildJobsServer(), { bus }); ``` The in-memory bus never leaves the process, so one process needs nothing more. Run more than one and you implement the two-method `ServerEventBus` interface — `publish` and `subscribe` — over your own pub/sub backend, then pass the same instance to every handler; see [Sessions, state, and scaling](https://ts.sdk.modelcontextprotocol.io/v2/serving/sessions-state-scaling.md). ## Recap - `sendToolListChanged()`, `sendPromptListChanged()`, and `sendResourceListChanged()` push a list-changed notification to connected clients. - Registering, updating, enabling, disabling, or removing through a registration handle sends the matching list-changed for you. - `McpServer` advertises `listChanged` as you register; only the low-level `Server` declares it up front. - Behind `createMcpHandler`, publish through `handler.notify`; delivery reaches every open subscription stream that opted in. - One process needs no `bus`; more than one shares a `ServerEventBus`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/servers/errors.md ================================================================================ # Errors A **tool error** is a successful JSON-RPC result with `isError: true` that the model reads and recovers from. A **protocol error** is a JSON-RPC error response the model never sees. ## Return a tool error with `isError` Return `isError: true` from a tool handler to report a failure the model should see. ```ts import { McpServer, ProtocolError, ProtocolErrorCode, ResourceNotFoundError, ResourceTemplate } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const notes = new Map([['welcome', 'Read tools.md first.']]); const server = new McpServer({ name: 'notes', version: '1.0.0' }); server.registerTool( 'read-note', { description: 'Read a note by its id', inputSchema: z.object({ id: z.string() }) }, async ({ id }) => { const note = notes.get(id); if (!note) { return { content: [{ type: 'text', text: `No note with id "${id}". Known ids: ${[...notes.keys()].join(', ')}` }], isError: true }; } return { content: [{ type: 'text', text: note }] }; } ); ``` Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring. Call `read-note` with an id that does not exist. ```ts const missing = await client.callTool({ name: 'read-note', arguments: { id: 'drafts' } }); console.log(missing); ``` The `tools/call` response is an ordinary result: ``` { content: [ { type: 'text', text: 'No note with id "drafts". Known ids: welcome' } ], isError: true } ``` The model reads the message, sees `welcome` in it, and retries with an id that exists. Put the recovery hint in `text` — it is the only thing the model has to work with. ## Let a thrown exception become a tool error Throw instead: the SDK catches anything a tool handler throws and converts it to the same `isError: true` shape. ```ts server.registerTool( 'delete-note', { description: 'Delete a note by its id', inputSchema: z.object({ id: z.string() }) }, async ({ id }) => { if (!notes.delete(id)) { throw new Error(`Cannot delete "${id}": no such note`); } return { content: [{ type: 'text', text: `Deleted "${id}"` }] }; } ); ``` Call `delete-note` with the same missing id. ```ts const thrown = await client.callTool({ name: 'delete-note', arguments: { id: 'drafts' } }); console.log(thrown); ``` The exception's `message` becomes the result's `content` text: ``` { content: [ { type: 'text', text: 'Cannot delete "drafts": no such note' } ], isError: true } ``` A throw and an explicit `isError: true` produce the same shape; returning explicitly gives you control over `content`. The SDK skips `outputSchema` validation on any `isError` result. ## Throw a protocol error Resource, prompt, and completion callbacks have no `isError` channel. Throw `ProtocolError(code, message, data?)` when the request itself is wrong. ```ts server.registerResource( 'note', new ResourceTemplate('note://{id}', { list: undefined }), { description: 'A note by its id' }, async (uri, { id }) => { const noteId = String(id); if (!/^[a-z]+$/.test(noteId)) { throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Note ids are lowercase letters, got "${noteId}"`); } const note = notes.get(noteId); if (!note) throw new ResourceNotFoundError(uri.href); return { contents: [{ uri: uri.href, text: note }] }; } ); ``` ::: info Coming from v1? `ProtocolError` and `ProtocolErrorCode` replace v1's `McpError` and `ErrorCode` — run the codemod, then see the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: Read the resource with an id the callback rejects. ```ts try { await client.readResource({ uri: 'note://42' }); } catch (error) { const { code, message } = error as ProtocolError; console.log({ code, message }); } ``` `readResource` rejects with a `ProtocolError` carrying the wire fields: ``` { code: -32602, message: 'Note ids are lowercase letters, got "42"' } ``` On the wire this is a JSON-RPC error response — `{ code, message, data? }` instead of a `result` — and the host's MCP client handles it; the model never sees it. A non-`ProtocolError` exception thrown from one of these callbacks surfaces as `-32603` Internal Error with the exception's message. ## Choose between tool error and protocol error Pick by audience. The model drives `tools/call`, so a failure it can recover from — a missing record, a bad argument, a transient upstream fault — belongs in `isError: true` with a message that names the fix. The host application drives `resources/read`, `prompts/get`, and `completion/complete`, so failures there are protocol errors addressed to the caller's code. The handler decides which channel exists: - A tool handler produces only tool errors. The SDK converts every exception it throws — including a thrown `ProtocolError` — into an `isError: true` result. `UrlElicitationRequiredError` is the one exception; it propagates as a JSON-RPC error so the host can open the URL — see [Elicitation](https://ts.sdk.modelcontextprotocol.io/v2/servers/elicitation.md). - A resource, prompt, or completion callback produces only protocol errors. Throw a `ProtocolError`. ## Use the typed error subclasses Each subclass picks the right `ProtocolErrorCode` and packs structured `data` for you. `ResourceNotFoundError` takes the missing URI — the read callback above already throws it for a well-formed id with no note. ```ts try { await client.readResource({ uri: 'note://archived' }); } catch (error) { const { code, message, data } = error as ResourceNotFoundError; console.log({ code, message, data }); } ``` The error carries the requested URI in `data` and the code the spec mandates for a `resources/read` miss: ``` { code: -32602, message: 'Resource not found: note://archived', data: { uri: 'note://archived' } } ``` Three more subclasses cover the other structured protocol errors: - `UrlElicitationRequiredError(elicitations)` — `-32042`; the only error a tool handler can propagate. See [Elicitation](https://ts.sdk.modelcontextprotocol.io/v2/servers/elicitation.md). - `UnsupportedProtocolVersionError({ supported, requested })` — `-32022`; `data.supported` lets the peer pick a version and retry. - `MissingRequiredClientCapabilityError({ requiredCapabilities })` — `-32021`; `data.requiredCapabilities` names exactly what the client must declare. Match these by `code` and `data` shape when peers may run pre-brand SDK copies or hand you plain wire shapes; on brand-aware releases `instanceof` also matches across separately bundled copies of the SDK. The same check is available as an explicit static guard — `ProtocolError.isInstance(err)`, `ResourceNotFoundError.isInstance(err)` — which narrows in TypeScript and reads the same brand. ## Look up a protocol error code `ProtocolErrorCode` is the complete vocabulary of wire codes the SDK sends and recognizes. | Member | Code | Meaning | | --- | --- | --- | | `ParseError` | `-32700` | The message was not valid JSON. | | `InvalidRequest` | `-32600` | The message was not a valid JSON-RPC request. | | `MethodNotFound` | `-32601` | No handler is registered for the method. | | `InvalidParams` | `-32602` | The params are wrong — also the code for a `resources/read` miss. | | `InternalError` | `-32603` | The handler threw something other than a `ProtocolError`. | | `ResourceNotFound` | `-32002` | Receive-tolerated only: the SDK answers a `resources/read` miss with `-32602` and never emits `-32002`. Throw `ResourceNotFoundError` instead. | | `MissingRequiredClientCapability` | `-32021` | The request needs a capability the client did not declare. | | `UnsupportedProtocolVersion` | `-32022` | The requested protocol version is unknown to the receiver or unsupported by it. | | `UrlElicitationRequired` | `-32042` | The tool needs the user to visit a URL before it can complete. | `-32021` and `-32022` are new in protocol revision 2026-07-28 — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Recap - `isError: true` is a successful JSON-RPC result carrying a tool failure the model reads and acts on. - A tool handler that throws produces the same `isError: true` result; the exception's `message` becomes the `content` text. - A tool handler cannot produce a protocol error — only `UrlElicitationRequiredError` escapes. - `ProtocolError` and its subclasses, thrown from resource, prompt, and completion callbacks, become JSON-RPC error responses the model never sees. - `ResourceNotFoundError` and the other subclasses pick the code and pack structured `data`; match them by `code` and `data` — or, on brand-aware releases, by `instanceof`. - The table above lists every `ProtocolErrorCode` member. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.md ================================================================================ # Serve over stdio A host that launches your server as a local child process talks to it over **stdio**: JSON-RPC requests arrive on stdin, responses leave on stdout. To host one endpoint that many clients connect to, serve the same factory over [HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md) instead. ## Serve a factory over stdio `serveStdio` takes a factory; it owns the transport and calls the factory to build the instance that serves the connection. ```ts import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; const handle = serveStdio(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); // server.registerTool(...) — one factory builds the instance that serves the connection return server; }); ``` The process is now an MCP server. A host that spawns it lists and calls whatever the factory registered; until one does, the process waits on stdin. ::: info Coming from v1? `serveStdio` replaces the `new StdioServerTransport()` + `server.connect(transport)` wiring — run the codemod, then see the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: ::: info `serveStdio` serves older clients from the same factory by default; the `legacy` option and the full story are on [Legacy clients](https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients.md). The entry also owns which protocol revision each connection negotiates — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ::: ## Log to stderr, never stdout Announce readiness with `console.error`, which writes to stderr. ```ts console.error('notes server is listening on stdio'); ``` stdout is the JSON-RPC channel: the host parses every line of it as a protocol message. Add one `console.log('debug: starting the notes server')` to the program above and send it an `initialize` request. Its two output streams now carry: ``` [stdout] debug: starting the notes server [stdout] {"result":{"protocolVersion":"2025-06-18","capabilities":{},"serverInfo":{"name":"notes","version":"1.0.0"}},"jsonrpc":"2.0","id":1} [stderr] notes server is listening on stdio ``` The protocol channel opens with a line no JSON-RPC parser accepts, ahead of the `initialize` response. The `console.error` banner went to stderr, which the host keeps out of the channel and shows in its server log. ## Test it with the Inspector The **MCP Inspector** launches your server command itself and connects to it over stdio. ```sh npx @modelcontextprotocol/inspector node ./build/server.js ``` In the browser tab it opens, click **Connect**; the **Tools** tab lists and calls everything the factory registered, without configuring the server in a host. ## Shut down cleanly `serveStdio` returns a **`StdioServerHandle`**; its `close()` tears down the pinned server instance and the transport. ```ts process.on('SIGINT', () => { void handle.close(); }); ``` `close()` resolves once the instance the factory built and the underlying transport are both shut down. ## Recap - `serveStdio(factory)` is the stdio entry point: it owns the transport and calls your factory to build the instance that serves the connection. - stdout is the protocol channel; log with `console.error`. - One `console.log` puts a line no JSON-RPC parser accepts into the stream the host parses. - `npx @modelcontextprotocol/inspector ` exercises a stdio server without configuring it in a host. - The returned `StdioServerHandle`'s `close()` tears down the pinned instance and the transport. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md ================================================================================ # Serve over HTTP To host one MCP endpoint that many clients connect to, serve your factory over **Streamable HTTP**. A host that launches the server as a local child process speaks [stdio](https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.md) instead. ## Create a handler `createMcpHandler` takes a **factory** — a function that builds and returns a fresh `McpServer` — and returns the handler that serves it. ```ts import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); server.registerTool( 'add-note', { description: 'Save a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] }) ); return server; }); ``` `handler.fetch` is a web-standard `(Request) => Promise` — nothing is listening yet. The tool calls on this page come from a real `Client` driving the handler's `fetch` in process; [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring. Calling `add-note` through it returns the tool result: ``` [ { type: 'text', text: 'Saved: ship the release notes' } ] ``` The handler also carries `close` for shutdown and the `notify`/`bus` pair that publishes change events to subscribed clients — see [Notifications](https://ts.sdk.modelcontextprotocol.io/v2/servers/notifications.md). ::: info Coming from v1? `createMcpHandler` replaces the per-request `StreamableHTTPServerTransport` + `connect()` wiring — run the codemod, then see the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: ## Understand the per-request factory The factory runs once per HTTP request: a fresh instance serves every request, and the handler holds nothing between requests. Register tools, resources, and prompts inside the factory, never on a shared instance outside it. The factory receives the **request context** — `era`, `authInfo`, and the inbound `Request` as `requestInfo`. Destructure `authInfo` to build the instance around one caller; [Pass authentication through](#pass-authentication-through) shows where the value comes from. ```ts const perCaller = createMcpHandler(({ authInfo }) => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); server.registerTool('whoami', { description: 'Name the authenticated caller' }, async () => ({ content: [{ type: 'text', text: authInfo?.clientId ?? 'anonymous' }] })); return server; }); ``` Every request now gets an instance built for its own caller. Keep the factory cheap and side-effect-free: create connection pools and caches once at module scope and close over them. `era` names the protocol revision the request speaks — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). Because no state lives on the instance, the endpoint is stateless and scales horizontally as-is; sessions, resumability, and multi-node fan-out are their own page, [Sessions, state, and scaling](https://ts.sdk.modelcontextprotocol.io/v2/serving/sessions-state-scaling.md). ## Mount it on your runtime On a web-standard runtime — Cloudflare Workers, Deno, Bun — `export default handler` is the entire mount. Node frameworks wrap the handler once with `toNodeHandler` from `@modelcontextprotocol/node`; on plain `node:http`, bind loopback explicitly and compose the `localhostHostValidation` / `localhostOriginValidation` guards (also from `@modelcontextprotocol/node`) in front of it, matching the framework factories' defaults: ```ts const nodeHandler = toNodeHandler(handler); const validateHost = localhostHostValidation(); const validateOrigin = localhostOriginValidation(); createServer((req, res) => { if (!validateHost(req, res) || !validateOrigin(req, res)) return; void nodeHandler(req, res); }).listen(3000, '127.0.0.1'); ``` `POST http://127.0.0.1:3000/mcp` now reaches the factory; the guards answer anything else with `403` before the handler sees it — [the next section](#validate-host-and-origin-in-front-of-it) explains why they belong in front. The same wrapped handler mounts under [Express](https://ts.sdk.modelcontextprotocol.io/v2/serving/express.md), [Fastify](https://ts.sdk.modelcontextprotocol.io/v2/serving/fastify.md), and [Hono](https://ts.sdk.modelcontextprotocol.io/v2/serving/hono.md); [Serve on web-standard runtimes](https://ts.sdk.modelcontextprotocol.io/v2/serving/web-standard.md) covers the `export default` side. ## Validate Host and Origin in front of it The handler trusts its caller: it validates no `Host` header, no `Origin` header, and no token. Mount those checks in front of it — on a localhost bind, the `Host` check is what stops **DNS rebinding**, a malicious page resolving its own domain to `127.0.0.1` so the browser treats your local server as same-origin. Under a framework you never wire either check by hand: `createMcpExpressApp`, `createMcpHonoApp`, and `createMcpFastifyApp` all arm both by default on localhost binds — the [Express](https://ts.sdk.modelcontextprotocol.io/v2/serving/express.md), [Hono](https://ts.sdk.modelcontextprotocol.io/v2/serving/hono.md), and [Fastify](https://ts.sdk.modelcontextprotocol.io/v2/serving/fastify.md) recipes start there. On plain `node:http`, compose `localhostHostValidation` and `localhostOriginValidation` (from `@modelcontextprotocol/node`) in front of the wrapped handler, as [the mount above](#mount-it-on-your-runtime) does. On a bare fetch runtime, put `hostHeaderValidationResponse` and `originValidationResponse` (from `@modelcontextprotocol/server`) in front of `handler.fetch` — [Serve on web-standard runtimes](https://ts.sdk.modelcontextprotocol.io/v2/serving/web-standard.md#protect-against-dns-rebinding) builds that wrapper. ## Pass authentication through `authInfo` is pass-through: the handler never reads it from headers and never verifies a token. Verify the bearer token in front of the handler and hand it the result as `fetch`'s second argument, `handler.fetch(request, { authInfo })`; the factory reads it back as `authInfo`, and tool handlers as `ctx.http.authInfo`. Under a Node framework the verifying middleware runs first and `toNodeHandler` forwards what it sets — each recipe shows its own mount, and [Require authorization](https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md) builds the verifier with `requireBearerAuth`. With an `AuthInfo` whose `clientId` is `alice`, `whoami` from [the factory above](#understand-the-per-request-factory) answers: ``` [ { type: 'text', text: 'alice' } ] ``` ## Shape the response stream The handler answers a request with a single JSON body and upgrades to an SSE stream only when a tool handler emits a notification — progress, logging — before its result. `responseMode` pins one shape instead. ```ts const jsonOnly = createMcpHandler(factory, { responseMode: 'json' }); ``` `'json'` never streams: the SDK drops mid-call notifications and delivers only the terminal result. `'sse'` always streams. `subscriptions/listen` streams stay on SSE whichever you pick. ::: info The handler serves 2025-era clients statelessly from the same factory by default. The `legacy` option — and where the SSE transport went — is on [Support legacy clients](https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients.md). ::: ## Shut down `handler.close()` aborts in-flight exchanges and closes their per-request instances; the handler holds nothing else. ```ts process.on('SIGINT', async () => { await handler.close(); process.exit(0); }); ``` `close()` resolves once every in-flight instance has closed; `fetch` then throws on any further request. ## Recap - `createMcpHandler(factory)` returns `{ fetch, close, notify, bus }`; `fetch` is a web-standard `(Request) => Promise`. - The factory builds one fresh instance per request and receives `era`, `authInfo`, and `requestInfo`. - `export default handler` mounts it on web-standard runtimes; `toNodeHandler(handler)` mounts it once under Node frameworks. - The handler validates no `Host` or `Origin` header and verifies no token — mount both checks in front of it; the framework app factories arm the header checks for you. - `authInfo` flows from `fetch(request, { authInfo })` into the factory and tool handlers; each framework recipe shows its own mount. - `responseMode` pins the response shape; `'json'` drops mid-call notifications. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/serving/express.md ================================================================================ # Serve with Express ```sh npm install @modelcontextprotocol/server @modelcontextprotocol/express @modelcontextprotocol/node express ``` ## Mount the handler `createMcpHandler` turns a server factory into a web-standard HTTP handler, and `toNodeHandler` adapts it once to Express's `(req, res)`. `createMcpExpressApp` is `express()` with `express.json()` and DNS rebinding protection already applied. ```ts import { createMcpExpressApp } from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] })); return server; }); const app = createMcpExpressApp(); const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body)); ``` `app` is an ordinary Express app with one route — `/mcp` answers every MCP request — and nothing is listening yet. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md#understand-the-per-request-factory) covers that model. ## Protect against DNS rebinding A malicious page can DNS-rebind its own domain to `127.0.0.1` and reach a localhost server as if it were same-origin. `createMcpExpressApp` validates the `Host` and `Origin` headers against that: with the default `127.0.0.1` bind (and `localhost` / `::1`), a request carrying a non-localhost value gets `403` before your handler runs. Binding to all interfaces drops that default — name the hosts you serve instead. ```ts const publicApp = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); ``` `allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. ## Forward auth and the parsed body `createMcpExpressApp` installed `express.json()`, so `req.body` is the parsed body — passing it as `toNodeHandler`'s third argument keeps the adapter from re-reading the stream Express already consumed. `requireBearerAuth` verifies the bearer token and attaches the result to `req.auth`; `toNodeHandler` forwards it, and handlers read it as `ctx.http.authInfo`. ```ts import { requireBearerAuth } from '@modelcontextprotocol/express'; const auth = requireBearerAuth({ verifier }); publicApp.all('/mcp', auth, (req, res) => void node(req, res, req.body)); ``` `verifier` is your token verification. [Authorization](https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md) covers writing one, requiring scopes, and serving the OAuth metadata documents. ## Run it and verify Add the listen line and start the process (`npx tsx server.ts`). ```ts app.listen(3000); ``` POST a `tools/list` request to the endpoint. ```sh curl -s -X POST http://127.0.0.1:3000/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` The response is a single SSE `message` event carrying the `tools/list` result: ``` event: message data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1} ``` ## Recap - One install line, one file: `createMcpExpressApp()` plus `app.all('/mcp', …)` over `toNodeHandler(createMcpHandler(factory))`. - A fresh server instance from your factory serves every request. - `createMcpExpressApp` already runs `express.json()`; pass `req.body` as `toNodeHandler`'s third argument. - The default `127.0.0.1` bind validates `Host` and `Origin`; pass `allowedHosts` when binding to `0.0.0.0`. - `requireBearerAuth` sets `req.auth`; `toNodeHandler` forwards it as `ctx.http.authInfo`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/serving/hono.md ================================================================================ # Serve with Hono ```sh npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono ``` ## Mount the handler `createMcpHandler` turns a server factory into a web-standard HTTP handler, and `handler.fetch` takes the `Request` a Hono route already holds as `c.req.raw` — no Node adapter. `createMcpHonoApp` is `new Hono()` with JSON body parsing and DNS rebinding protection already applied. ```ts import { createMcpHonoApp } from '@modelcontextprotocol/hono'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import type { Context } from 'hono'; import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] })); return server; }); const app = createMcpHonoApp(); app.all('/mcp', (c: Context) => handler.fetch(c.req.raw, { parsedBody: c.get('parsedBody') })); export default app; ``` `app` is an ordinary Hono app, and `export default app` is the `{ fetch }` object Cloudflare Workers, Deno, and Bun serve directly; on Node, pass `app` to `serve` from `@hono/node-server`. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md#understand-the-per-request-factory) covers that model. ::: tip Keep the explicit `c: Context` annotation: on an inferred callback context `c.get`'s key parameter narrows to `never` and `c.get('parsedBody')` does not compile. ::: ## Protect against DNS rebinding A malicious page can DNS-rebind its own domain to `127.0.0.1` and reach a localhost server as if it were same-origin. `createMcpHonoApp` validates the `Host` and `Origin` headers against that: with the default `127.0.0.1` bind (and `localhost` / `::1`), a request carrying a non-localhost value gets `403` before your handler runs. Binding to all interfaces drops that default — name the hosts you serve instead. ```ts const publicApp = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); ``` `allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. ## Forward auth and the parsed body `createMcpHonoApp` parses JSON bodies into `c.get('parsedBody')` for you; keep passing it through. Auth travels the same way — `handler.fetch`'s second argument is strictly pass-through, and handlers read it as `ctx.http.authInfo`. ```ts publicApp.all('/mcp', async (c: Context) => { const authInfo = await verifyToken(c.req.raw); return handler.fetch(c.req.raw, { authInfo, parsedBody: c.get('parsedBody') }); }); ``` `verifyToken` is your token verification. [Authorization](https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md) covers verifying bearer tokens and serving the OAuth metadata documents. ## Run it and verify Deploy the default export on any runtime that serves a `{ fetch }` object — `wrangler dev server.ts` puts it on `http://127.0.0.1:8787`. POST a `tools/list` request to `/mcp`. ```sh curl -s -X POST http://127.0.0.1:8787/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` The response is a single SSE `message` event carrying the `tools/list` result: ``` event: message data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1} ``` ## Recap - One install line, one file: `createMcpHonoApp()` plus one `app.all('/mcp', …)` route over `createMcpHandler(factory).fetch`. - Hono hands `c.req.raw` straight to `handler.fetch` — no Node adapter. - A fresh server instance from your factory serves every request. - The default `127.0.0.1` bind validates `Host` and `Origin`; pass `allowedHosts` when binding to `0.0.0.0`. - `authInfo` and `parsedBody` travel in `handler.fetch`'s second argument; handlers read auth as `ctx.http.authInfo`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/serving/fastify.md ================================================================================ # Serve with Fastify ```sh npm install @modelcontextprotocol/server @modelcontextprotocol/fastify @modelcontextprotocol/node fastify ``` ## Mount the handler `createMcpHandler` turns a server factory into a web-standard HTTP handler, and `toNodeHandler` adapts it once to Node's `(req, res)` — a Fastify route hands it `request.raw` and `reply.raw`. `createMcpFastifyApp` is `Fastify()` with DNS rebinding protection already applied. ```ts import { createMcpFastifyApp } from '@modelcontextprotocol/fastify'; import { toNodeHandler } from '@modelcontextprotocol/node'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] })); return server; }); const app = createMcpFastifyApp(); const node = toNodeHandler(handler); app.all('/mcp', (request, reply) => node(request.raw, reply.raw, request.body)); ``` `app` is an ordinary Fastify instance with one route — `/mcp` answers every MCP request — and nothing is listening yet. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md#understand-the-per-request-factory) covers that model. ## Protect against DNS rebinding A malicious page can DNS-rebind its own domain to `127.0.0.1` and reach a localhost server as if it were same-origin. `createMcpFastifyApp` validates the `Host` and `Origin` headers against that: with the default `127.0.0.1` bind (and `localhost` / `::1`), a request carrying a non-localhost value gets `403` before your handler runs. Binding to all interfaces drops that default — name the hosts you serve instead. ```ts const publicApp = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); ``` `allowedHosts` and `allowedOrigins` take hostnames, port-agnostic. A request without an `Origin` header always passes, so MCP clients outside a browser are unaffected. ## Forward auth and the parsed body Fastify parses JSON bodies itself, so `request.body` is already the parsed body — passing it as `toNodeHandler`'s third argument keeps the adapter from re-reading the consumed stream. Auth rides on the Node request: set `auth` on `request.raw` and `toNodeHandler` forwards it, so handlers read it as `ctx.http.authInfo`. ```ts publicApp.all('/mcp', async (request, reply) => { const auth = await verifyToken(request.headers.authorization); return node(Object.assign(request.raw, { auth }), reply.raw, request.body); }); ``` `verifyToken` is your token verification. [Authorization](https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md) covers verifying bearer tokens and serving the OAuth metadata documents. ## Run it and verify Add the listen line and start the process (`npx tsx server.ts`). ```ts await app.listen({ port: 3000 }); ``` POST a `tools/list` request to the endpoint. ```sh curl -s -X POST http://127.0.0.1:3000/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` The response is a single SSE `message` event carrying the `tools/list` result: ``` event: message data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1} ``` ## Recap - One install line, one file: `createMcpFastifyApp()` plus `app.all('/mcp', …)` over `toNodeHandler(createMcpHandler(factory))`. - A fresh server instance from your factory serves every request. - Fastify already parsed `request.body`; pass it as `toNodeHandler`'s third argument. - The default `127.0.0.1` bind validates `Host` and `Origin`; pass `allowedHosts` when binding to `0.0.0.0`. - Set `auth` on the raw Node request; `toNodeHandler` forwards it as `ctx.http.authInfo`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/serving/web-standard.md ================================================================================ # Serve on web-standard runtimes ```sh npm install @modelcontextprotocol/server ``` ## Mount the handler `createMcpHandler` returns a `{ fetch }` object — the shape Cloudflare Workers, Deno, and Bun expect from a module's default export — so `export default handler` mounts it. ```ts import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'notes', version: '1.0.0' }); server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ content: [{ type: 'text', text: `Saved: ${text}` }] })); return server; }); export default handler; ``` The deployed worker answers MCP requests on every path, with no Node adapter and no body middleware. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md#understand-the-per-request-factory) covers that model. ## Protect against DNS rebinding The handler performs no `Host` or `Origin` validation, and on a bare fetch-native runtime there is no app factory to arm it for you. Put the framework-agnostic response helpers in front of `fetch`. ```ts import { hostHeaderValidationResponse, originValidationResponse } from '@modelcontextprotocol/server'; const guarded = { async fetch(request: Request): Promise { const rejected = hostHeaderValidationResponse(request, ['api.example.com']) ?? originValidationResponse(request, ['app.example.com']); return rejected ?? handler.fetch(request); } }; ``` A request whose `Host` is not on the list gets `403` before `handler.fetch` runs; both helpers take hostnames, port-agnostic, and a request without an `Origin` header always passes. For a localhost-only process, `localhostAllowedHostnames()` and `localhostAllowedOrigins()` (same package) replace the explicit lists. ## Forward auth and the parsed body There is no body middleware on a fetch-native runtime — `fetch` reads the `Request` itself, so there is no `parsedBody` to forward. The handler never derives auth from request headers either: verify the token yourself and pass the result as `fetch`'s second argument, and handlers read it as `ctx.http.authInfo`. ```ts const secured = { async fetch(request: Request): Promise { const authInfo = await verifyToken(request); return handler.fetch(request, { authInfo }); } }; ``` `verifyToken` is your token verification. [Authorization](https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md) covers verifying bearer tokens and serving the OAuth metadata documents. ## Run it and verify Deploy the default export on your runtime — `wrangler dev server.ts` puts it on `http://127.0.0.1:8787`; `deno serve server.ts` and `bun run server.ts` serve the same `{ fetch }` shape. POST a `tools/list` request to it. ```sh curl -s -X POST http://127.0.0.1:8787/mcp \ -H 'Content-Type: application/json' \ -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' ``` The response is a single SSE `message` event carrying the `tools/list` result: ``` event: message data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inputSchema":{"type":"object","$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"text":{"type":"string"}},"required":["text"]}}]},"jsonrpc":"2.0","id":1} ``` ## Recap - One install line, one file: the handler `createMcpHandler` returns is already the `{ fetch }` default export web-standard runtimes serve. - No Node adapter and no body middleware are involved. - A fresh server instance from your factory serves every request. - The handler does no `Host`/`Origin` validation; on a bare runtime, put `hostHeaderValidationResponse` and `originValidationResponse` in front of it. - Auth is pass-through via `handler.fetch`'s second argument; handlers read it as `ctx.http.authInfo`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/serving/sessions-state-scaling.md ================================================================================ # Sessions, state, and scaling `createMcpHandler` builds a fresh server instance from your factory for every HTTP request and holds nothing between requests, so a v2 server is stateless and scales horizontally by default — [Serve over HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md) is the whole setup. Read on if you run a sessionful 2025-era deployment, need a dropped stream to resume, or push change notifications across nodes. ## Pin a client to a session A **session** pins a client to one long-lived transport instance; sessions belong to the hand-wired 2025-era transport — the 2026-07-28 revision is per-request and has no `Mcp-Session-Id` ([Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md)). On `NodeStreamableHTTPServerTransport`, `sessionIdGenerator` turns sessions on; leaving it `undefined` is stateless mode. ```ts import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; import { randomUUID } from 'node:crypto'; const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); ``` The transport answers `initialize` with the generated id in an `Mcp-Session-Id` response header and rejects later requests that arrive without it. The SDK's `StreamableHTTPClientTransport` sends the header back on every request with no configuration. One transport instance is one session, so a sessionful deployment keeps a map: build a transport when `initialize` arrives, store it in `onsessioninitialized`, and route every later request to the transport that owns its `Mcp-Session-Id`. This Express route handles all three verbs — `POST`, the `GET` notification stream, and `DELETE` ([Serve with Express](https://ts.sdk.modelcontextprotocol.io/v2/serving/express.md) covers the app itself). ```ts const sessions = new Map(); const route = async (req: Request, res: Response) => { const sessionId = req.headers['mcp-session-id'] as string | undefined; if (sessionId && sessions.has(sessionId)) { await sessions.get(sessionId)!.handleRequest(req, res, req.body); return; } if (!sessionId && isInitializeRequest(req.body)) { const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: id => { sessions.set(id, transport); } }); transport.onclose = () => { if (transport.sessionId) sessions.delete(transport.sessionId); }; await buildServer().connect(transport); await transport.handleRequest(req, res, req.body); return; } if (sessionId) { // Unknown session id: the client should start a new session. res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null }); return; } // No session header on a non-initialize request: the request is malformed. res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Session ID required' }, id: null }); }; app.post('/mcp', route); app.get('/mcp', route); app.delete('/mcp', route); ``` The map cleans itself up: `transport.onclose` fires when the session ends, whether the client sent `DELETE` or you called `transport.close()`. A request with an unknown `Mcp-Session-Id` gets the `404` above, which tells the client to start a new session; a request with no session header at all gets the `400`, which tells it to re-send the id it already has instead of re-initializing. ::: tip On shutdown, close every stored transport — `for (const [, transport] of sessions) await transport.close()` — before exiting; `close()` ends the session's SSE streams and rejects its pending requests. ::: ## Resume a dropped stream A sessionful client holds a `GET` SSE stream open for server notifications, and anything sent while that connection is down is lost. An **event store** closes the gap: with one configured, the transport stamps every SSE message with an event id from the store before sending it. `EventStore` is a two-method contract — `storeEvent(streamId, message)` persists a message and returns its event id; `replayEventsAfter(lastEventId, { send })` re-sends every later message on that stream. Implement it over storage every node can reach (`databaseEventStore` here) and pass it next to `sessionIdGenerator`. ```ts const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), eventStore: databaseEventStore }); ``` When the connection drops, the client reconnects with the last event id it received as a `Last-Event-ID` header and the transport replays everything stored after it. The SDK's `StreamableHTTPClientTransport` reconnects and sends that header on its own. ::: tip `examples/shared/src/inMemoryEventStore.ts` in the SDK repository is a complete `EventStore` reference implementation — in memory, so single-process only. ::: ## Scale across nodes The stateless default is the scaling story: every node builds a fresh instance from the same factory and holds nothing between requests, so put the nodes behind any load balancer — no session affinity, nothing to share, nothing to configure. Sessionful 2025-era nodes hold their sessions in process memory, so they scale two ways. **Persistent storage**: keep `sessionIdGenerator` and point every node at the same `eventStore`, so a dropped stream is resumable from any node that shares the store. **Local state with message routing**: keep per-node sessions and send each session's traffic to the node that owns it — load-balancer affinity, or pub/sub routing between nodes. One thing still crosses nodes on a stateless deployment: `subscriptions/listen`. Its streams deliver the change events published on the handler's **`ServerEventBus`** ([Notifications](https://ts.sdk.modelcontextprotocol.io/v2/servers/notifications.md)), and the default bus is in-process — `handler.notify.toolsChanged()` on node A never reaches a subscriber whose stream node B holds. Implement `ServerEventBus` over your pub/sub (`publish(event)` forwards to the broker; `subscribe(listener)` registers for events arriving from it) and hand one to every node's `createMcpHandler`. ```ts const handler = createMcpHandler(buildServer, { bus: redisBus }); ``` Now `handler.notify.resourceUpdated(uri)` on any node publishes through the shared bus, and every node delivers the notification to its own open subscription streams. ## Recap - `createMcpHandler` builds a fresh server per request and holds nothing between requests, so stateless nodes scale behind any load balancer with no session affinity. - Sessions belong to the hand-wired 2025-era transport: `sessionIdGenerator` turns them on, and responses carry `Mcp-Session-Id`. - A sessionful deployment keeps one transport per session and routes every request to it by that header; unknown ids get a `404`. - An `eventStore` makes a dropped SSE stream resumable: the client reconnects with `Last-Event-ID` and the transport replays what it missed. - `subscriptions/listen` scales across nodes by handing every node's `createMcpHandler` the same `ServerEventBus`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md ================================================================================ # Require authorization Protecting a server you run → this page. Signing a user in from a client you build → [Authenticate a user with OAuth](https://ts.sdk.modelcontextprotocol.io/v2/clients/oauth.md). No user present → [Authenticate without a user](https://ts.sdk.modelcontextprotocol.io/v2/clients/machine-auth.md). ## Require a bearer token Your MCP server is an OAuth **resource server**: it verifies access tokens that an authorization server issued, and it never issues them. `requireBearerAuth` from `@modelcontextprotocol/express` is that whole gate — build it from a verifier and mount it in front of the `/mcp` route from the [Express recipe](https://ts.sdk.modelcontextprotocol.io/v2/serving/express.md). ```ts import type { OAuthTokenVerifier } from '@modelcontextprotocol/express'; import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, requireBearerAuth } from '@modelcontextprotocol/express'; import { toNodeHandler } from '@modelcontextprotocol/node'; import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; const mcpServerUrl = new URL('https://api.example.com/mcp'); const verifier: OAuthTokenVerifier = { verifyAccessToken }; const auth = requireBearerAuth({ verifier, requiredScopes: ['mcp'], resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl) }); const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] }); const node = toNodeHandler(createMcpHandler(buildServer)); app.all('/mcp', auth, (req, res) => void node(req, res, req.body)); ``` A request with a missing, malformed, or expired token gets `401` with the OAuth error code `invalid_token`. A valid token missing one of `requiredScopes` gets `403` with `insufficient_scope`. Both responses carry a `WWW-Authenticate: Bearer …` challenge whose `resource_metadata` parameter is the URL you passed — that challenge is what starts a client's OAuth flow. ::: info Coming from v1? The Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`, …) are frozen in `@modelcontextprotocol/server-legacy/auth`. Use a dedicated identity provider for new servers; this page only covers the resource-server half. ::: ## Require a bearer token on a web-standard host On hosts whose HTTP surface is a `fetch(request)` handler — Cloudflare Workers, Deno, Bun, Hono — the gate is `requireBearerAuth` from `@modelcontextprotocol/server`: no framework, only web-standard `Request` and `Response`. ```ts const gate = requireBearerAuth({ verifier, requiredScopes: ['mcp'] }); const handler = createMcpHandler(buildServer); export default { async fetch(request: Request): Promise { const auth = await gate(request); if (auth instanceof Response) return auth; return handler.fetch(request, { authInfo: auth }); } }; ``` The gate resolves to the verified `AuthInfo` — pass it to the handler as `{ authInfo }` and handlers read it as `ctx.http.authInfo` — or to the ready-to-return challenge `Response`. Status codes, error bodies, and the `WWW-Authenticate` challenge (including `resourceMetadataUrl`) are identical to the Express middleware: both are adapters over one core, so a verifier written for one serves the other unchanged. ## Verify tokens your way `verifyAccessToken` is the one function you supply: take the raw token string, return an `AuthInfo`. Local JWT verification, [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662) introspection, or a call to your identity provider all fit behind it. ```ts async function verifyAccessToken(token: string): Promise { const payload = await verifyJwt(token); return { token, clientId: payload.sub, scopes: payload.scopes, expiresAt: payload.exp }; } ``` Throw an `OAuthError` with `OAuthErrorCode.InvalidToken` (both from `@modelcontextprotocol/server`) for a token you reject, and `requireBearerAuth` turns it into the `401` challenge. Any other exception comes back as `500 server_error`. ::: warning `requireBearerAuth` also answers `401 invalid_token` for a token whose `expiresAt` is unset. Always populate it — from the JWT `exp` claim or the introspection response's `exp` field. ::: ## Publish protected resource metadata `mcpAuthMetadataRouter` serves the [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) protected resource metadata document that the `401` challenge points at. `oauthMetadata` is your authorization server's own RFC 8414 metadata document. ```ts app.use(mcpAuthMetadataRouter({ oauthMetadata, resourceServerUrl: mcpServerUrl })); ``` The router mounts two well-known routes: `/.well-known/oauth-protected-resource/mcp` — the path-aware RFC 9728 location, the same string `getOAuthProtectedResourceMetadataUrl(mcpServerUrl)` put into the challenge — and `/.well-known/oauth-authorization-server`, a mirror of `oauthMetadata` for clients that probe your origin directly. An unauthenticated client follows `401` → `resource_metadata` → `authorization_servers` to find your AS, obtains a token, and retries. On a web-standard host, `oauthMetadataResponse` from `@modelcontextprotocol/server` serves the same two documents from a `fetch(request)` handler — it returns the matched document `Response` (with permissive CORS and `405` handling) or `undefined` to fall through to your own routing: ```ts import { oauthMetadataResponse } from '@modelcontextprotocol/server'; async function webStandardFetch(request: Request): Promise { return oauthMetadataResponse(request, { oauthMetadata, resourceServerUrl: mcpServerUrl }) ?? serveMcp(request); } ``` ## Read the caller in your handlers `requireBearerAuth` attaches the verified `AuthInfo` to `req.auth`, `toNodeHandler` forwards it, and tool handlers inside `buildServer` read it as `ctx.http.authInfo` — the exact object your verifier returned. ```ts server.registerTool('whoami', { description: 'Report the authenticated caller' }, async ctx => { const caller = ctx.http?.authInfo; return { content: [{ type: 'text', text: `${caller?.clientId} [${caller?.scopes.join(' ')}]` }] }; }); ``` `ctx.http` is `undefined` when the same server runs over [stdio](https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.md), so guard the read if your server serves both transports. ::: tip The per-request factory itself receives the same value as `ctx.authInfo`, so it can register a different tool set per caller before any handler runs. ::: ## Enforce per-tool scopes `requiredScopes` gates the whole endpoint. For a scope only some tools need, check inside the handler — the handler is the only place that knows which tool is executing. ```ts server.registerTool('purge-notes', { description: 'Delete every note' }, async ctx => { if (!ctx.http?.authInfo?.scopes.includes('notes:write')) { return { content: [{ type: 'text', text: 'insufficient_scope: purge-notes requires notes:write' }], isError: true }; } return { content: [{ type: 'text', text: 'All notes deleted' }] }; }); ``` A caller holding only `mcp` gets an ordinary tool result with `isError: true`, so the model reads the refusal and moves on instead of losing the connection. ::: info Responding `403 insufficient_scope` at the HTTP layer instead triggers the client transport's automatic scope step-up (SEP-2350) — see [Authenticate a user with OAuth](https://ts.sdk.modelcontextprotocol.io/v2/clients/oauth.md). ::: ## Recap - `requireBearerAuth` from `@modelcontextprotocol/server` is the same gate for web-standard `fetch` hosts; the Express middleware adapts the same core. - `requireBearerAuth` plus a `verifyAccessToken` you write turn an Express-mounted MCP route into an OAuth resource server; the SDK never issues tokens. - Missing, invalid, or expired tokens get `401 invalid_token`; a token missing a `requiredScopes` entry gets `403 insufficient_scope`; both carry a `WWW-Authenticate: Bearer` challenge. - `mcpAuthMetadataRouter` publishes the RFC 9728 document that challenge points at, plus a mirror of the AS metadata. - Verified auth flows `req.auth` → `ctx.http.authInfo`; per-tool scopes are a check inside the handler that returns `isError: true`. - The v1 Authorization Server helpers are frozen in `@modelcontextprotocol/server-legacy/auth`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients.md ================================================================================ # Support legacy clients A **legacy client** speaks a 2025-era protocol revision: it opens with `initialize` and sends no per-request `_meta` envelope. Both serving entry points answer those clients from the same factory that serves modern ones; the `legacy` option decides whether they keep doing it. [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md) covers the era model itself. ## Choose a legacy posture [`createMcpHandler`](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md) has two postures. The default, `legacy: 'stateless'`, serves each legacy request from a fresh instance out of your factory, with no sessions. `legacy: 'reject'` makes the endpoint modern-only. ```ts import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; const buildServer = () => new McpServer({ name: 'notes', version: '1.0.0' }); const strict = createMcpHandler(buildServer, { legacy: 'reject' }); ``` A 2025-era `initialize` POST to the strict handler gets HTTP `400` and the unsupported-protocol-version error naming the one revision the endpoint serves: ``` 400 { "jsonrpc": "2.0", "error": { "code": -32022, "message": "Unsupported protocol version: 2025-06-18", "data": { "supported": [ "2026-07-28" ], "requested": "2025-06-18" } }, "id": 1 } ``` Drop the option and the same request gets a normal 2025 `InitializeResult` from a fresh instance, torn down when the exchange ends. Per request means no sessions: under the default posture a legacy `GET` (the standalone SSE stream) and `DELETE` (session termination) answer `405 Method not allowed.` — a client that needs those needs the routing below. ::: tip A strict endpoint still acknowledges legacy-classified notification POSTs with `202` — and then drops them. Legacy `GET` and `DELETE` answer `405` there too. ::: ## Choose the same posture on stdio [`serveStdio`](https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.md) takes the same option with a different default — `'serve'` — and applies it once per connection, not per request. ```ts serveStdio(buildServer, { legacy: 'reject' }); ``` Under `'serve'` a 2025-era opening pins the connection to a legacy instance from your factory and serves it exactly as a hand-wired stdio server would. Under `'reject'` the entry answers the opening with the same unsupported-protocol-version error and keeps the connection open for a modern opening. ## Keep a sessionful 2025 deployment running Neither entry point accepts a handler as the `legacy` value. To keep an existing sessionful deployment serving the 2025 clients it already has, route in front of a strict handler with `isLegacyRequest` — the entry's own classification step exported as a predicate, so the branch never disagrees with `createMcpHandler`. ```ts import { isLegacyRequest, legacyStatelessFallback } from '@modelcontextprotocol/server'; const legacy = legacyStatelessFallback(buildServer); async function serve(request: Request): Promise { if (await isLegacyRequest(request)) { return legacy(request); } return strict.fetch(request); } ``` `legacyStatelessFallback(factory)` is the entry's default legacy serving as a standalone handler — it holds the legacy leg's place here. Put your existing wiring there instead and it keeps its sessions, its event store, and its clients: [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) runs a sessionful `StreamableHTTPServerTransport` deployment behind this exact branch. Route every `false` to the strict handler — the modern path owns the error answers for malformed modern requests. The `initialize` the strict handler rejected above now completes the 2025 handshake on the legacy leg: ``` 200 { protocolVersion: '2025-06-18', capabilities: {}, serverInfo: { name: 'notes', version: '1.0.0' } } ``` ::: tip Behind an Express body parser the Node stream is already drained: build the `Request` the predicate takes with `toWebRequest(req, req.body)` from `@modelcontextprotocol/node`. ::: ## Know where SSE went The v2 server never serves the HTTP+SSE transport. An SSE server moving to v2 moves to Streamable HTTP — `createMcpHandler` above — as part of the [v2 upgrade](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). The client side keeps `SSEClientTransport`, so a v2 `Client` still reaches old SSE servers. For a server deployment that cannot move yet, a frozen v1 copy of the transport ships as `@modelcontextprotocol/server-legacy/sse` (deprecated). ## Recap - Both entry points serve 2025-era clients from the same factory by default; `legacy: 'reject'` makes an endpoint modern-only. - The default HTTP posture is per request and stateless: legacy `GET` and `DELETE` session operations answer `405`. - `serveStdio` decides the era once per connection; its default is `'serve'`. - `isLegacyRequest` in front of a strict handler keeps an existing sessionful 2025 deployment serving its clients. - The v2 server never serves SSE; the frozen v1 transport is `@modelcontextprotocol/server-legacy/sse`, and the client keeps `SSEClientTransport`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/clients/connect.md ================================================================================ # Connect to a server A **client** holds one connection to one server: construct a `Client`, pick a **transport**, and `connect()`. ## Create a client and connect over HTTP `Client` takes a name and a version; `StreamableHTTPClientTransport` takes the server's MCP endpoint URL. ```ts import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const client = new Client({ name: 'my-client', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp')); await client.connect(transport); ``` `connect()` runs the `initialize` handshake and resolves once it completes. The client now holds the negotiated protocol version, the server's capabilities, and its instructions. ::: info Coming from v1? `Client` and the transport classes keep their names — only the import paths moved, to `@modelcontextprotocol/client` and its `/stdio` subpath. Run the codemod, then see the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: ## Connect to a local process over stdio For a server you run as a child process, change only the transport: `StdioClientTransport`, imported from `@modelcontextprotocol/client/stdio`, spawns the command and speaks JSON-RPC over its stdin and stdout. ```ts const client = new Client({ name: 'my-client', version: '1.0.0' }); const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] }); await client.connect(transport); ``` `server.js` runs as a child of your process. `close()` shuts it down in order: close stdin, then `SIGTERM`, then `SIGKILL`. ::: tip `InMemoryTransport.createLinkedPair()` is the third transport: it links a `Client` and an `McpServer` inside one process, no network and no child process. [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) builds on it. ::: ## Fall back to SSE for servers that predate Streamable HTTP An SSE-only server speaks the older HTTP+SSE transport instead of Streamable HTTP. Try `StreamableHTTPClientTransport` first; when it fails, retry with `SSEClientTransport` on a fresh `Client`. ```ts try { const client = new Client({ name: 'my-client', version: '1.0.0' }); await client.connect(new StreamableHTTPClientTransport(new URL(url))); return client; } catch { const client = new Client({ name: 'my-client', version: '1.0.0' }); await client.connect(new SSEClientTransport(new URL(url))); return client; } ``` Whichever branch returns, the `Client` behaves the same from here on — nothing downstream depends on the transport. ::: info `versionNegotiation` in `ClientOptions` controls which protocol revision `connect()` negotiates — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ::: ## Read what the server told you at connect time Three accessors return what the server declared during the handshake; all of them return `undefined` until `connect()` resolves. ```ts console.log(client.getServerVersion()); console.log(client.getServerCapabilities()); console.log(client.getInstructions()); ``` Connected to a server named `travel` that registered one tool and set `instructions`, that prints: ``` { name: 'travel', version: '2.1.0' } { tools: { listChanged: true } } Call list-trips before book-trip. Dates are ISO 8601. ``` The capability object gates every verb on [the next page](https://ts.sdk.modelcontextprotocol.io/v2/clients/calling.md): only ask for what the server advertised. `getInstructions()` is the server's usage guide for the model — put it in the system prompt. ## Disconnect cleanly Over Streamable HTTP, terminate the server-side session, then close the client. ```ts await transport.terminateSession(); await client.close(); ``` `close()` tears down the transport and rejects every request still in flight with a `CONNECTION_CLOSED` error. `terminateSession()` returns without sending anything when the server never issued a session ID. On the other transports, `close()` alone is the whole teardown. ## Recap - `new Client({ name, version })`, a transport, and `connect()` are the whole setup; `connect()` runs the `initialize` handshake. - `StreamableHTTPClientTransport` connects to remote servers; `StdioClientTransport`, from `@modelcontextprotocol/client/stdio`, spawns local ones; `SSEClientTransport` is the fallback for SSE-only servers. - `InMemoryTransport.createLinkedPair()` links a client and a server in one process. - After `connect()`, `getServerVersion()`, `getServerCapabilities()`, and `getInstructions()` return what the server declared. - `close()` tears down the transport and rejects in-flight requests. - Protocol-revision differences live on the protocol versions page, not here. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/clients/calling.md ================================================================================ # Call tools, read resources, get prompts Every block on this page runs on a connected `Client` — [Connect to a server](https://ts.sdk.modelcontextprotocol.io/v2/clients/connect.md) shows the wiring — here paired in memory with an `orders` server that registers three tools, a resource, and a prompt. ## List the tools and call one `listTools` returns the tools the server advertises; `callTool` invokes one by name with a plain `arguments` object. ```ts const { tools } = await client.listTools(); console.log(tools.map(tool => tool.name)); const result = await client.callTool({ name: 'lookup-order', arguments: { id: 'A-1041' } }); console.log(result.content); ``` `result.content` is the content array the tool handler returned, unchanged: ``` [ 'lookup-order', 'order-total', 'export-orders' ] [ { type: 'text', text: 'A-1041: 3 items, shipped' } ] ``` ::: tip A failed tool call is still a result: check `isError` on it before trusting `content`. Arguments the input schema rejects come back the same way. Only protocol-level failures — unknown tool, timeout — throw. ::: ## Let the SDK walk the pages That `listTools()` already walked every page: when a server splits its list, the SDK follows `nextCursor` page by page and returns one aggregated list with `nextCursor: undefined`. `listPrompts()`, `listResources()`, and `listResourceTemplates()` aggregate the same way. Pass a `cursor` — a page's `nextCursor` your application held on to — and `listTools` returns exactly that page, raw. ```ts const page = await client.listTools({ cursor: heldCursor }); console.log( page.tools.map(tool => tool.name), page.nextCursor ); ``` The `orders` server hands out its three tools two per page, and `heldCursor` names the second page — one tool, nothing left to follow: ``` [ 'export-orders' ] undefined ``` ::: warning `ClientOptions.listMaxPages` (default 64) caps the aggregate walk; a server whose pagination never terminates rejects the call with an `SdkError` whose code is `LIST_PAGINATION_EXCEEDED`. `listMaxPages: 0` removes the cap. Explicit-`cursor` calls are never capped. ::: ## Read structured output A tool that declares an `outputSchema` returns `structuredContent` next to `content`. It is typed `unknown` — check that it is present and narrow it before use. ```ts const details = await client.callTool({ name: 'order-total', arguments: { id: 'A-1041' } }); const total: unknown = details.structuredContent; if (typeof total === 'object' && total !== null && 'currency' in total) { console.log(total); } ``` `order-total` declares `{ id, total, currency }`, and that is what comes back: ``` { id: 'A-1041', total: 61.5, currency: 'EUR' } ``` When an earlier `listTools()` gave the client the tool's `outputSchema`, `callTool` validates `structuredContent` against it and rejects a result that does not match. The wire encoding of structured results differs by protocol era — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Read a resource `listResources` names what the server exposes; `readResource` fetches one URI. ```ts const { resources } = await client.listResources(); console.log(resources.map(resource => resource.uri)); const { contents } = await client.readResource({ uri: 'orders://recent' }); console.log(contents[0]); ``` Each item in `contents` carries the `uri`, a `mimeType`, and either `text` or a base64 `blob`: ``` [ 'orders://recent' ] { uri: 'orders://recent', mimeType: 'application/json', text: '["A-1041","A-1042"]' } ``` For parameterized URIs, `listResourceTemplates()` returns the server's URI templates — expand one and pass the resulting URI to `readResource`. To react when a resource changes instead of re-reading it on a timer, see [Subscriptions](https://ts.sdk.modelcontextprotocol.io/v2/clients/subscriptions.md). ## Get a prompt `listPrompts` advertises each prompt with its arguments; `getPrompt` fills them in and returns `messages` ready to send to a model. ```ts const { prompts } = await client.listPrompts(); console.log(prompts.map(prompt => prompt.name)); const prompt = await client.getPrompt({ name: 'summarize-order', arguments: { id: 'A-1041', tone: 'terse' } }); console.log(prompt.messages); ``` The server's template comes back with both arguments substituted: ``` [ 'summarize-order' ] [ { role: 'user', content: { type: 'text', text: 'Write a terse status update for order A-1041.' } } ] ``` ## Autocomplete an argument `complete` asks the server for suggestions while the user types an argument: `ref` names the prompt (or resource template) and `argument` carries the partial value. ```ts const { completion } = await client.complete({ ref: { type: 'ref/prompt', name: 'summarize-order' }, argument: { name: 'tone', value: 'f' } }); console.log(completion.values); ``` The server matches `f` against the values it accepts for `tone`: ``` [ 'formal', 'friendly' ] ``` ## Track progress on a long call Every verb takes request options as a second argument. `onprogress` receives each `notifications/progress` the server emits for this call; `resetTimeoutOnProgress` restarts the request timeout on every update and `maxTotalTimeout` is the absolute cap. ```ts const exported = await client.callTool( { name: 'export-orders', arguments: { format: 'csv' } }, { onprogress: update => console.log(update), resetTimeoutOnProgress: true, maxTotalTimeout: 600_000 } ); console.log(exported.content); ``` The updates stream in while the call is still pending; the return type does not change: ``` { progress: 1, total: 2, message: 'exported A-1041' } { progress: 2, total: 2, message: 'exported A-1042' } [ { type: 'text', text: '2 orders exported as csv' } ] ``` ## Recap - `listTools`, `listResources`, `listResourceTemplates`, and `listPrompts` aggregate every page; `{ cursor }` fetches a single raw page and `listMaxPages` caps the walk. - `callTool` returns `content` for the model and, when the tool declares an `outputSchema`, `structuredContent` for your application. - `readResource({ uri })` and `getPrompt({ name, arguments })` follow the same list-then-fetch shape as tools. - `complete()` returns the server's suggestions for a prompt or resource-template argument. - `onprogress` in the request options streams progress updates without changing the call's return type. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/clients/server-requests.md ================================================================================ # Handle requests from the server ## Declare what your client can do Declare each **capability** in the `Client` constructor's options — a server only sends your client a request it declared a capability for, and the SDK enforces that on both sides. ```ts import { Client } from '@modelcontextprotocol/client'; const client = new Client( { name: 'my-client', version: '1.0.0' }, { capabilities: { sampling: {}, elicitation: { form: {}, url: {} } } } ); ``` Every result quoted on this page comes from this client connected over an in-memory transport pair to a server whose tools elicit input and request sampling. [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring; [Elicitation](https://ts.sdk.modelcontextprotocol.io/v2/servers/elicitation.md) and [Sampling](https://ts.sdk.modelcontextprotocol.io/v2/servers/sampling.md) show the server side. ::: tip An empty `elicitation: {}` declares form mode only — `url` must be listed explicitly. `getSupportedElicitationModes`, exported from `@modelcontextprotocol/client`, turns any `elicitation` capability object into `{ supportsFormMode, supportsUrlMode }`. ::: ## Handle an elicitation request A tool that calls `elicitInput` sends your client an `elicitation/create` request. Branch on `request.params.mode`: `'url'` carries a URL to open in the user's browser, and anything else is a form your client builds from `request.params.requestedSchema`. ```ts client.setRequestHandler('elicitation/create', async request => { if (request.params.mode === 'url') { // Open request.params.url in the user's browser; answer when they finish. return { action: 'accept' }; } // Render request.params.requestedSchema as a form; return what the user entered. return { action: 'accept', content: { city: 'Lisbon' } }; }); ``` `action` is the user's decision: `'accept'` carries the submitted `content`, `'decline'` and `'cancel'` carry nothing. Calling a tool that asks where to ship an order now round-trips through the form branch: ``` [ { type: 'text', text: 'Order placed: Travel mug ships to Lisbon.' } ] ``` ::: tip Form requests sent before `mode` existed omit it entirely — branch on `'url'` and treat everything else as a form, never on `mode === 'form'`. ::: ## Handle a sampling request ::: warning Deprecated — SEP-2577 Servers should call their LLM provider directly instead of sampling — see [Sampling](https://ts.sdk.modelcontextprotocol.io/v2/servers/sampling.md). Keep this handler to support servers that have not migrated yet. ::: A tool that calls `requestSampling` sends your client a `sampling/createMessage` request: a list of messages to run through a model your application controls. ```ts client.setRequestHandler('sampling/createMessage', async request => { const lastMessage = request.params.messages.at(-1); console.log('Sampling request:', lastMessage?.content); // In production, run the messages through your model here. return { model: 'host-model', role: 'assistant', content: { type: 'text', text: 'One travel mug to Lisbon.' } }; }); ``` Calling a tool that summarizes the order logs the prompt the server sent, and the tool result carries the handler's completion: ``` Sampling request: { type: 'text', text: 'Summarize this order: 1 Travel mug to Lisbon' } [ { type: 'text', text: 'host-model: One travel mug to Lisbon.' } ] ``` ## Register each handler once Register each handler once, on the `Client` you construct. The same handler answers a request the server pushes to your client and a request the SDK fulfils for you inside a `callTool()` round — your code never sees the difference. ::: info Which of those two delivery paths a connection uses depends on its protocol version — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ::: ## Cap or disable automatic fulfilment When the SDK fulfils requests inside a call, the `inputRequired` option caps how many rounds it runs on your behalf. ```ts const client = new Client( { name: 'my-client', version: '1.0.0' }, { capabilities: { sampling: {}, elicitation: { form: {}, url: {} } }, inputRequired: { maxRounds: 3 } } ); ``` Past `maxRounds` (default 10) the call rejects with an `SdkError` coded `INPUT_REQUIRED_ROUNDS_EXCEEDED`. Set `autoFulfill: false` to turn the loop off entirely: a call that needs input rejects on its first round instead, and the round trips are yours to drive. ## Recap - Declare a capability in the `Client` constructor or the server never sends that request. - `setRequestHandler('elicitation/create')` branches on `mode` and returns the user's `action`, plus `content` on accept. - `setRequestHandler('sampling/createMessage')` runs the messages through your model and returns `{ model, role, content }`. - Register each handler once; it answers the request however the connection delivers it. - `inputRequired` caps the automatic interactive rounds; `autoFulfill: false` disables them. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/clients/roots.md ================================================================================ # Provide roots ::: warning Deprecated — SEP-2577 Pass paths through tool arguments, resource URIs, or host configuration instead. **Roots** are deprecated as of protocol version 2026-07-28 (SEP-2577) and stay functional on 2025-era connections for at least twelve months — see the [deprecated features registry](https://modelcontextprotocol.io/specification/draft/deprecated). ::: ## Migrate away first A **root** is a `file://` URI the client hands to the server as a boundary for its file operations. The 2026-07-28 revision deprecates the request that carries them, and nothing replaces it — give the server its paths directly. Send the path a call should act on as a tool argument ([Tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md)), expose the locations the server owns as resources ([Resources](https://ts.sdk.modelcontextprotocol.io/v2/servers/resources.md)), or put fixed directories in the server's own configuration. The rest of this page covers the roots API for clients that still answer 2025-era servers through the deprecation window. ## Declare the roots capability `roots` in the `Client` constructor's `capabilities` tells the server it can ask for the list; `listChanged: true` also lets you notify it when the list changes. ```ts import { Client } from '@modelcontextprotocol/client'; const client = new Client({ name: 'workspace-client', version: '1.0.0' }, { capabilities: { roots: { listChanged: true } } }); ``` Declare the capability before registering the handler: without it, `setRequestHandler('roots/list', …)` throws. ## Answer roots/list `setRequestHandler('roots/list', …)` returns `{ roots }`. Every `uri` must start with `file://`; `name` is optional. ```ts const roots = [ { uri: 'file:///home/user/projects/my-app', name: 'My App' }, { uri: 'file:///home/user/data', name: 'Data' } ]; client.setRequestHandler('roots/list', async () => { return { roots }; }); ``` A connected server that requests `roots/list` receives exactly what the handler returned: ``` [ { uri: 'file:///home/user/projects/my-app', name: 'My App' }, { uri: 'file:///home/user/data', name: 'Data' } ] ``` Roots are advisory boundaries, not an access grant — the server still reaches the filesystem with its own permissions, and the SDK never enforces the list on either side. ::: info On a 2026-07-28 connection there is no server-to-client request channel; the same handler fulfils a `roots/list` request embedded in an `input_required` result — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ::: ## Tell the server when the roots change `sendRootsListChanged()` sends `notifications/roots/list_changed`; it requires the `listChanged: true` declared above. ```ts roots.push({ uri: 'file:///home/user/projects/another-app', name: 'Another app' }); await client.sendRootsListChanged(); ``` The notification carries no payload. A server that watches it requests `roots/list` again and receives the updated list: ``` [ { uri: 'file:///home/user/projects/my-app', name: 'My App' }, { uri: 'file:///home/user/data', name: 'Data' }, { uri: 'file:///home/user/projects/another-app', name: 'Another app' } ] ``` ## Recap - Roots are deprecated (SEP-2577): pass paths through tool arguments, resource URIs, or configuration instead. - `capabilities: { roots: { listChanged: true } }` on the `Client` constructor declares the capability; register the `roots/list` handler only after declaring it. - The handler returns `{ roots }`, and every root `uri` starts with `file://`. - Roots are advisory boundaries, not an access grant. - `sendRootsListChanged()` notifies the server that the list changed; the server re-requests `roots/list` itself. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/clients/subscriptions.md ================================================================================ # Subscribe to changes A **subscription stream** is one long-lived `subscriptions/listen` request that carries every change notification you opted in to. On a connection that negotiated [2026-07-28](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md), change notifications arrive only on a stream you open — nothing arrives unsolicited. ## Open a subscription stream `listen` takes a **filter** naming the notification types you want. Register a handler for each type with `setNotificationHandler` before you open the stream. ```ts client.setNotificationHandler('notifications/tools/list_changed', async () => { const { tools } = await client.listTools(); console.log('Tools changed:', tools.length); }); const subscription = await client.listen({ toolsListChanged: true, resourceSubscriptions: ['config://app'] }); console.log('Server honored:', subscription.honoredFilter); ``` `listen()` resolves once the server acknowledges the stream, and returns an `McpSubscription` whose `honoredFilter` is the subset of your filter the server agreed to deliver: ``` Server honored: { toolsListChanged: true, resourceSubscriptions: [ 'config://app' ] } ``` The server narrows the filter to its advertised capabilities — `resourceSubscriptions` survives only when it advertises `resources: { subscribe: true }`, and each list-change field only when the matching `listChanged` capability is set. The four filter fields are `toolsListChanged`, `promptsListChanged`, `resourcesListChanged`, and `resourceSubscriptions` (an array of resource URIs). ## Handle the notifications `resourceSubscriptions` asked for per-resource updates; register the matching handler and re-read the resource when it fires. ```ts client.setNotificationHandler('notifications/resources/updated', async notification => { const { contents } = await client.readResource({ uri: notification.params.uri }); console.log('Updated', notification.params.uri, contents); }); ``` Every notification on the stream dispatches through `setNotificationHandler` — the same registration an unsolicited 2025-era notification fires, so register once for either delivery path. When the server publishes a tool change and an update to `config://app`, both handlers fire from the one stream: ``` Tools changed: 2 Updated config://app [ { uri: 'config://app', text: '{"theme":"dark"}' } ] ``` ## Close the stream and react to closure `close()` tears the stream down. `closed` resolves exactly once with the reason — it never rejects. ```ts await subscription.close(); console.log('Closed:', await subscription.closed); ``` The reason names who ended the stream: ``` Closed: local ``` `'local'` means you closed it, `'graceful'` means the server ended the subscription deliberately, and `'remote'` means the stream dropped without a response. The SDK never re-listens for you. Re-listen only on `'remote'`: ```ts while (watching) { const sub = await client.listen({ resourceSubscriptions: ['config://app'] }); const reason = await sub.closed; if (reason !== 'remote') break; // 'local' or 'graceful': done await new Promise(resolve => setTimeout(resolve, 1000)); // back off, then re-listen } ``` ## Let the SDK open the stream for you The `listChanged` client option opens and manages the stream itself. ```ts const watcher = new Client( { name: 'notes-watcher', version: '1.0.0' }, { listChanged: { tools: { onChanged: (error, tools) => { if (error) { console.error('Refresh failed:', error); return; } console.log('Tools refreshed:', tools?.length); } } } } ); ``` After `connect()` the SDK opens the stream from the intersection of the `listChanged` types you configured and the capabilities the server advertises, and exposes the handle as `autoOpenedSubscription`. On every change the SDK re-fetches the list and hands it to `onChanged`: ``` Tools refreshed: 1 ``` ::: warning `listChanged` registers its own handler for each configured `list_changed` type during `connect()`. The last registration for a notification type wins, so a manual `setNotificationHandler` for that type registered after connecting silently disables `listChanged` for it. ::: ## Fall back to legacy per-resource subscribe On a 2025-era connection, request per-resource updates with `subscribeResource` instead. ```ts await client.subscribeResource({ uri: 'config://app' }); // The same notifications/resources/updated handler fires. await client.unsubscribeResource({ uri: 'config://app' }); ``` The notification it produces is the same `notifications/resources/updated`, dispatched to the handler you already registered. ::: info `listen()` is 2026-07-28-only and `subscribeResource()` is 2025-era — on the wrong era each rejects with an `SdkError` whose code is `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION`. See [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ::: ## Recap - `listen(filter)` opens one stream carrying every change notification you asked for; `honoredFilter` is the capability-gated subset the server granted. - Notifications on the stream dispatch through `setNotificationHandler` — the same registrations 2025-era unsolicited notifications fire. - `closed` resolves exactly once with `'local'`, `'graceful'`, or `'remote'`, and never rejects; there is no automatic re-listen. - The `listChanged` client option opens and manages the stream for you, exposed as `autoOpenedSubscription`. - `subscribeResource` and `unsubscribeResource` are the 2025-era per-resource path; which path your connection supports is an era question. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/clients/oauth.md ================================================================================ # Authenticate a user with OAuth Protecting a server you run → [Require authorization](https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md). Signing a user in from a client → this page. No user present → [Authenticate without a user](https://ts.sdk.modelcontextprotocol.io/v2/clients/machine-auth.md). ## Hand the transport an OAuth provider Pass an **`OAuthClientProvider`** as the transport's `authProvider` — it, and every other symbol on this page, comes from `@modelcontextprotocol/client`. ```ts const provider = new MyOAuthProvider(); const client = new Client({ name: 'my-app', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider: provider }); try { await client.connect(transport); } catch (error) { if (!(error instanceof UnauthorizedError)) throw error; // The transport already called provider.redirectToAuthorization(url): // the end user is in the browser, at the authorization server. } ``` When the server requires authorization and the provider has no token, the SDK runs discovery against the server, registers (or looks up) your OAuth client, calls the provider's `redirectToAuthorization(url)`, and `connect()` throws `UnauthorizedError`. The end user finishes signing in out of band; your callback endpoint picks the flow back up below. ::: info With protocol-version negotiation in play, the connect-time 401 can also surface as an `SdkError` carrying the `UnauthorizedError` at `error.data.cause` — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ::: ## Implement OAuthClientProvider The provider is the storage and redirect surface the SDK drives: client registrations, tokens, the PKCE verifier, discovery state, and the browser hand-off. Key client credentials by `ctx.issuer` so a `client_id` registered with one authorization server is never sent to another. ```ts class MyOAuthProvider implements OAuthClientProvider { // Key DCR-obtained credentials by issuer so a client_id registered with one // authorization server is never returned for another (SEP-2352). private creds = new Map(); private storedTokens?: OAuthTokens; private verifier?: string; private discovery?: OAuthDiscoveryState; lastState?: string; readonly redirectUrl = 'http://localhost:8090/callback'; readonly clientMetadata: OAuthClientMetadata = { client_name: 'My MCP Client', redirect_uris: ['http://localhost:8090/callback'], // Loopback redirect → the SDK would default this to 'native'; set // explicitly when the heuristic is wrong for your deployment (SEP-837). application_type: 'native' }; clientInformation(ctx?: OAuthClientInformationContext) { return ctx ? this.creds.get(ctx.issuer) : undefined; } saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) { if (ctx) this.creds.set(ctx.issuer, info); } tokens() { return this.storedTokens; } saveTokens(tokens: OAuthTokens) { // In production, persist to OS keychain / secure storage — never plain files. this.storedTokens = tokens; } // CSRF binding for the redirect — the SDK puts this on the authorize URL; // your callback handler compares it before calling `finishAuth`. state() { this.lastState = crypto.randomUUID(); return this.lastState; } // Callback-leg AS-binding (SEP-2352): record what discovery resolved before // the redirect so the SDK can verify the code is exchanged at the same AS. saveDiscoveryState(state: OAuthDiscoveryState) { this.discovery = state; } discoveryState() { return this.discovery; } redirectToAuthorization(url: URL) { onRedirect(url); } saveCodeVerifier(v: string) { this.verifier = v; } codeVerifier() { if (!this.verifier) throw new Error('no code verifier'); return this.verifier; } } ``` The SDK calls the `save*` methods as the flow produces values and reads them back through `tokens()`, `clientInformation()`, `codeVerifier()`, and `discoveryState()`. On a later `connect()` it reads `tokens()` before anything else, so a provider backed by durable storage skips the browser round trip. ## Finish the flow from the callback The authorization server redirects the end user to `redirectUrl` with `code` and `state` in the query. Compare `state`, hand the whole query to `finishAuth`, and reconnect. ```ts const callbackUrl = await waitForCallback(); // however your app receives the redirect const params = new URL(callbackUrl).searchParams; // The SDK does not validate `state` — compare it to the value your provider generated. if (params.get('state') !== provider.lastState) throw new Error('state mismatch'); await transport.finishAuth(params); // Reconnect on a FRESH transport — a started transport cannot be restarted. // OAuth state (tokens, verifier, discovery) lives on the provider, not the transport. await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); ``` `finishAuth(params)` extracts `code`, validates the RFC 9207 `iss` parameter, exchanges the code at the authorization server discovery resolved before the redirect, and saves the tokens through your provider. The second `connect()` finds those tokens and completes without another redirect. ::: tip `finishAuth` also takes a positional form, `finishAuth(code, iss)`. Pass the `URLSearchParams` instead: the SDK reads both values from it, and a positional call that drops `iss` is rejected when the authorization server advertises RFC 9207 support. ::: ## Handle issuer mismatch `finishAuth` throws **`IssuerMismatchError`** when the callback's `iss` does not match the issuer the flow started with. ```ts try { await transport.finishAuth(params); } catch (error) { if (error instanceof IssuerMismatchError) { // Mix-up attack: never render params.get('error_description') to the user. throw new Error('Authorization failed: issuer mismatch'); } throw error; } ``` The error's `kind` is `'authorization_response'` here; the same check runs during discovery against the authorization server's published `issuer` (RFC 8414 §3.3) and throws with `kind: 'metadata'`. ::: warning A mismatch means the callback came from an authorization server you did not start the flow with — a mix-up attack. The callback's `error` and `error_description` are attacker-controlled: never render them. The transport's `skipIssuerMetadataValidation` option disables the discovery-leg check; leave it off unless you control the server. ::: ## Pin the resource indicator The SDK binds tokens to your server with the RFC 8707 `resource` parameter: when the server publishes protected resource metadata (RFC 9728), the SDK checks the metadata's `resource` against the server URL and attaches it to the authorization redirect and every token request. Override `validateResourceURL` to force the value — return the URL to send, or `undefined` to omit the parameter. ```ts class PinnedResourceProvider extends MyOAuthProvider { async validateResourceURL(serverUrl: string | URL, resource?: string): Promise { const expected = resourceUrlFromServerUrl(serverUrl); // strips the fragment (RFC 8707 §2) if (resource && !checkResourceAllowed({ requestedResource: expected, configuredResource: resource })) { throw new Error(`Refusing resource ${resource} for server ${expected.href}`); } return expected; } } ``` `PinnedResourceProvider` sends the server's own URL as `resource` on every leg of the flow and refuses metadata that names a different one. `checkResourceAllowed` and `resourceUrlFromServerUrl` are exported for exactly this override. ## Recap - This page signs in an end user; machine-to-machine flows live on [Authenticate without a user](https://ts.sdk.modelcontextprotocol.io/v2/clients/machine-auth.md). - Pass an `OAuthClientProvider` as the transport's `authProvider`; `connect()` throws `UnauthorizedError` after sending the user to the authorization server. - `finishAuth(params)` with the whole callback query validates `iss` (RFC 9207) and exchanges the code. - Reconnect on a fresh transport; OAuth state lives on the provider, not the transport. - `IssuerMismatchError` is the mix-up defense — never render the callback's `error_description`. - `validateResourceURL` overrides the RFC 8707 `resource` parameter the SDK sends. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/clients/machine-auth.md ================================================================================ # Authenticate without a user Protecting a server you run → [Require authorization](https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md). Authenticating an end user → [OAuth](https://ts.sdk.modelcontextprotocol.io/v2/clients/oauth.md). No user — a job, a backend, a service account → this page. ## Authenticate with client credentials `ClientCredentialsProvider` runs the OAuth `client_credentials` grant from a `client_id` and `client_secret`. Pass it as the transport's `authProvider` — every flow on this page plugs into that same option. ```ts import { Client, ClientCredentialsProvider, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const authProvider = new ClientCredentialsProvider({ clientId: 'reporting-job', clientSecret: 'reporting-job-secret' }); const client = new Client({ name: 'reporting-job', version: '1.0.0' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); await client.connect(transport); ``` `connect` discovers the server's authorization server, posts the grant to its token endpoint, and attaches the access token to every request. On a 401 the provider refreshes the token and the transport retries once. No browser, no end user. ::: tip Pass `expectedIssuer` to pin the credential to the authorization server it was registered with. If discovery resolves a different issuer, the SDK throws `AuthorizationServerMismatchError` instead of sending the secret. ::: ## Bring your own bearer token When something outside the SDK already owns the token — an API key, a gateway, a platform secret store — implement `AuthProvider` with only `token()`. ```ts const authProvider: AuthProvider = { token: async () => getStoredToken() }; const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); ``` The transport calls `token()` before every request and sets the `Authorization` header from whatever it returns. Without `onUnauthorized`, a 401 throws `UnauthorizedError`. Add `onUnauthorized(ctx)` to refresh the credential and the transport retries the request once. ## Sign with a private key instead of a secret `PrivateKeyJwtProvider` runs the same `client_credentials` grant, but authenticates the token request with a signed JWT assertion (`private_key_jwt`, RFC 7523) in place of a shared secret. ```ts const authProvider = new PrivateKeyJwtProvider({ clientId: 'reporting-job', privateKey: pemEncodedKey, algorithm: 'RS256' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); ``` `privateKey` accepts a PEM string, a `Uint8Array`, or a JWK object. The provider signs a fresh assertion for every token request; `jwtLifetimeSeconds` overrides the 300-second default, and `claims` merges extra claims into the assertion. ## Act for an enterprise user with cross-app access **Cross-app access** (Enterprise Managed Authorization, SEP-990) lets a service reach an MCP server for a user who already authenticated with the enterprise IdP, with no second consent screen. Two exchanges get it there: the IdP ID Token becomes a **JWT Authorization Grant** (RFC 8693), and that grant becomes an MCP access token (RFC 7523). `CrossAppAccessProvider` runs the second exchange. Your `assertion` callback supplies the grant — here `discoverAndRequestJwtAuthGrant` performs the first exchange against the IdP. ```ts const authProvider = new CrossAppAccessProvider({ assertion: async ctx => { const grant = await discoverAndRequestJwtAuthGrant({ idpUrl: 'https://idp.example.com', audience: ctx.authorizationServerUrl, resource: ctx.resourceUrl, idToken: await getIdToken(), clientId: 'idp-exchange-client', clientSecret: 'idp-exchange-secret', scope: ctx.scope, fetchFn: ctx.fetchFn }); return grant.jwtAuthGrant; }, clientId: 'reporting-job', clientSecret: 'reporting-job-secret' }); const transport = new StreamableHTTPClientTransport(new URL('https://api.example.com/mcp'), { authProvider }); ``` The SDK discovers the MCP server's authorization server and resource URL (RFC 9728) before it calls `assertion`, then hands them in on `ctx` together with the negotiated `scope` and the transport's `fetchFn`. Pass them through so the IdP issues a grant bound to the right audience and resource. ## Drop to the token-exchange utilities Both exchanges behind `CrossAppAccessProvider` are exported as standalone functions for flows the provider does not cover — caching grants across transports, a non-standard IdP step, your own token store. - `requestJwtAuthorizationGrant` exchanges an ID Token for a JWT Authorization Grant at a known IdP token endpoint (RFC 8693). - `discoverAndRequestJwtAuthGrant` performs the same exchange, discovering the IdP's token endpoint from `idpUrl` first. - `exchangeJwtAuthGrant` exchanges a JWT Authorization Grant for an access token at the MCP server's authorization server (RFC 7523). All three live in [`client/crossAppAccess`](https://ts.sdk.modelcontextprotocol.io/v2/api/@modelcontextprotocol/client/client/crossAppAccess.html) in the API reference. ## Recap - Every flow on this page plugs in through the same `authProvider` option on `StreamableHTTPClientTransport`. - `ClientCredentialsProvider` runs the `client_credentials` grant with a shared secret; `PrivateKeyJwtProvider` runs the same grant with a signed JWT assertion in its place. - An `AuthProvider` with only `token()` is enough when something outside the SDK owns the token; without `onUnauthorized`, a 401 throws `UnauthorizedError`. - `CrossAppAccessProvider` chains an enterprise IdP token through a JWT Authorization Grant to an MCP access token (SEP-990), and both exchanges are exported standalone. - Authenticating an end user belongs on [OAuth](https://ts.sdk.modelcontextprotocol.io/v2/clients/oauth.md). ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/clients/middleware.md ================================================================================ # Compose client middleware A **middleware** wraps the `fetch` a client transport uses, so it sees every HTTP request on the way out and every `Response` on the way back. ## Write a middleware `createMiddleware` builds one from a function that receives the next handler plus the request. Compose it onto `fetch` with `applyMiddlewares` and hand the result to the transport's `fetch` option. ```ts import { applyMiddlewares, createMiddleware, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const tagRequests = createMiddleware(async (next, input, init) => { const headers = new Headers(init?.headers); headers.set('X-Request-Source', 'reports-cli'); return next(input, { ...init, headers }); }); const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { fetch: applyMiddlewares(tagRequests)(fetch) }); ``` Every request this transport sends now carries the header — including the requests the SDK sends that you never wrote, like `initialize`. ::: info Not the framework middleware packages This page is about client request middleware: functions that wrap the `fetch` inside `@modelcontextprotocol/client`. The `@modelcontextprotocol/express`, `@modelcontextprotocol/hono`, and `@modelcontextprotocol/node` packages also carry the word "middleware" — those are server-side framework adapters for mounting a handler. See [Express](https://ts.sdk.modelcontextprotocol.io/v2/serving/express.md) and [Hono](https://ts.sdk.modelcontextprotocol.io/v2/serving/hono.md). ::: ## Compose several middlewares `applyMiddlewares` takes any number of middlewares; each one in the list wraps everything before it. Stub out the network and stamp each layer's name on both sides of `next` to watch the order. ```ts const stamp = (name: string) => createMiddleware(async (next, input, init) => { console.log(`-> ${name}`); const response = await next(input, init); console.log(`<- ${name}`); return response; }); const base = async () => new Response('ok'); await applyMiddlewares(stamp('retry'), stamp('auth'), stamp('trace'))(base)('http://localhost:3000/mcp'); ``` The last middleware you pass is outermost — it sees the request first and the response last: ``` -> trace -> auth -> retry <- retry <- auth <- trace ``` The first middleware you pass sits closest to the network. Put a retry there so every layer above it sees one settled `Response`. ## Use the built-in logging middleware `withLogging` ships in `@modelcontextprotocol/client`; called with no options it logs every request the wrapped `fetch` makes. ```ts const loggedFetch = applyMiddlewares(tagRequests, withLogging())(fetch); ``` Connect through `loggedFetch` and call one tool. Four requests reach the wire, and you wrote one of them: ``` HTTP POST http://localhost:3000/mcp 200 (0ms) HTTP POST http://localhost:3000/mcp 202 (0ms) HTTP GET http://localhost:3000/mcp 405 (0ms) HTTP POST http://localhost:3000/mcp 200 (0ms) ``` The `POST`s are `initialize`, the `notifications/initialized` notification, and your `tools/call`; the `GET` opens the server-to-client stream, which this server declines. Pass `statusLevel: 400` to log only failures, `includeRequestHeaders` / `includeResponseHeaders` to add headers to each line, and `logger` to replace the formatter entirely. ::: warning The default logger writes to `console.log` and `console.error`. In a process whose stdout carries an MCP stdio transport, pass your own `logger` so these lines stay off that stream. ::: ## Combine middleware with an auth provider `withOAuth(provider, serverUrl)` is the OAuth flow expressed as one middleware layer: it adds the `Authorization` header, and on a `401` it re-authenticates against `serverUrl` and retries the request once. ```ts const serverUrl = new URL('http://localhost:3000/mcp'); const authed = new StreamableHTTPClientTransport(serverUrl, { fetch: applyMiddlewares(withOAuth(provider, serverUrl), withLogging({ statusLevel: 400 }))(fetch) }); ``` `provider` is the same `OAuthClientProvider` you would hand to the transport directly. With `statusLevel: 400`, `withLogging` stays silent until a request fails. ::: tip For the common case, pass `authProvider` to the transport instead — see [OAuth](https://ts.sdk.modelcontextprotocol.io/v2/clients/oauth.md). `withOAuth` is for stacks that already own `fetch` and need auth composed with other layers. ::: ## Inspect the response A middleware runs on both sides of `next`: read the request body before the call and the `Response` after it. Map each JSON-RPC method to the HTTP status it came back with. ```ts const observeStatus = createMiddleware(async (next, input, init) => { const response = await next(input, init); if (typeof init?.body === 'string') { const { method } = JSON.parse(init.body) as { method?: string }; console.log(`${method ?? 'response'} -> HTTP ${response.status}`); } return response; }); ``` Connecting through `observeStatus` and calling one tool prints one line per request that carried a body: ``` initialize -> HTTP 200 notifications/initialized -> HTTP 202 tools/call -> HTTP 200 ``` Always return the `Response`; the transport consumes its body after you. To read the body too, read a `response.clone()`. ## Recap - A middleware wraps the transport's `fetch`: `createMiddleware` builds one, `applyMiddlewares` composes many, and the transport's `fetch` option takes the result. - The last middleware passed to `applyMiddlewares` is outermost; the first sits closest to the network. - A middleware sees every HTTP request the transport sends, including the ones the SDK sends on its own. - `withLogging` and `withOAuth` ship in `@modelcontextprotocol/client`. - A middleware sees both directions: the request before `next`, the `Response` after it. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/clients/caching.md ================================================================================ # Cache responses Caching is one feature with two halves: the server marks a result with a freshness hint, and the client's **response cache** serves it locally while it stays fresh. ## Let the cache work The cacheable verbs check the cache before they send. A still-fresh entry comes back without a round trip; `cacheMode` overrides the disposition per call. ```ts const tools = await client.listTools(); // network, then cached for the server's ttlMs const again = await client.listTools(); // served from cache while still fresh await client.listTools(undefined, { cacheMode: 'refresh' }); // always refetch and re-store await client.readResource({ uri: 'config://app' }, { cacheMode: 'bypass' }); // no cache read or write ``` `client` is connected to the server in the next section — served in-process by `createMcpHandler`, the wiring [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows — and the harness counts every request that reaches it. After all four calls, only the first `listTools()` and the `'refresh'` crossed the wire: ``` tools/list requests that reached the server: 2 resources/read requests that reached the server: 1 ``` Nothing on the client opts in: every `Client` holds a response cache, and the server's hint decides what it may serve. ## Have the server send the hint `ServerOptions.cacheHints` attaches a `ttlMs` and a `cacheScope` to each cacheable result it names (SEP-2549) — without one, the SDK emits `ttlMs: 0` and no client ever serves that result from cache. ```ts const server = new McpServer( { name: 'catalog', version: '1.0.0' }, { cacheHints: { 'tools/list': { ttlMs: 60_000, cacheScope: 'public' }, 'resources/read': { ttlMs: 5_000, cacheScope: 'private' } } } ); ``` `registerResource` also takes a per-resource `cacheHint`; it wins, field by field, over the `resources/read` entry here for that resource's read results. Mark a result `cacheScope: 'public'` only when it is identical for every caller — anything derived from the caller's authorization context stays `'private'`, the default. ::: tip A server cannot pin an entry forever: the client caps any `ttlMs` at 24 hours (`MAX_CACHE_TTL_MS`). ::: ## Choose a cache mode per call `cacheMode` on `listTools()`, `listPrompts()`, `listResources()`, `listResourceTemplates()`, and `readResource()` — the cacheable verbs — takes one of three values. `'use'`, the default, serves a still-fresh entry and otherwise fetches and stores. `'refresh'` always fetches and stores the fresh result. `'bypass'` fetches without reading or writing: it leaves the cache byte-untouched, including the `tools/list` entry the SDK itself reads for output validation when you [call tools](https://ts.sdk.modelcontextprotocol.io/v2/clients/calling.md). ## Bring your own store `responseCacheStore` swaps the backing store; the default is a fresh `InMemoryResponseCacheStore` per client, holding at most 512 `resources/read` entries. ```ts const store = new InMemoryResponseCacheStore({ maxEntries: 2048 }); const client = new Client({ name: 'my-client', version: '1.0.0' }, { responseCacheStore: store }); ``` Every method on the `ResponseCacheStore` interface may return a promise, so a Redis-style store implements the same five methods. Entries are keyed by connected-server identity, so one store can back many clients: connections to different servers never collide. ## Partition the store per user When one shared store serves several principals, set `cachePartition` to a stable identity of the authorization context — the auth subject, for example. ```ts const client = new Client({ name: 'gateway', version: '1.0.0' }, { responseCacheStore: sharedStore, cachePartition: userId }); ``` `'private'`-scoped entries are stored under that partition and never read across it; `'public'`-scoped entries stay shared within the server's namespace. ::: warning A shared store without `cachePartition` can serve one user's `'private'`-scoped resource bodies to another. Set it whenever the store outlives a single principal. ::: ## Cache against servers that send no hints `defaultCacheTtlMs` is the TTL applied when a cacheable result arrives without a `ttlMs`. The default is `0`: a result with no hint is never served from cache. ```ts const client = new Client({ name: 'my-client', version: '1.0.0' }, { defaultCacheTtlMs: 60_000 }); ``` Fresh or not, the cache also evicts itself when the server signals a change: a `list_changed` notification drops the matching list entries, and `notifications/resources/updated` drops the cached body for that URI — see [Subscriptions](https://ts.sdk.modelcontextprotocol.io/v2/clients/subscriptions.md). ::: info Cache hints are a 2026-07-28 surface — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). Against a 2025-era server, `defaultCacheTtlMs` is the only lever. ::: ## Recap - Caching is one feature with two halves: the server attaches `ttlMs` / `cacheScope`, the client honours them — by default neither half does anything alone. - `listTools()`, `listPrompts()`, `listResources()`, `listResourceTemplates()`, and `readResource()` serve a still-fresh result without a round trip; `cacheMode` overrides per call. - A result without a hint carries `ttlMs: 0` and is never served from cache; the client caps every `ttlMs` at 24 hours. - `responseCacheStore` swaps the backing store; `cachePartition` is mandatory when that store serves several principals. - `defaultCacheTtlMs` opts in to caching against servers that send no hints. - `list_changed` and `notifications/resources/updated` evict matching entries automatically. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md ================================================================================ # Protocol versions ## Name the two eras An **era** is a behavior family, not a version string. Every protocol revision from `2024-10-07` through `2025-11-25` opens with the `initialize` handshake and shares one wire behavior — the SDK calls that family `legacy`. The `2026-07-28` revision starts the `modern` era: no `initialize`, a `server/discover` advertisement instead, and a `_meta` envelope on every request. The SDK speaks both eras from the same `Client` and serves both from the same entry points. A connection's era is decided once, at connect time, and every difference it implies is in [the matrix below](#compare-the-eras). ## Negotiate the era from the client `versionNegotiation` picks which handshake `connect()` performs. `mode: 'auto'` probes the server with `server/discover` and connects on whichever era it finds. ```ts import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'))); console.log(client.getProtocolEra()); ``` `http://localhost:3000/mcp` is a `createMcpHandler` server — [built below](#serve-both-eras-from-one-entry-point) — so the probe finds the 2026-07-28 era: ``` modern ``` Point the same options at a 2025-only server and `connect()` falls back to the `initialize` handshake on the same connection — one extra round trip, no error. ```ts const fallback = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await fallback.connect(new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp'))); console.log(fallback.getProtocolEra()); ``` `getProtocolEra()` reports the era the connection landed on; it returns `undefined` before `connect()` resolves and never changes after it. ``` legacy ``` ## Pin an era `mode` takes three values; the first is the default. - Absent, or `mode: 'legacy'` — the 2025 `initialize` handshake, byte for byte. No probe. - `mode: 'auto'` — probe with `server/discover`; fall back to `initialize` against a 2025-only server. - `mode: { pin: '2026-07-28' }` — that revision or nothing. A pin never falls back. Pin against the same 2025-only server and `connect()` rejects instead of falling back. ```ts import { SdkError } from '@modelcontextprotocol/client'; const pinned = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); try { await pinned.connect(new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp'))); } catch (error) { if (error instanceof SdkError) console.log(`${error.code}: ${error.message}`); } ``` The rejection is a typed, local `SdkError` — nothing reaches the server beyond the probe: ``` ERA_NEGOTIATION_FAILED: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode) ``` ## Understand the probe `probe` bounds the `server/discover` round trip that `'auto'` and a pin run before anything else. ```ts const cli = new Client( { name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto', probe: { timeoutMs: 10_000, // default: the connection's request timeout maxRetries: 0 // default: no probe re-sends after a timeout } } } ); ``` A probe timeout is transport-aware. On stdio a silent server is a legacy server, so `connect()` falls back to `initialize` on the same stream; on HTTP silence is an outage, so `connect()` rejects with `SdkError(RequestTimeout)` instead of misreporting a dead server as legacy. One browser exception: an opaque CORS `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have allow-lists that predate the 2026 headers. The client's `supportedProtocolVersions` option shapes the probe: its 2026+ entries are the versions the probe offers, and the legacy fallback stays available only while the list keeps a pre-2026 entry. A list with no pre-2026 entry removes the fallback — against a 2025-only server, `connect()` rejects with `SdkError(EraNegotiationFailed)`. ::: warning Do not default a spawn-per-invocation CLI tool to `'auto'`. On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls `connect()` for the full probe timeout before falling back, and the extra round trip changes recorded transcripts. Keep the default and expose `'auto'` (or a pin) as a flag. ::: ## Serve both eras from one entry point `createMcpHandler` is the HTTP entry that answered both clients above: it builds a fresh server per request and passes the factory the `era` that request belongs to. ```ts import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const handler = createMcpHandler(({ era }) => { const server = new McpServer({ name: 'forecast', version: '1.0.0' }); server.registerTool( 'forecast', { description: 'Forecast for a city', inputSchema: z.object({ city: z.string() }) }, async ({ city }) => ({ content: [{ type: 'text', text: `${city}: sunny (${era} era)` }] }) ); return server; }); ``` By default the handler also serves 2025-era traffic per request (`legacy: 'stateless'`); pass `legacy: 'reject'` to refuse it. Connect one more client with the default mode to the same URL — no probe, the 2025 handshake — and call the tool from both. ```ts const defaultClient = new Client({ name: 'my-client', version: '1.0.0' }); await defaultClient.connect(new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'))); for (const caller of [client, defaultClient]) { const result = await caller.callTool({ name: 'forecast', arguments: { city: 'Berlin' } }); console.log(caller.getProtocolEra(), JSON.stringify(result.content)); } ``` One endpoint, one factory, two eras — and the era reached the handler: ``` modern [{"type":"text","text":"Berlin: sunny (modern era)"}] legacy [{"type":"text","text":"Berlin: sunny (legacy era)"}] ``` On stdio, `serveStdio(factory)` from `@modelcontextprotocol/server/stdio` is the same shape per connection: the opening exchange pins the connection's era, and `legacy: 'reject'` refuses 2025 openings. [Serve legacy clients](https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients.md) owns the `legacy` option and the hosting recipes for both entries. ## Compare the eras This table is the only copy of the era differences in these docs. `getProtocolEra()` on the client and the factory's `era` on the server tell you which column you are in. | Axis | 2025 era (`'legacy'`, `2024-10-07` … `2025-11-25`) | 2026 era (`'modern'`, `2026-07-28`) | | ------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------ | | Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | | Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | | Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | | Client identity on the server | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | | Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` | `return inputRequired(...)` from the handler | | Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | | Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | | `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `logLevel` `_meta` envelope key (absent = no logs) | | HTTP `400` with a JSON-RPC error body | `SdkHttpError` | `ProtocolError`, delivered in-band | | Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` | ## Separate deprecation from era Deprecation is not an era difference. `sampling`, `roots`, and the `logging` capability behind `ctx.mcpReq.log()` are deprecated as of `2026-07-28` (SEP-2577) but stay in the specification for at least twelve months; which API carries each one on a given connection is an era difference, and already has its row in the matrix above. Each deprecated surface opens its own page with a sunset banner naming the migration target; nothing in the matrix moves when a deprecation lands. ## Link here instead of explaining inline Era differences live on this page and nowhere else. Every other page in these docs spends at most one sentence on an era and links here; do the same in your own server's documentation. > The wire encoding of structured results differs by protocol era — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Recap - An era is a behavior family: `legacy` covers `2024-10-07` through `2025-11-25`, `modern` starts at `2026-07-28`. - `versionNegotiation` picks the client handshake; the default is the unchanged 2025 `initialize`, no probe. - `mode: 'auto'` probes with `server/discover` and falls back to `initialize`; a pin never falls back and rejects with `SdkError(EraNegotiationFailed)`. - `getProtocolEra()` reports the negotiated era on the client; the `createMcpHandler` / `serveStdio` factory receives the `era` it is about to serve. - The behavior matrix on this page is the only copy; every other page links here in one line. - Deprecation (SEP-2577) is not an era difference. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/advanced/low-level-server.md ================================================================================ # Low-level Server `Server` is the **protocol layer** under `McpServer`: it routes each JSON-RPC request to the handler you register for that method string, and nothing more. Rebuild the `search` tool from [Tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md) on it to see what `registerTool` adds. ## Build the server and list your tools by hand Declare the `tools` capability in the constructor and answer `tools/list` yourself. `inputSchema` is the raw JSON Schema the client and the model see. ```ts import { Server } from '@modelcontextprotocol/server'; const catalog = [ { name: 'Espresso cup', price: 12 }, { name: 'Travel mug', price: 24 }, { name: 'Mug rack', price: 36 } ]; const server = new Server({ name: 'catalog', version: '1.0.0' }, { capabilities: { tools: {} } }); server.setRequestHandler('tools/list', async () => ({ tools: [ { name: 'search', description: 'Search the product catalog', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'Substring to match against product names' } }, required: ['query'] } } ] })); ``` A client's `tools/list` returns exactly the array you wrote — the SDK derived none of it. ::: tip Drop `capabilities: { tools: {} }` and `setRequestHandler('tools/list', …)` throws. `Server` never infers a capability from a handler, the way `registerTool` registers the `tools` capability for you. ::: ## Handle `tools/call` yourself `tools/call` is one handler for every tool. Dispatch on `request.params.name` and read `request.params.arguments` yourself. ```ts server.setRequestHandler('tools/call', async request => { if (request.params.name !== 'search') { return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true }; } const { query } = request.params.arguments as { query: string }; const hits = catalog.filter(product => product.name.toLowerCase().includes(query.toLowerCase())); return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] }; }); ``` An in-memory `Client` connected to this server — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring — calls `search` with `{ query: 'mug' }` and the handler's `content` comes back unchanged: ``` [ { type: 'text', text: 'Travel mug\nMug rack' } ] ``` Now call it with `{ query: 42 }`. The protocol layer checks only that `arguments` is an object, so the value reaches the handler and the handler crashes: ``` ProtocolError -32603: query.toLowerCase is not a function ``` `callTool` rejected with a protocol error instead of resolving to an `isError: true` tool result — [Errors](https://ts.sdk.modelcontextprotocol.io/v2/servers/errors.md) covers the difference. ## Validate arguments yourself From one Zod `inputSchema` the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types. Here you wrote the JSON Schema by hand, the cast went unchecked, and nothing tied the two together. `fromJsonSchema` — exported from `@modelcontextprotocol/server` — wraps a JSON Schema object as a validator you run yourself. Registering `tools/call` again replaces the handler; this one rejects before it touches the arguments. ```ts const SearchArguments = fromJsonSchema<{ query: string }>({ type: 'object', properties: { query: { type: 'string' } }, required: ['query'] }); server.setRequestHandler('tools/call', async request => { if (request.params.name !== 'search') { return { content: [{ type: 'text', text: `Unknown tool: ${request.params.name}` }], isError: true }; } const parsed = await SearchArguments['~standard'].validate(request.params.arguments ?? {}); if (parsed.issues) { return { content: [{ type: 'text', text: parsed.issues.map(issue => issue.message).join('; ') }], isError: true }; } const hits = catalog.filter(product => product.name.toLowerCase().includes(parsed.value.query.toLowerCase())); return { content: [{ type: 'text', text: hits.map(product => product.name).join('\n') }] }; }); ``` The same `{ query: 42 }` call now comes back as an ordinary tool result the model can read and retry: ``` { content: [ { type: 'text', text: 'data/query must be string' } ], isError: true } ``` Keeping the schema you advertise in `tools/list` identical to the one you validate with is still on you — `registerTool` derives both from the same object. ## Serve it with the same entry points `serveStdio` — from `@modelcontextprotocol/server/stdio` — and `createMcpHandler` each take an `McpServerFactory`, and the factory returns either an `McpServer` or a `Server`. ```ts serveStdio(() => server); createMcpHandler(() => server); ``` Every serving recipe — [stdio](https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.md), [HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md) — applies to this server unchanged. ## Reach the low level from `McpServer` Every `McpServer` owns its `Server` as `mcp.server`, so drop down per method, never per program. Declare the extra capability in the constructor, keep `registerTool` for the tools, and hand-register the one method `McpServer` has no API for. ```ts const mcp = new McpServer({ name: 'catalog', version: '1.0.0' }, { capabilities: { resources: { subscribe: true } } }); mcp.registerTool( 'search', { description: 'Search the product catalog', inputSchema: z.object({ query: z.string() }) }, async ({ query }) => { const names = catalog.filter(product => product.name.includes(query)).map(product => product.name); return { content: [{ type: 'text', text: names.join('\n') }] }; } ); const subscriptions = new Set(); mcp.server.setRequestHandler('resources/subscribe', async request => { subscriptions.add(request.params.uri); return {}; }); ``` `registerTool` still answers `tools/list` and `tools/call`; `resources/subscribe` reaches the handler you wrote. On the 2026-07-28 revision resource subscriptions arrive on a `subscriptions/listen` stream the serving entries answer for you — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Decide which layer to build on Default to `McpServer`. `registerTool`, `registerResource`, and `registerPrompt` cover everything this page rebuilt — schema derivation, argument validation, typed handler arguments — plus the bookkeeping it skipped: `listChanged` notifications, [completions](https://ts.sdk.modelcontextprotocol.io/v2/servers/completion.md), and the list/read/get dispatch for every registry. Build on `Server` when you own dispatch: a [gateway](https://ts.sdk.modelcontextprotocol.io/v2/advanced/gateway.md) that forwards whatever method arrives, a tool set computed per request from an external registry, or [custom methods](https://ts.sdk.modelcontextprotocol.io/v2/advanced/custom-methods.md) outside the spec. You never choose once for the whole program. Start on `McpServer` and take over individual methods through `mcp.server` as they need it. ## Recap - `Server` is the protocol layer: `setRequestHandler(method, handler)` per spec method, and nothing derived on top. - On `Server` you write the JSON Schema in `tools/list` and the argument validation in `tools/call`; `registerTool` derives both from one Zod schema. - A handler exception on `Server` reaches the client as a protocol error, not as an `isError: true` tool result. - `serveStdio` and `createMcpHandler` accept a factory that returns a `Server` unchanged. - `mcp.server` is the per-method escape hatch; default to `McpServer` and drop to `Server` only where you own dispatch. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/advanced/custom-methods.md ================================================================================ # Custom methods A **custom method** is a JSON-RPC method outside the MCP specification. Prefix it with a vendor namespace — `acme/search`, never a bare `search` — so it can never collide with a spec method. ## Handle a vendor-prefixed method on the server `setRequestHandler` lives on the low-level [`Server`](https://ts.sdk.modelcontextprotocol.io/v2/advanced/low-level-server.md), reached from an `McpServer` as `mcp.server`. A non-spec method needs schemas: pass `{ params, result }` as the second argument and the handler receives the parsed `params` object directly. ```ts import { McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; const SearchParams = z.object({ query: z.string(), limit: z.number().int().default(10) }); const SearchResult = z.object({ items: z.array(z.string()) }); const mcp = new McpServer({ name: 'acme-search', version: '1.0.0' }); mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }) => { return { items: Array.from({ length: limit }, (_, index) => `${query}-${index}`) }; }); ``` The SDK validates incoming `params` against `SearchParams` before the handler runs; `result` types the handler's return value. A spec method never takes the schema bundle — `setRequestHandler('tools/call', handler)` resolves its schemas from the method name. ::: tip Send `acme/search` with `query: 42` and the request fails before your handler runs — the caller gets back an `Invalid params` JSON-RPC error: ``` Invalid params for acme/search: query: Invalid input: expected string, received number ``` ::: ## Call it from the client Every call on this page comes from an in-memory `Client` connected to the server above — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring. For a non-spec method, `client.request` takes the request and a result schema; the SDK validates the response against it before the promise resolves and infers the return type from it. ```ts const result = await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 3 } }, SearchResult); console.log(result); ``` The handler's return value comes back validated and typed: ``` { items: [ 'mcp-0', 'mcp-1', 'mcp-2' ] } ``` ::: info For spec methods, `client.request({ method: 'tools/list' })` takes no schema — the SDK resolves it from the method name, exactly as `setRequestHandler` does on the server. ::: ## Send a custom notification from the handler A custom **notification** is the one-way mirror of a custom request: vendor-prefixed, no result. Registering a method again replaces its handler — replace `acme/search` with one that reports progress through `ctx.mcpReq.notify`. ```ts mcp.server.setRequestHandler('acme/search', { params: SearchParams, result: SearchResult }, async ({ query, limit }, ctx) => { await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'start', pct: 0 } }); const items = Array.from({ length: limit }, (_, index) => `${query}-${index}`); await ctx.mcpReq.notify({ method: 'acme/searchProgress', params: { stage: 'done', pct: 1 } }); return { items }; }); ``` `ctx.mcpReq.notify` sends each notification to the peer whose request is being handled, on the same connection. ## Receive it on the client `setNotificationHandler` follows the same rule as `setRequestHandler`: a non-spec notification method takes a `{ params }` schema, and the handler receives the parsed params. ```ts const SearchProgressParams = z.object({ stage: z.string(), pct: z.number() }); client.setNotificationHandler('acme/searchProgress', { params: SearchProgressParams }, params => { console.log(params); }); await client.request({ method: 'acme/search', params: { query: 'mcp', limit: 1 } }, SearchResult); ``` That one call logs both stages: ``` { stage: 'start', pct: 0 } { stage: 'done', pct: 1 } ``` ## Declare an extension capability An **extension capability** advertises a vendor feature during capability negotiation: `capabilities.extensions` maps a prefix-qualified extension identifier to that extension's settings object. Declare entries with `registerCapabilities` before connecting. ```ts mcp.server.registerCapabilities({ extensions: { 'com.example/feature-flags': { flags: ['dark-mode', 'beta-search'] } } }); ``` Every client that connects sees the entry. The settings value is free-form JSON; `{}` means supported with no settings. ## Read the negotiated extensions on the client After connecting, the advertised map is on `client.getServerCapabilities()`. ```ts const extensions = client.getServerCapabilities()?.extensions ?? {}; console.log(extensions); ``` The map arrives exactly as the server declared it: ``` { 'com.example/feature-flags': { flags: [ 'dark-mode', 'beta-search' ] } } ``` Legacy connections advertise it in the `initialize` result and 2026-07-28 connections in `server/discover` — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Recap - `setRequestHandler(method, { params, result }, handler)` handles a non-spec method; spec methods never take the schema bundle. - The SDK validates incoming `params` before the handler runs and rejects what fails with an `Invalid params` error; `result` types the handler's return value. - `client.request(request, ResultSchema)` is the calling side; the SDK validates the response against the schema. - Custom notifications mirror custom requests: `ctx.mcpReq.notify` on one side, `setNotificationHandler` with `{ params }` on the other. - `capabilities.extensions` advertises a vendor feature before connecting; the client reads the negotiated map after. - Method names and extension identifiers are prefix-qualified (`acme/search`, `com.example/feature-flags`) — never bare words. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/advanced/schema-libraries.md ================================================================================ # Schema libraries ## Register a tool with an ArkType schema `inputSchema` accepts any **Standard Schema** that can produce JSON Schema — ArkType works as-is, no wrapper, exactly like the Zod schemas in [Tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md). ```ts import { McpServer } from '@modelcontextprotocol/server'; import { type } from 'arktype'; const server = new McpServer({ name: 'schema-zoo', version: '1.0.0' }); server.registerTool( 'greet', { description: 'Greet someone by name', inputSchema: type({ name: 'string', 'times?': '1 <= number.integer <= 5' }) }, async ({ name, times }) => ({ content: [{ type: 'text', text: Array.from({ length: times ?? 1 }, () => `Hello, ${name}`).join('\n') }] }) ); ``` From that one schema the SDK derives the JSON Schema the model sees, validates arguments before your handler runs, and infers the handler's argument types — `name` is `string`, `times` is `number | undefined`. Every call on this page comes from an in-memory `Client` connected to this server — [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows that wiring. Call `greet` with `times: 99` and the SDK rejects the call with ArkType's own message; the handler never runs: ``` { content: [ { type: 'text', text: 'Input validation error: Invalid arguments for tool greet: times: times must be at most 5 (was 99)' } ], isError: true } ``` ::: info Coming from v1? Raw shapes (`inputSchema: { name: z.string() }`) are deprecated — pass a schema object. See the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: ## Register a tool with a Valibot schema Valibot does not expose JSON Schema conversion on the schema itself — wrap it with `toStandardJsonSchema` from `@valibot/to-json-schema`. ```ts import { toStandardJsonSchema } from '@valibot/to-json-schema'; import * as v from 'valibot'; server.registerTool( 'shout', { description: 'Greet someone, loudly', inputSchema: toStandardJsonSchema(v.object({ name: v.string() })) }, async ({ name }) => ({ content: [{ type: 'text', text: `HELLO, ${name.toUpperCase()}` }] }) ); ``` `tools/list` now advertises `shout` with the JSON Schema the wrapper derives, and Valibot parses every call that reaches the handler. ## Start from JSON Schema you already have `fromJsonSchema` (exported from `@modelcontextprotocol/server`) wraps a plain JSON Schema document so you can register it without a schema library. The generic parameter types the handler's arguments; omit it and they are `unknown`. ```ts import { fromJsonSchema } from '@modelcontextprotocol/server'; server.registerTool( 'farewell', { description: 'Say goodbye', inputSchema: fromJsonSchema<{ name: string }>({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }) }, async ({ name }) => ({ content: [{ type: 'text', text: `Goodbye, ${name}` }] }) ); ``` `tools/list` advertises the document you passed, unchanged: ``` { type: 'object', properties: { name: { type: 'string' } }, required: [ 'name' ] } ``` The SDK checks every call against it with a real **JSON Schema validator** — the last two sections pick which one. ## Validate structured output with any library `outputSchema` — and a prompt's `argsSchema` — follow the same Standard Schema rule. Return the matching value as `structuredContent`, next to the human-readable `content`. ```ts server.registerTool( 'measure', { description: 'Measure the length of a name', inputSchema: type({ name: 'string' }), outputSchema: type({ name: 'string', length: 'number' }) }, async ({ name }) => { const output = { name, length: name.length }; return { content: [{ type: 'text', text: JSON.stringify(output) }], structuredContent: output }; } ); ``` The SDK validates `structuredContent` against the ArkType schema before the result leaves your server. Calling `measure` with `{ name: 'Ada' }` returns both renderings: ``` { content: [ { type: 'text', text: '{"name":"Ada","length":3}' } ], structuredContent: { name: 'Ada', length: 3 } } ``` ## Swap the JSON Schema validator The server runs a JSON Schema validator in two places: a `fromJsonSchema` schema, and [elicitation](https://ts.sdk.modelcontextprotocol.io/v2/servers/elicitation.md) form responses. Build one from the `validators/ajv` subpath, which re-exports the SDK's bundled `Ajv` and `addFormats`. ```ts import { addFormats, Ajv, AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; const ajv = new Ajv({ strict: true, allErrors: true }); addFormats(ajv); const validator = new AjvJsonSchemaValidator(ajv); const strict = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: validator }); ``` `strict` now checks elicitation form responses with your `Ajv` instance. ::: warning `jsonSchemaValidator` covers elicitation form responses only. A `fromJsonSchema` schema binds its validator at creation — pass yours as the second argument: `fromJsonSchema(document, validator)`. ::: ## Pick the validator for your runtime Leave `jsonSchemaValidator` unset and the SDK selects by runtime: AJV on Node.js, `@cfworker/json-schema` on workerd and in browsers. Import from the `validators/cf-worker` subpath to pin the lightweight one anywhere. ```ts import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; const edge = new McpServer({ name: 'schema-zoo', version: '1.0.0' }, { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() }); ``` `edge` runs the same two validation paths through `@cfworker/json-schema` instead of AJV, on any runtime. ## Recap - `inputSchema`, `outputSchema`, and a prompt's `argsSchema` accept any Standard Schema that exposes JSON Schema — Zod and ArkType as-is, Valibot through `@valibot/to-json-schema`. - The raw-shape overload (`inputSchema: { name: z.string() }`) is deprecated; pass a schema object. - `fromJsonSchema(document)` registers a JSON Schema you already have; the generic parameter types the handler's arguments. - `jsonSchemaValidator` on the server options swaps the validator for elicitation form responses; `fromJsonSchema` takes its own as a second argument. - The default validator is runtime-selected — AJV on Node.js, `@cfworker/json-schema` on workerd and browsers — and the `validators/ajv` and `validators/cf-worker` subpaths force either one. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/advanced/custom-transports.md ================================================================================ # Custom transports A **transport** moves `JSONRPCMessage` values in both directions over a channel the SDK knows nothing about. Implement the `Transport` interface and `connect()` accepts it like a built-in one. ## Implement the `Transport` interface Three methods — `start`, `send`, `close` — and three callbacks the SDK installs: `onmessage`, `onerror`, `onclose`. This loopback delivers each message straight to a linked peer in the same process. ```ts import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/server'; export class LoopbackTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; private peer?: LoopbackTransport; /** Cross-wire two ends: whatever one end sends, the other receives. */ static link(a: LoopbackTransport, b: LoopbackTransport): void { a.peer = b; b.peer = a; } async start(): Promise { // Open your channel here. The loopback has nothing to open. } async send(message: JSONRPCMessage): Promise { const peer = this.peer; if (!peer) throw new Error('Loopback peer is gone'); queueMicrotask(() => peer.onmessage?.(message)); } async close(): Promise { this.peer = undefined; this.onclose?.(); } } ``` The SDK never looks inside your channel: it calls `send` for every outbound message and expects every inbound one on `onmessage`. Both `@modelcontextprotocol/server` and `@modelcontextprotocol/client` export `Transport`, `TransportSendOptions`, and `JSONRPCMessage`, so one implementation serves either side. ## Honor the callback contract The interface carries three rules that no type checker enforces. - Never call `start()` on a transport you hand to a `Client` or `Server`: `connect()` installs the three callbacks and then calls `start()` itself. A transport that starts reading before the callbacks exist drops messages. - `close()` must end by firing your own `onclose` — the protocol layer tears down its side of the connection from that callback, however the channel ended. - `onerror` reports out-of-band conditions (a malformed frame, a dropped socket) and is not necessarily fatal. For a failure the sender must see, throw from `send` instead. ## Connect it like a built-in transport `Client.connect()` and `McpServer.connect()` take any `Transport`. Link two loopback ends, hand one to each side, and call a tool. ```ts import { Client } from '@modelcontextprotocol/client'; import { McpServer } from '@modelcontextprotocol/server'; const server = new McpServer({ name: 'loopback-demo', version: '1.0.0' }); server.registerTool('ping', { description: 'Reply with pong' }, async () => ({ content: [{ type: 'text', text: 'pong' }] })); const client = new Client({ name: 'loopback-client', version: '1.0.0' }); const serverEnd = new LoopbackTransport(); const clientEnd = new LoopbackTransport(); LoopbackTransport.link(serverEnd, clientEnd); await server.connect(serverEnd); await client.connect(clientEnd); const result = await client.callTool({ name: 'ping' }); console.log(result.content); ``` The whole MCP handshake and the tool call run over the loopback; the handler's `content` comes back unchanged: ``` [ { type: 'text', text: 'pong' } ] ``` ## Frame messages over a byte stream The loopback hands its peer a parsed object. A socket hands you bytes — frame them with the same helpers the stdio transports use: `ReadBuffer` buffers chunks and yields one parsed message per newline-delimited line, `serializeMessage` writes one, and `deserializeMessage` parses a single line you already hold. ```ts import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/server'; import type { Socket } from 'node:net'; export class SocketTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: JSONRPCMessage) => void; private readonly readBuffer = new ReadBuffer(); constructor(private readonly socket: Socket) {} async start(): Promise { this.socket.on('data', chunk => { try { this.readBuffer.append(chunk); let message = this.readBuffer.readMessage(); while (message !== null) { this.onmessage?.(message); message = this.readBuffer.readMessage(); } } catch (error) { this.onerror?.(error as Error); } }); this.socket.on('error', error => this.onerror?.(error)); this.socket.on('close', () => this.onclose?.()); } async send(message: JSONRPCMessage): Promise { this.socket.write(serializeMessage(message)); } async close(): Promise { this.socket.end(); } } ``` `readMessage()` returns `null` until a complete line has arrived and skips non-JSON lines, so partial chunks, coalesced writes, and stray debug output all come out as whole `JSONRPCMessage` values or not at all. ::: tip `ReadBuffer` throws once its buffer exceeds 10 MB (`STDIO_DEFAULT_MAX_BUFFER_SIZE`). Pass `new ReadBuffer({ maxBufferSize })` to raise the cap. ::: ## Report a session ID and the negotiated version Three optional members let the protocol layer talk back to your transport; the SDK uses each one only when it is present. Set `sessionId` when your channel has one, and declare `setProtocolVersion` to receive the protocol version the two sides negotiated during `initialize`. ```ts export class SessionLoopbackTransport extends LoopbackTransport { sessionId?: string; protocolVersion?: string; setProtocolVersion(version: string): void { this.protocolVersion = version; } } ``` Connect a client and a server over a linked pair of these and both ends end up holding the same version; logging the client end's `protocolVersion` after `connect()` prints: ``` 2025-11-25 ``` Which version you see depends on the connection's protocol era — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ::: info `setSupportedProtocolVersions` is the third optional member: `connect()` passes the local side's accepted versions into it, which is how the HTTP server transports know what to allow in the `MCP-Protocol-Version` header. ::: ## Opt into per-request cancellation Declare `hasPerRequestStream` only on a transport that opens one underlying request per outbound JSON-RPC request, and forward `requestSignal` to that request. ```ts readonly hasPerRequestStream = true; async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise { const response = await fetch(this.endpoint, { method: 'POST', headers: { 'content-type': 'application/json', ...options?.headers }, body: JSON.stringify(message), signal: options?.requestSignal }); this.onmessage?.(parseJSONRPCMessage(await response.json())); } ``` On a 2026-07-28 connection the protocol layer cancels an in-flight request by aborting that request's `requestSignal` instead of sending `notifications/cancelled` — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). Single-channel transports — stdio, the loopback above — leave the flag undefined and ignore `requestSignal`; cancellation stays a notification for them. ## Test it against the in-memory pair `InMemoryTransport` is the reference implementation: the smallest `Transport` the SDK ships, and a known-good baseline for the client and server you drive your own transport with. ```ts import { InMemoryTransport } from '@modelcontextprotocol/client'; const [inMemoryClientEnd, inMemoryServerEnd] = InMemoryTransport.createLinkedPair(); ``` Run the same client and server over both pairs: the `ping` call above returns the same `content` over `InMemoryTransport` as over the loopback, and anything that differs is a bug in your transport. [Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) builds its whole harness on this pair. ## Recap - A transport is `start`, `send`, `close` plus `onmessage`, `onerror`, `onclose` — nothing else is required. - `connect()` installs the callbacks, then calls `start()` for you; `close()` must fire `onclose`. - `ReadBuffer`, `serializeMessage`, and `deserializeMessage` give you the newline-delimited framing the stdio transports use. - `sessionId`, `setProtocolVersion`, `setSupportedProtocolVersions`, and `hasPerRequestStream` are optional members the SDK uses only when they are present. - `InMemoryTransport.createLinkedPair()` is the reference implementation and the baseline to test your transport against. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/advanced/wire-schemas.md ================================================================================ # Wire schemas `@modelcontextprotocol/core` exports the **wire schemas** — the exact Zod constants the SDK validates protocol and OAuth payloads against — for code that holds raw JSON instead of SDK objects. ## Validate a wire payload `CallToolResultSchema.safeParse` validates an upstream body before you relay it. ```ts import { CallToolResultSchema } from '@modelcontextprotocol/core'; // The body an upstream server returned for a tools/call you forwarded. const body: unknown = JSON.parse('{"content":[{"type":"text","text":"Travel mug"}]}'); const parsed = CallToolResultSchema.safeParse(body); if (!parsed.success) { throw new Error(`upstream returned an invalid tools/call result: ${parsed.error.message}`); } console.log(parsed.data.content); ``` `parsed.data` is the typed result: ``` [ { type: 'text', text: 'Travel mug' } ] ``` Hand the same schema a malformed body and `safeParse` returns the failure instead of throwing. ```ts const malformed = CallToolResultSchema.safeParse({ content: 'Travel mug' }); console.log(malformed.error?.issues); ``` The error names the field that broke the contract: ``` [ { expected: 'array', code: 'invalid_type', path: [ 'content' ], message: 'Invalid input: expected array, received string' } ] ``` ::: info Coming from v1? These are the `*Schema` constants v1 exported from `@modelcontextprotocol/sdk/types.js`. The codemod rewrites the import path — see the [upgrade guide](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md). ::: ## Decide whether you need this package at all If you build with `McpServer` or `Client`, skip this package: [tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md) arrive in your handler already validated, and [tool calls](https://ts.sdk.modelcontextprotocol.io/v2/clients/calling.md) come back as typed results. Reach for `@modelcontextprotocol/core` when nothing stands between you and the JSON — gateways, proxies, test harnesses, [worker fleets](https://ts.sdk.modelcontextprotocol.io/v2/advanced/gateway.md). Install it separately (`npm install @modelcontextprotocol/core`) — `@modelcontextprotocol/server` and `@modelcontextprotocol/client` keep a Zod-free public surface and never depend on it. The package is runtime-neutral; `zod` is its only dependency. ## Pick the schema for the message you hold Every named type in the spec has a matching constant, `Schema`. When you do not yet know which one you hold, `JSONRPCMessageSchema` validates the undecoded envelope. ```ts import { JSONRPCMessageSchema } from '@modelcontextprotocol/core'; const frame = '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"mug"}}}'; const message = JSONRPCMessageSchema.parse(JSON.parse(frame)); ``` `message` narrows to one of the four JSON-RPC shapes — request, notification, result response, error response — and an invalid frame throws a `ZodError`. The constants come in the same families as the spec: requests (`CallToolRequestSchema`), results (`ListToolsResultSchema`), notifications (`ProgressNotificationSchema`), and `*ParamsSchema` for when you hold only the `params` object (`CallToolRequestParamsSchema`). ## Route raw JSON-RPC in a proxy Parse the envelope once, branch on `method`, then validate with the per-method request schema before forwarding. ```ts import { CallToolRequestSchema } from '@modelcontextprotocol/core'; if ('method' in message) { switch (message.method) { case 'tools/call': { const call = CallToolRequestSchema.parse(message); console.log(`forward tools/call for ${call.params.name} upstream`); break; } default: console.log(`forward ${message.method} unchanged`); } } ``` `call.params.name` is a typed `string`, with no `Client` or `Server` anywhere in the path: ``` forward tools/call for search upstream ``` For everything beyond validation — sessions, capability negotiation, request correlation — build on the SDK instead: see the [low-level server](https://ts.sdk.modelcontextprotocol.io/v2/advanced/low-level-server.md). ## Validate OAuth and discovery metadata The second export group covers OAuth and OpenID discovery. `OAuthMetadataSchema` validates an authorization server's metadata document. ```ts import { OAuthMetadataSchema } from '@modelcontextprotocol/core'; // In production this body comes from GET /.well-known/oauth-authorization-server. const response = new Response( JSON.stringify({ issuer: 'https://auth.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', response_types_supported: ['code'] }) ); const metadata = OAuthMetadataSchema.parse(await response.json()); console.log(metadata.token_endpoint); ``` A document missing a required endpoint fails the parse; a valid one comes back typed: ``` https://auth.example.com/token ``` The group follows the same naming convention: `OAuthTokensSchema` for token responses, `OAuthProtectedResourceMetadataSchema` for protected-resource metadata, `OpenIdProviderDiscoveryMetadataSchema` for OpenID provider discovery. ## Get the TypeScript types, guards and errors from the SDK packages `@modelcontextprotocol/core` exports Zod values and nothing else. The spec types, the `isJSONRPCRequest`-style guards, and the error classes are public API of `@modelcontextprotocol/server` and `@modelcontextprotocol/client` — import them from whichever package you already depend on. ```ts import type { CallToolResult } from '@modelcontextprotocol/client'; import * as z from 'zod/v4'; // The SDK's spec type and the schema's own inferred output describe the same value. const relayed: CallToolResult = parsed.data; type CallToolResultFromCore = z.infer; ``` The assignment typechecks: what a core schema parses is what the SDK packages type. A package that depends only on core derives the same types with `z.infer`. ::: tip To check a value's shape without taking a Zod dependency at all, use the `isSpecType` guards exported from `@modelcontextprotocol/client` and `@modelcontextprotocol/server`: `isSpecType.CallToolResult(value)`. ::: ## Recap - `@modelcontextprotocol/core` exports the SDK's own spec and OAuth/OpenID Zod schemas, and nothing else. - Its audience is code that holds raw JSON — gateways, proxies, test harnesses — not `Client` or `Server` users. - Every spec type has a `Schema` constant; `JSONRPCMessageSchema` validates the undecoded envelope. - Types, guards and error classes are not in core — import them from `@modelcontextprotocol/server` or `@modelcontextprotocol/client`. - The package is runtime-neutral; `zod` is its only dependency. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/advanced/gateway.md ================================================================================ # Gateways and worker fleets A **gateway** — a proxy, a worker pool, any process that fronts one MCP server with many short-lived clients — probes the server once and reuses the answer for every connection after it. ## Connect with a prior discover result `connect()` takes an optional `prior`: a persisted `DiscoverResult` from an earlier probe. With it, `connect()` adopts the server's advertisement directly and sends nothing on the wire. ```ts import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; const url = new URL('http://localhost:3000/mcp'); // Probe once … const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await bootstrap.connect(new StreamableHTTPClientTransport(url)); const persisted = JSON.stringify(bootstrap.getDiscoverResult()); // … then every other client connects with zero round trips. const worker = new Client({ name: 'worker', version: '1.0.0' }); await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) }); ``` `worker` is connected: `callTool` works immediately, and the server has not heard from it yet. `connect({ prior })` is 2026-07-28+ only — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Probe once at bootstrap An `'auto'`-mode (or pinned) connect sends `server/discover` and records the answer; `getDiscoverResult()` reads it back. ```ts console.log(bootstrap.getDiscoverResult()); ``` The recorded value is the server's whole advertisement — supported versions, capabilities, identity, instructions: ``` { ttlMs: 0, cacheScope: 'private', supportedVersions: [ '2026-07-28' ], capabilities: { tools: { listChanged: true } }, serverInfo: { name: 'gateway-target', version: '1.0.0' }, resultType: 'complete' } ``` ::: tip An already-connected client can re-probe at any time: `await client.discover()` sends `server/discover` and updates `getDiscoverResult()`. A default-mode connect never probes, so its `getDiscoverResult()` is `undefined` — [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md#pin-an-era) lists the negotiation modes. ::: ## Persist the advertisement The value is plain JSON. Write the string to Redis, a config map, or a process-local cache; parse it back wherever a client needs it. ```ts import type { DiscoverResult } from '@modelcontextprotocol/client'; const prior = JSON.parse(persisted) as DiscoverResult; ``` Nothing about `prior` is tied to the process that probed: any client that can reach the same URL can adopt it. ## Fan out to workers Build every replica from the same blob; the `request_count` call after them is the proof. ```ts const fleet = await Promise.all( ['worker-a', 'worker-b', 'worker-c'].map(async name => { const replica = new Client({ name, version: '1.0.0' }); await replica.connect(new StreamableHTTPClientTransport(url), { prior }); return replica; }) ); const proof = await worker.callTool({ name: 'request_count' }); console.log(proof.structuredContent); ``` `request_count` is a tool on this page's example server that returns how many MCP requests reached the process. Five clients are connected by now — `bootstrap`, `worker`, three replicas — and the server has answered two requests: ``` { requests: 2 } ``` The bootstrap probe was the first request and the `request_count` call itself the second. The four `connect({ prior })` calls sent nothing. ## Reuse only within one authorization context The advertisement is what the server returned to the credential that probed. ::: warning Never share a persisted `DiscoverResult` across principals — key the blob on the authorization context that obtained it (a credential hash works). The server still authorizes every request, so a wider `prior` grants nothing, but it misleads client-side capability gating. ::: ## Open a listen stream when a worker needs notifications `connect({ prior })` never auto-opens a `subscriptions/listen` stream — prior-connected clients are request-only until you open one yourself. ```ts const subscription = await worker.listen({ toolsListChanged: true }); console.log(subscription.honoredFilter); ``` The server acknowledges the filter it agreed to honor: ``` { toolsListChanged: true } ``` From here the stream behaves like any other subscription — [Subscriptions](https://ts.sdk.modelcontextprotocol.io/v2/clients/subscriptions.md) covers the notification handlers and the close semantics. ::: info A `listChanged` option configured on a prior-connected client registers its handlers but stays silent: no stream opens until you call `listen()`. ::: ## Handle a stale or incompatible advertisement A `prior` that shares no 2026-07-28+ revision with the client rejects with `SdkError(EraNegotiationFailed)` before anything reaches the transport. ```ts import { SdkError, SdkErrorCode } from '@modelcontextprotocol/client'; const stale: DiscoverResult = { ...prior, supportedVersions: ['2025-06-18'] }; const late = new Client({ name: 'worker-d', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); try { await late.connect(new StreamableHTTPClientTransport(url), { prior: stale }); } catch (error) { if (!(error instanceof SdkError) || error.code !== SdkErrorCode.EraNegotiationFailed) throw error; console.log(error.code); // Fall back to a fresh probe, then re-persist getDiscoverResult(). await late.connect(new StreamableHTTPClientTransport(url)); console.log('re-probed:', late.getNegotiatedProtocolVersion()); } ``` The rejection happens before the transport starts, so the same `Client` connects again on the fallback path: ``` ERA_NEGOTIATION_FAILED re-probed: 2026-07-28 ``` Replace the persisted blob with the fresh `getDiscoverResult()` and the rest of the fleet recovers on its next read. ## Recap - `connect(transport, { prior })` adopts a persisted `DiscoverResult` with zero round trips. - The advertisement comes from one `'auto'`-mode or pinned probe — or an explicit `client.discover()` — and `getDiscoverResult()` reads it back. - The value is plain JSON: stringify it into a shared cache, parse it in any process that fronts the same server. - Reuse a `DiscoverResult` only across clients that present the same authorization context. - Prior-connected clients are request-only; call `listen()` on the one that needs notifications. - An incompatible `prior` rejects with `SdkError(EraNegotiationFailed)`; fall back to a fresh probe and re-persist. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/testing.md ================================================================================ # Test a server Drive your server through a real `Client`, in-process — no port, no socket, no mock transport. ## Serve the handler in-process Start from the `createServer` factory you ship — here, one tool — and pass `handler.fetch` as the client transport's `fetch` option. ```ts import assert from 'node:assert/strict'; import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import * as z from 'zod/v4'; function createServer() { const server = new McpServer({ name: 'pricing', version: '1.0.0' }); server.registerTool( 'apply-discount', { description: 'Apply a percentage discount to a price', inputSchema: z.object({ price: z.number(), percent: z.number().min(0).max(100) }), outputSchema: z.object({ total: z.number() }) }, async ({ price, percent }) => { if (price < 0) { return { content: [{ type: 'text', text: 'price must be >= 0' }], isError: true }; } const total = price * (1 - percent / 100); return { content: [{ type: 'text', text: `$${total}` }], structuredContent: { total } }; } ); return server; } const handler = createMcpHandler(createServer); const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { fetch: (url, init) => handler.fetch(new Request(url, init)) }); ``` The transport never dials `http://test.local/mcp` — `handler.fetch` serves every request in-process, through the same `createMcpHandler` you deploy. ## Connect a client and call a tool Create a `Client`, connect it over the transport, and call the tool. `versionNegotiation: { mode: 'auto' }` negotiates the newest protocol revision the handler serves. ```ts const client = new Client({ name: 'test-harness', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(transport); const result = await client.callTool({ name: 'apply-discount', arguments: { price: 80, percent: 25 } }); console.log(result.structuredContent); ``` The handler answered in-process: ``` { total: 60 } ``` ## Assert on the result Assert on `structuredContent` for the happy path; a handler failure resolves as an ordinary result with `isError: true`, not a thrown error. ```ts assert.deepStrictEqual(result.structuredContent, { total: 60 }); const failed = await client.callTool({ name: 'apply-discount', arguments: { price: -5, percent: 25 } }); assert.equal(failed.isError, true); console.log(failed.content); ``` There is nothing to `catch` — `failed.content` carries the message the model would read: ``` [ { type: 'text', text: 'price must be >= 0' } ] ``` ::: tip This page uses `node:assert/strict`; swap in your runner's `expect` — nothing else changes. Arguments the input schema rejects produce the same `isError: true` result, so they assert the same way — see [Tools](https://ts.sdk.modelcontextprotocol.io/v2/servers/tools.md). ::: ## Tear down between tests Close both ends in your runner's `afterEach` — the client first, then the handler. ```ts await client.close(); await handler.close(); ``` `handler.close()` aborts any exchange still in flight, so a hung tool call cannot leak into the next test. ## Pair two instances in memory `InMemoryTransport.createLinkedPair()` returns two transports that are each other's wire — connect one instance to each end. ```ts import { InMemoryTransport } from '@modelcontextprotocol/client'; const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const memServer = createServer(); const memClient = new Client({ name: 'test-harness', version: '1.0.0' }); await memServer.connect(serverTransport); await memClient.connect(clientTransport); ``` `memClient.callTool` returns the same results over this pair. `createLinkedPair` connects 2025-era instances only; `handler.fetch` is the in-process entry for 2026-07-28 coverage — see [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md). ## Cover stdio by spawning the process Stdio has no in-process shortcut: `StdioClientTransport`, imported from `@modelcontextprotocol/client/stdio`, spawns the command and connects to the child over its stdin and stdout. ```ts const stdioClient = new Client({ name: 'test-harness', version: '1.0.0' }); await stdioClient.connect(new StdioClientTransport({ command: 'node', args: ['dist/server.js'] })); ``` From here the client behaves exactly as above, and `stdioClient.close()` shuts the child process down. [Serve over stdio](https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.md) covers the server side. ## Recap - `handler.fetch` passed as the transport's `fetch` option serves every request in-process; the transport never dials the URL. - One `Client` plus one `createMcpHandler` is a complete no-socket integration test of the server you deploy. - Assert on `structuredContent`; a handler failure resolves as a result with `isError: true`. - Close the client, then the handler, between tests. - `InMemoryTransport.createLinkedPair()` pairs 2025-era instances in memory. - stdio coverage means spawning the real process with `StdioClientTransport`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/troubleshooting.md ================================================================================ # Troubleshooting Each heading on this page is the verbatim error message. Match yours, then apply that entry's fix. ## `SyntaxError: Unexpected token ... is not valid JSON` On stdio, standard output is the wire: the host parses every line your server writes to `stdout` as JSON-RPC. One `console.log` — yours or a dependency's — puts a stray line on it, and the host reports that line with this error. Log to `stderr` instead; `serveStdio` owns `stdout`, and `console.error` is safe anywhere in the process. ```ts import { McpServer } from '@modelcontextprotocol/server'; import { serveStdio } from '@modelcontextprotocol/server/stdio'; serveStdio(() => { const server = new McpServer({ name: 'app', version: '1.0.0' }); console.error('app server running on stdio'); // stderr — never console.log return server; }); ``` The host shows the `stderr` line in the server's log and keeps parsing `stdout` cleanly. [Serve over stdio](https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.md) covers the entry point. ::: tip The quoted token is the first character of the stray line, which usually identifies the call that wrote it. ::: ## `TS2589: Type instantiation is excessively deep and possibly infinite` Two copies of `zod` in the dependency tree. The SDK derives its tool, prompt and resource types from Zod v4 schemas; a second `zod` copy makes TypeScript instantiate cross-version types until it hits its recursion limit and fails at an unrelated-looking call site. List every installed copy: ```sh npm ls zod # or: pnpm why zod / yarn why zod ``` Align everything on one Zod 4 version. When a transitive dependency pins another copy, force one with your package manager's override field (`overrides` for npm and pnpm, `resolutions` for Yarn): ```json { "overrides": { "zod": "^4.2.0" } } ``` `npm ls zod` reporting a single version means the duplicate is gone and the error with it. ## `ReferenceError: crypto is not defined` The OAuth client helpers sign and verify through the Web Crypto API at `globalThis.crypto`. Every `@modelcontextprotocol/*` package requires Node.js 20, where that global is always defined — this error means the process is running on an older runtime (Node.js 18 and earlier). Upgrade Node.js. Where you cannot, assign the polyfill from `node:crypto` before anything touches the SDK: ```ts import { webcrypto } from 'node:crypto'; if (typeof globalThis.crypto === 'undefined') { globalThis.crypto = webcrypto; } ``` With the global in place the [client OAuth](https://ts.sdk.modelcontextprotocol.io/v2/clients/oauth.md) flows run unchanged. ## `SdkError: ERA_NEGOTIATION_FAILED` `connect()` found no **protocol era** both sides speak. Two shapes produce it: `versionNegotiation: { mode: { pin: ... } }` names a revision the server does not offer over `server/discover`, and pinning never falls back; or `mode: 'auto'` with a `supportedProtocolVersions` list that has no pre-2026 entry, which removes the legacy fallback. The pinned shape — `transport` here reaches a server still on the 2025 revisions ([Test a server](https://ts.sdk.modelcontextprotocol.io/v2/testing.md) shows the in-memory wiring these outputs come from): ```ts const pinned = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); try { await pinned.connect(transport); } catch (error) { if (!(error instanceof SdkError)) throw error; console.log(`${error.code}: ${error.message}`); } ``` The rejection names the pinned revision the server never offered: ``` ERA_NEGOTIATION_FAILED: Version negotiation failed: the server did not offer pinned protocol version 2026-07-28 via server/discover (no fallback in pin mode) ``` Change the mode to `'auto'`: the probe falls back to the 2025 `initialize` handshake on the same connection. ```ts const negotiated = new Client({ name: 'app', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await negotiated.connect(transport); console.log(negotiated.getProtocolEra()); ``` `connect()` resolves and the client reports the era it landed on: ``` legacy ``` Keep `{ pin }` where a legacy connection is unacceptable and a hard failure is the behavior you want. [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md) defines the eras and what each negotiation mode offers. ## `SdkError: METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` You sent a spec method the negotiated protocol era does not define. The SDK raises this locally — nothing reached the transport — and the message names the method, the negotiated revision, and the era-appropriate replacement. `subscriptions/listen` exists only on a 2026-07-28 connection; this client negotiated a 2025 era, the default: ```ts const client = new Client({ name: 'app', version: '1.0.0' }); await client.connect(transport); try { await client.listen({ resourceSubscriptions: ['file:///logs/app.log'] }); } catch (error) { if (!(error instanceof SdkError)) throw error; console.log(`${error.code}: ${error.message}`); } ``` The message carries the fix: ``` METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION: subscriptions/listen requires a 2026-07-28-era connection (negotiated: 2025-11-25). On a 2025-era connection, change notifications are delivered unsolicited: use ClientOptions.listChanged and resources/subscribe instead. ``` Either negotiate the era that defines the method — `versionNegotiation: { mode: 'auto' }` against a server that serves 2026-07-28, as in the previous entry — or call the surface the negotiated era does define. [Subscriptions](https://ts.sdk.modelcontextprotocol.io/v2/clients/subscriptions.md) covers both delivery models; [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md) lists which methods each era defines. ## `Module '"@modelcontextprotocol/server"' has no exported member 'SSEServerTransport'` `@modelcontextprotocol/server` no longer ships the server-side SSE transport, and the OAuth Authorization Server helpers (`mcpAuthRouter`, `ProxyOAuthServerProvider`) left with it. Both live on as a frozen v1 copy in `@modelcontextprotocol/server-legacy`. Rewrite the imports: ```diff - import { SSEServerTransport } from '@modelcontextprotocol/server'; + import { SSEServerTransport } from '@modelcontextprotocol/server-legacy/sse'; - import { mcpAuthRouter, ProxyOAuthServerProvider } from '@modelcontextprotocol/server'; + import { mcpAuthRouter, ProxyOAuthServerProvider } from '@modelcontextprotocol/server-legacy/auth'; ``` The Resource Server helpers did not move there: `requireBearerAuth`, `mcpAuthMetadataRouter` and `OAuthTokenVerifier` are first-class in `@modelcontextprotocol/express` — see [Authorization](https://ts.sdk.modelcontextprotocol.io/v2/serving/authorization.md). `@modelcontextprotocol/server-legacy` is frozen and receives no new features; serve new code over [Streamable HTTP](https://ts.sdk.modelcontextprotocol.io/v2/serving/http.md), which still reaches 2025-era clients through [legacy client support](https://ts.sdk.modelcontextprotocol.io/v2/serving/legacy-clients.md). A client limited to the HTTP+SSE transport is the one case that still needs the frozen `@modelcontextprotocol/server-legacy/sse` import above. ## Recap - Every heading on this page is the exact message you searched for. - On stdio, `stdout` carries JSON-RPC; log with `console.error`. - `TS2589` means two `zod` copies in the dependency tree. - The SDK raises `ERA_NEGOTIATION_FAILED` and `METHOD_NOT_SUPPORTED_BY_PROTOCOL_VERSION` locally — neither is a wire error. - Server SSE and the Authorization Server helpers live in `@modelcontextprotocol/server-legacy`. ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/migration/index.md ================================================================================ # MCP TypeScript SDK — Migration Guides Pick the guide for your starting point. ## Upgrading from v1.x (`@modelcontextprotocol/sdk`) → **[upgrade-to-v2.md](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md)** You are on the monolithic `@modelcontextprotocol/sdk` package and want to move to the v2 packages (`@modelcontextprotocol/client`, `@modelcontextprotocol/server`, …). Start by running the codemod: ```bash npx @modelcontextprotocol/codemod@beta v1-to-v2 . ``` Run it at the package root (`.`) — real projects import the SDK from `test/`, `scripts/`, and fixtures too, and those rewrites are missed when you point it at `./src`. The codemod handles most mechanical renames. The guide covers what it can't. The codemod handles the v1→v2 SDK surface upgrade only — adopting the 2026-07-28 protocol revision (`createMcpHandler`, multi-round-trip requests, `versionNegotiation`) is architectural and not codemod-automatable; see [support-2026-07-28.md](https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md). ## Already on v2, adopting protocol revision 2026-07-28 → **[support-2026-07-28.md](https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md)** You are already on the v2 packages and want your server or client to speak the 2026-07-28 protocol revision (per-request `_meta` envelope, `createMcpHandler`, `serveStdio`, `versionNegotiation`, multi-round-trip requests, per-era wire codecs). This guide also covers code written against an earlier **v2 alpha** that read wire-only members (`resultType`, envelope keys) directly. ## Using an LLM agent to migrate [upgrade-to-v2.md](https://ts.sdk.modelcontextprotocol.io/v2/migration/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. ## See also - [`@modelcontextprotocol/codemod` README](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/packages/codemod/README.md) - [Troubleshooting](https://ts.sdk.modelcontextprotocol.io/v2/troubleshooting.md) - [Examples](https://github.com/modelcontextprotocol/typescript-sdk/tree/main/examples) ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md ================================================================================ # 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@beta 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 - [What the codemod handles](#what-the-codemod-handles) - [What the codemod does NOT handle](#what-the-codemod-does-not-handle) - [Manual changes](#manual-changes-what-the-codemod-does-not-handle) - [Packaging & runtime](#packaging--runtime) - [Imports & transports](#imports--transports) - [Low-level protocol & handler context (`ctx`)](#low-level-protocol--handler-context-ctx) - [Server registration API](#server-registration-api) - [HTTP & headers](#http--headers) - [Errors](#errors) - [Auth](#auth) - [Types & schemas](#types--schemas) - [Behavioral changes](#behavioral-changes) - [Enhancements](#enhancements) - [Unchanged APIs](#unchanged-apis) - [Need help?](#need-help) --- ## What the codemod handles The codemod ([`@modelcontextprotocol/codemod`](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. - Drops `Protocol` / `mergeCapabilities` from `shared/protocol.js` imports, re-exports, mocks, and dynamic imports — no v2 package exports them — leaving a marker with the replacement at each site. ## What the codemod does NOT handle Each of these maps to a manual section below. The codemod marks every site it recognized but could not safely rewrite with an `@mcp-codemod-error` comment. - **Node 20 / ESM** — pre-flight, not a code rewrite. → [Packaging & runtime](#packaging--runtime) - **Header-read `.get()` rewrite** — `IsomorphicHeaders` is renamed to `Headers` and `extra.requestInfo?.headers[…]` is remapped to `ctx.http?.req?.headers[…]`, but converting that bracket access to `.get()` is manual. (Headers you _pass in_ via `requestInit.headers` need no rewrite — plain objects remain valid.) → [HTTP & headers](#http--headers) - **`ctx.mcpReq.send()` schema-arg drop** — the codemod drops the schema arg from `client.request()` / `client.callTool()` but leaves nested `ctx.mcpReq.send()` calls alone. → [Low-level protocol](#low-level-protocol--handler-context-ctx) - **OAuth error-class consolidation** — `instanceof InvalidGrantError` → `OAuthError` + `OAuthErrorCode` is a judgment rewrite. → [Auth](#auth) - **`SdkErrorCode` branch selection** — the codemod renames `StreamableHTTPError` → `SdkHttpError`; deciding which `SdkErrorCode` branch a given catch should match is judgment. → [Errors](#errors) - **Namespace schema access** — `import * as t from '…/types.js'` + `t.CallToolResultSchema.parse(…)` can't be split per-symbol; the codemod flags it action-required — re-import the schema from `@modelcontextprotocol/core` by hand. → [Types & schemas](#types--schemas) - **Import-less (injected) SDK surfaces** — the codemod is import-driven: a file that receives the SDK surface as a parameter (dependency injection, factory seams) and has no SDK import is never rewritten, and the v1 idioms there fail at **runtime**, not compile time — e.g. the v1 schema-first `setRequestHandler(Schema, …)` form throws a `TypeError` at registration. Grep such seams for v1 API tokens beyond import statements (`setRequestHandler(`, `ErrorCode.`, `extra.`) and apply the [handler-registration](#setrequesthandler--setnotificationhandler-use-method-strings) and [Errors](#errors) sections by hand. → [Low-level protocol](#low-level-protocol--handler-context-ctx) - **Behavioral adaptation** — list auto-aggregation, capability empties, lazy validator compilation, output-schema validation rules. → [Behavioral changes](#behavioral-changes) --- ## 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) but does **not** require the `hono` framework — your package manager may emit a harmless unmet-peer warning for `hono` (upstream `@hono/node-server` declares it). v2 requires **Node.js 20+**. 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 during the beta All v2 packages are published on the public npm registry. Two notes for the beta window: - 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](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). - **Capability fields**: the `roots`, `sampling`, and `logging` capability schema fields. - **Type stacks**: the full Logging stack (`LoggingLevel`, `SetLevelRequest`, `LoggingMessageNotification` and params), the full Sampling stack (`CreateMessageRequest`/`Result`, `SamplingMessage`, `ModelPreferences`/`ModelHint`, `ToolChoice`, `ToolUseContent`/`ToolResultContent`, the `includeContext` enum values), and the full Roots stack (`Root`, `ListRootsRequest`/`Result`, `RootsListChangedNotification`). - **`registerClient`** (Dynamic Client Registration) — prefer Client ID Metadata Documents per SEP-991. The deprecation is annotation-only — JSDoc `@deprecated` markers were added, nothing else: every deprecated runtime API keeps its v1 call signature (e.g. `Server.sendLoggingMessage(params, sessionId?)` keeps the two-argument form) and its wire behavior, and remains functional for at least the twelve-month deprecation window. #### `setRequestHandler` / `setNotificationHandler` use method strings The low-level handler registration takes a **method string** instead of a Zod schema. 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 swallows the conversion failure, 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. Strip the `$schema` member from the converted output before passing it to `fromJsonSchema()` — `zod-to-json-schema` stamps a draft-07 `$schema` by default, and the default validator [accepts 2020-12 only](#json-schema-2020-12-posture-sep-1613-sep-2106). How a too-old zod surfaces depends on which entry point your code imports. With main-entry `import { z } from 'zod'` on a zod-3 range, the project **typechecks cleanly and fails at the first `tools/list`** (the quiet runtime path above). With `import * as z from 'zod/v4'` — or any zod whose _typings_ predate `~standard.jsonSchema` (zod 4.0–4.1, and zod 3.25.x via the `zod/v4` subpath) — the same code **runs** through the bundled fallback but **fails to compile**: `registerTool`/`registerPrompt` reject the schema with `TS2769: No overload matches this call` listing both overloads. The real cause is buried in the first overload's elaboration — `Property 'jsonSchema' is missing in type …` (that property is `~standard.jsonSchema`, added in zod 4.2.0) — and a follow-on implicit-`any` error on the handler's arguments usually appears below it. If you see that two-overload error on a registration call with a zod schema, check the installed zod version before anything else; both symptoms resolve identically with step (1) of the ladder. Projects that must stay below zod 4.2 and accept the documented runtime fallback can resolve the remaining registration compile errors with an explicit assertion to the registration schema type — `inputSchema: schema as unknown as StandardSchemaWithJSON` — or a small typed wrapper that attaches a `~standard.jsonSchema` provider (step (2) of the ladder, which changes runtime conversion but not the schema's static type) and returns the asserted type. The fallback caveats (one-time warning, dropped `.describe()` descriptions) still apply unless the provider is attached. The forced zod-4 bump also surfaces zod's **own** type-level API changes in consumer annotations: `z.ZodTypeDef` no longer exists and `z.ZodType`'s generic parameters changed, so v3-era annotations like `z.ZodType` fail to compile — see [zod's v3-to-v4 changelog](https://zod.dev/v4/changelog). Consumer-only schemas can keep compiling via zod's v3 compat subpath (`zod/v3`), but anything passed to the SDK must be a zod-4 (or other Standard Schema) schema. The deprecated raw-shape overloads exist only on `registerTool` / `registerPrompt`. `RegisteredTool.update()` / `RegisteredPrompt.update()` take **schema objects** (`paramsSchema` / `outputSchema`: `StandardSchemaWithJSON`) — a raw shape passed to `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. `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. #### 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`) | #### Connect-time OAuth retry (`UnauthorizedError`) `UnauthorizedError` survives in v2 (exported from `@modelcontextprotocol/client` — its only appearance in the error table above is the removed `SSEClientTransport.send()` 401 path), and the v1 connect-time pattern carries over: catch it from `connect()`, complete the browser flow, call `transport.finishAuth(…)`, reconnect. ```typescript try { await client.connect(transport); } catch (error) { if (!(error instanceof UnauthorizedError)) throw error; // provider.redirectToAuthorization() has been called; complete the flow, // then reconnect on a FRESH transport (a started transport cannot be restarted). await transport.finishAuth(new URL(callbackUrl).searchParams); await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider })); } ``` One qualification: this direct `instanceof` check applies under the default `'legacy'` version negotiation. Under the probing modes (`versionNegotiation: { mode: 'auto' }`, with or without a pin) the connect-time 401 currently surfaces wrapped as `SdkError(SdkErrorCode.EraNegotiationFailed)` with the `UnauthorizedError` at `error.data.cause` — unwrap before the check, as shown in the [client 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](#deprecated-in-v2-sep-2577)) wanting the same defaults pass `resolveClientMetadata(provider)` as `clientMetadata`. DCR rejection now throws `RegistrationRejectedError` (carrying `status`, `body`, `submittedMetadata`). #### Token endpoint must use TLS (SEP-2207) `exchangeAuthorization()`, `refreshAuthorization()`, `fetchToken()`, and the Cross-App Access helpers throw `InsecureTokenEndpointError` when the token endpoint is not `https:` (loopback `localhost` / `127.0.0.1` / `::1` exempt). `auth()` surfaces this on every path including refresh — switch any plain-`http:` AS on a non-loopback host to TLS; there is no opt-out. Storage confidentiality of `refresh_token` remains your `saveTokens()` implementation's responsibility. #### Scope step-up on `403 insufficient_scope` (SEP-2350) `StreamableHTTPClientTransport` accepts `onInsufficientScope: 'reauthorize' | 'throw'` (default `'reauthorize'`). On `'reauthorize'` the transport re-authorizes with the **union** of the previously-requested and challenged scope (`computeScopeUnion`); when that union strictly exceeds the current token's granted scope (`isStrictScopeSuperset`), the SDK bypasses the refresh-token branch and forces a fresh authorization request. On `'throw'` the transport raises `InsufficientScopeError` and does not re-authorize — set this for `client_credentials` / m2m clients where re-authorization can't widen scope, or to gate the consent prompt behind UX. Step-up retries are hard-capped per send (`maxStepUpRetries`, default 1). With a non-OAuth [`AuthProvider`](#authprovider--non-oauth-bearer-auth-and-the-widened-authprovider-option), a `403 insufficient_scope` now throws `InsufficientScopeError` instead of the previous `SdkHttpError(ClientHttpNotImplemented)`. The GET listen-stream open path applies the same handling as the POST send path. #### Credentials bound to the issuing authorization server (SEP-2352) `auth()` stamps an `issuer` field onto every value it passes to `saveTokens()` / `saveClientInformation()` and threads `{ issuer }` as the `ctx` argument to those methods plus `tokens()` / `clientInformation()`. On read, a stored value whose `issuer` names a different AS is treated as `undefined` and the flow re-registers / re-authorizes. **Round-trip the stored object verbatim and you're protected** — single-slot storage works. 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`). New TypeScript-only aliases `StoredOAuthTokens` / `StoredOAuthClientInformation` add an optional `issuer?: string` field on top of the wire types. `OAuthClientProvider.saveAuthorizationServerUrl()` / `authorizationServerUrl()` are `@deprecated` (still written for back-compat, never read by the SDK). The bundled `ClientCredentialsProvider`, `PrivateKeyJwtProvider`, `StaticPrivateKeyJwtProvider`, and `CrossAppAccessProvider` gain `expectedIssuer?: string` and no longer define `saveClientInformation()`. Implement `discoveryState()` / `saveDiscoveryState()` so the callback leg can verify it is exchanging the code at the same AS the redirect targeted; without it the SDK `console.warn`s once per callback (`discoveryState` must persist with the same durability as `codeVerifier`). 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 is **not** required by `client` / `server` — install it only if you import the raw schemas directly. If you would rather keep your project Zod-free, the **`isSpecType` / `specTypeSchemas`** alternatives are exported from `@modelcontextprotocol/client` and `…/server`: ```typescript import { isSpecType, specTypeSchemas } from '@modelcontextprotocol/client'; if (isSpecType.CallToolResult(value)) { ... } const blocks = mixed.filter(isSpecType.ContentBlock); const result = specTypeSchemas.CallToolResult['~standard'].validate(value); ``` `isSpecType` and `specTypeSchemas` are keyed by `SpecTypeName` — a literal union of every named type in the MCP spec — so you get autocomplete and a compile error on typos. `specTypeSchemas.X` is a `StandardSchemaV1Sync` (`validate()` is synchronous). `validate()` returns `{ value }` or `{ issues }` and never throws — unlike `.parse()` on the real schema; code that caught a `ZodError` should inspect `result.issues` (or keep `.parse()` on the schema imported from `@modelcontextprotocol/core`). The pre-existing `isCallToolResult(value)` guard still works. **`specTypeSchemas.X` is `StandardSchemaV1`, not `ZodType`.** Zod-specific composition — `.extend()`, `.pick()`, `.omit()`, `.merge()`, `.shape`, `.passthrough()`, `.parseAsync()` — does **not** compile on a `specTypeSchemas` entry; reach for the real Zod schema from `@modelcontextprotocol/core` when you need to derive a tolerant variant of a spec schema (e.g. `ListToolsResultSchema.extend({ tools: ToolSchema.omit({ outputSchema: true }).array() })`). The Zod-specific `AnySchema` / `SchemaOutput` types from `…/zod-compat.js` are removed — replace with `StandardSchemaV1` / `StandardSchemaV1.InferOutput` (the codemod's removal message says the same). **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 itself is no longer exported (it is internal engine). If you were reaching into protocol internals — rare, mostly debugging tools — `client.fallbackRequestHandler` / `server.fallbackRequestHandler` receives every inbound request that no registered handler matches, before capability gating. Delete the v1 `shared/protocol.js` import: `Protocol` has no v2 import path. The codemod drops `Protocol` (and `mergeCapabilities`) from the rewritten import and leaves an `@mcp-codemod-error` marker at the site explaining the replacement. #### JSON Schema 2020-12 posture (SEP-1613, SEP-2106) The default validator supports **JSON Schema 2020-12 only**. On Node it is now `Ajv2020` instead of draft-07 `Ajv`; the Cloudflare Workers default was already 2020-12. Schemas declaring a different `$schema` are rejected with `Error("…unsupported dialect…")`. `CallToolResult.structuredContent` is widened from `{ [k: string]: unknown }` to `unknown` (SEP-2106 lifts the `type:"object"` root restriction). The presence check is `!== undefined`, not falsy (`null` / `0` / `false` / `""` are legal values now). External `$ref` is not dereferenced (unchanged from v1; Ajv throws `MissingRefError` at compile, surfaced per-tool on `callTool`). | v1 pattern | Mechanical fix | | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `result.structuredContent.` / `result.structuredContent?.` | narrow first: `const sc = result.structuredContent; if (typeof sc === 'object' && sc !== null && '' in sc) { sc. }` | | `if (!result.structuredContent)` | `if (result.structuredContent === undefined)` | | relying on default `Ajv` being draft-07 | `new AjvJsonSchemaValidator(new Ajv({ strict: false, validateFormats: true, validateSchema: false, allErrors: true }))` (import `Ajv`, `addFormats`, `AjvJsonSchemaValidator` from `…/validators/ajv`) | | draft-07 idioms via `fromJsonSchema(schema)` | `fromJsonSchema(schema, new AjvJsonSchemaValidator(ajv))` — the `McpServer`/`Client` `jsonSchemaValidator` option does **not** reach `fromJsonSchema`-authored schemas | | `outputSchema` / `inputSchema` with absolute-URI `$ref` | inline under `$defs` and reference with `#/$defs/Name` | A tool may now register an `outputSchema` whose root is `type:"array"`, `type:"string"`, etc.; toward 2025-era clients the codec wraps it in a `{result:…}` envelope, and toward every era a non-object `structuredContent` with no `text` block of its own gets a `JSON.stringify(...)` `text` block auto-appended. See [support-2026-07-28.md › Per-era wire codecs](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. ### 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)). - **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 `nextCursor: undefined`. Passing `{ cursor }` still fetches one page. Manual pagination loops keep working (the first iteration returns everything); replace them with the bare no-arg call. The walk is capped at `ClientOptions.listMaxPages` (default 64); overrun throws `SdkError(ListPaginationExceeded)`. There is no way to fetch only the **first** page through the typed verbs — for page-level observation (pagination tooling, per-page stats) drop to `client.request({ method: 'tools/list', params })`, which never aggregates. - Output-schema validator compilation is now **lazy** — validators compile on the first `callTool()` against the cached `tools/list` entry, not eagerly inside `listTools()`. In v1, `listTools()` threw on an uncompilable `outputSchema`; now `listTools()` succeeds and the compile failure surfaces when `callTool()` is invoked on the affected tool, as `ProtocolError(InvalidParams, "Tool 'X' has an invalid outputSchema: …")`, before the request is sent. Validation is never silently skipped. - On a 2026-07-28 connection the cacheable verbs honour the server-stamped `ttlMs` / `cacheScope` (SEP-2549) and may return a still-fresh cached entry without a round trip. Per-call override: `{ cacheMode: 'refresh' | 'bypass' }`. New `ClientOptions`: `cachePartition`, `defaultCacheTtlMs`. `ResponseCacheStore` gained `delete(key)`; `InMemoryResponseCacheStore` is now bounded (`{ maxEntries }`, default 512). #### Server (Streamable HTTP transport) - Resumability behavior (SSE priming events, `closeSSE` / `closeStandaloneSSE` callbacks) is only enabled for protocol versions in the transport's supported-versions list that are `>= 2025-11-25`. Unknown future version strings in an `initialize` request body no longer enable it. - Session-ID mismatch still responds `404` with JSON-RPC `-32001` (`Session not found`), unchanged from v1. This `-32001` is an SDK convention, not spec-assigned; client code should key off the HTTP `404` status, not `-32001`. #### Server (deprecated accessors and app-factory Origin validation) - `Server.getClientCapabilities()`, `getClientVersion()`, `getNegotiatedProtocolVersion()` are `@deprecated` but functional. On 2026-07-28 requests, prefer `ctx.mcpReq.envelope`. - `createMcpExpressApp()` / `createMcpHonoApp()` / `createMcpFastifyApp()` with a localhost-class `host` now also validate the `Origin` header by default. Browser-served clients on a non-localhost origin need `allowedOrigins: [...]` (replaces the default localhost allowlist; validation cannot be disabled for localhost binds). Requests without an `Origin` header are unaffected; a present `Origin` that cannot be parsed — including the opaque **`Origin: null`** sent by sandboxed iframes, `file://` pages, and cross-origin redirects — is **rejected with 403** and cannot be allowlisted via `allowedOrigins`. Framework-agnostic helpers (`validateOriginHeader`, `localhostAllowedOrigins`, `originValidationResponse`) are in `@modelcontextprotocol/server`; `@modelcontextprotocol/node` ships `hostHeaderValidation` / `originValidation` request guards for plain `node:http`. #### Server (McpServer / Streamable HTTP behavior) - **Eager capability-handler install.** `McpServer` now installs list/read/call handlers for every primitive capability declared in `ServerOptions.capabilities`, even with zero registrations. `new McpServer(info, { capabilities: { tools: {} } })` with no registered tools answers `tools/list` with `{ tools: [] }` instead of `-32601 Method not found`. Low-level `Server` users remain responsible for registering handlers for declared capabilities — 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. --- ## Enhancements ### Automatic JSON Schema validator selection by runtime The SDK auto-selects the validator: Node.js → AJV; Cloudflare Workers (workerd) → `@cfworker/json-schema`. Cloudflare Workers users can remove explicit `jsonSchemaValidator` configuration. You don't need to install `ajv`, `ajv-formats`, or `@cfworker/json-schema` for the default path. To customize the built-in backend, import the named class from the explicit subpath (`@modelcontextprotocol/{client,server}/validators/ajv` or `…/cf-worker`) — importing from a subpath means the corresponding peer dep must be in your `package.json`. ### `Client.connect(transport, { prior })` — zero-round-trip connect Probe once, persist `client.getDiscoverResult()` (`JSON.stringify`), and feed it to every worker as `client.connect(transport, { prior })` — 2026-07-28+ only. New exported type `ConnectOptions` (extends `RequestOptions` with `prior?: DiscoverResult`). ### Serving the 2026-07-28 revision `createMcpHandler`, `serveStdio`, `versionNegotiation`, multi-round-trip requests (`requestState`), client cancellation via stream-close, `subscriptions/listen`, `Mcp-Param-*` headers, and per-era wire codecs are covered in **[support-2026-07-28.md](https://ts.sdk.modelcontextprotocol.io/v2/migration/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](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)). - 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](#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](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)). --- ## 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). ================================================================================ Source: https://ts.sdk.modelcontextprotocol.io/v2/migration/support-2026-07-28.md ================================================================================ # Supporting protocol revision 2026-07-28 This guide is for code **already on the v2 packages** that wants to speak the 2026-07-28 protocol revision — and for code written against an earlier **v2 alpha** that read wire-only members directly. If you are on `@modelcontextprotocol/sdk` (v1.x), start with [upgrade-to-v2.md](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md) instead. > **Schema artifact:** until the revision is finalized, the spec repository publishes > the 2026-07-28 schema under `schema/draft/` — there is no `schema/2026-07-28/` > directory yet. Tooling that vendors per-revision schema artifacts should track > `draft/` and note the divergence. Nothing in v2 puts a 2026-07-28 byte on the wire by default: a hand-constructed `Client` / `Server` / `McpServer` keeps speaking the 2025-era protocol it was written for. Serving or speaking 2026-07-28 is always an explicit opt-in via one of the entries below. ## Contents - [Serving the 2026-07-28 revision](#serving-the-2026-07-28-revision) - [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) - [Auth on 2026-07-28](#auth-on-2026-07-28) - [Per-era wire codecs](#per-era-wire-codecs) - [Wire-only members hidden from public types](#wire-only-members-hidden-from-public-types) - [Multi-round-trip requests](#multi-round-trip-requests) - [Legacy shim for `input_required`](#legacy-shim-for-input_required) - [`subscriptions/listen`](#subscriptionslisten) - [`Mcp-Param-*` and standard headers (SEP-2243)](#mcp-param--and-standard-headers-sep-2243) - [Cache fields and cache hints](#cache-fields-and-cache-hints) - [Tasks: deprecated wire vocabulary](#tasks-deprecated-wire-vocabulary) - [Appendix: 2025-era vs 2026-era behavior matrix](#appendix-2025-era-vs-2026-era-behavior-matrix) --- ## Serving the 2026-07-28 revision These entry points are documented in full in [Protocol versions](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md); this section contextualizes them as the migration path. ### Client side: `versionNegotiation` By default `Client.connect()` performs the same 2025 `initialize` handshake as v1.x, byte for byte. To negotiate the 2026-07-28 era, opt in via `ClientOptions.versionNegotiation` — see [Negotiate the era from the client](https://ts.sdk.modelcontextprotocol.io/v2/protocol-versions.md#negotiate-the-era-from-the-client). ```typescript const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); await client.connect(transport); client.getProtocolEra(); // 'modern' | 'legacy' ``` - **absent / `mode: 'legacy'`** (default) — today's behavior, no probe. - **`mode: 'auto'`** — probe with `server/discover`; fall back to the 2025 handshake on the same connection against a 2025-only server (one extra round trip). - **`mode: { pin: '2026-07-28' }`** — modern only; no fallback, `connect()` rejects with `SdkError(EraNegotiationFailed)` against a 2025-only server. `ProtocolOptions.supportedProtocolVersions` — the same option that pins what the legacy `initialize` handshake offers (see [upgrade-to-v2.md › Client connection & dispatch](https://ts.sdk.modelcontextprotocol.io/v2/migration/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 revision but is not checked against the list. #### Probe policy Failure semantics under `'auto'` are deliberately conservative but never silent about infrastructure problems. Anything the probe does not positively recognize as modern falls back to the legacy era — provided the supported-versions list still contains a 2025-era revision; with a modern-only list `connect()` rejects with `SdkError(EraNegotiationFailed)` instead. A network outage rejects with a typed connect error. Probe timeouts are **transport-aware**: on **stdio** a server that does not answer within `timeoutMs` is treated as legacy and the client falls back to `initialize` on the same stream (some legacy servers never respond to unknown pre-`initialize` requests at all); on **HTTP** a probe timeout rejects with `SdkError(RequestTimeout)` — a dead HTTP server is never misreported as legacy. One browser-specific exception: an opaque CORS/preflight `TypeError` during the probe falls back to the legacy era, because deployed 2025 servers commonly have CORS allow-lists that predate the 2026 headers. ```typescript versionNegotiation: { mode: 'auto', probe: { timeoutMs: 10_000, // default: the standard request timeout maxRetries: 0 // default: no retries — governs timeout re-sends only } } ``` `maxRetries` governs timeout re-sends only (the spec-mandated `-32022` corrective continuation — select-and-continue with a mutual version — is a separate negotiation step and is never counted against it). **Who should not default to `'auto'`:** spawn-per-invocation CLI and debugging tools. On stdio, a legacy server that never answers unknown pre-`initialize` requests stalls `connect()` for the full probe timeout before falling back; and the probe round trip changes recorded transcripts/raw logs, which matters for tools whose value is byte-stable observation. Such tools should keep the default and expose `'auto'` / a pin as an explicit flag. The probe request itself already carries the per-request `_meta` envelope (`io.modelcontextprotocol/protocolVersion`, `clientInfo`, `clientCapabilities`) — **before** the era is known. Once a modern era is negotiated the client auto-attaches the envelope to every outgoing request and notification. Tooling that classifies traffic must not treat "saw an envelope" as "modern era negotiated": the legacy-fallback path also begins with one enveloped probe. A gateway/worker fleet can skip the probe entirely with `client.connect(transport, { prior: persistedDiscoverResult })`. ### Server over HTTP: `createMcpHandler` `createMcpHandler(factory)` from `@modelcontextprotocol/server` is the v2 HTTP entry that serves 2026-07-28 per request — and, by default (`legacy: 'stateless'`), also serves 2025-era traffic per request through the established stateless idiom. One factory, one endpoint, both eras. ```typescript import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; const handler = createMcpHandler(() => { const server = new McpServer({ name: 'my-server', version: '1.0.0' }, { capabilities: { tools: {} } }); // register tools/resources/prompts once — the same factory backs both eras return server; }); // Web-standard runtimes: export default handler; // Node frameworks: app.all('/mcp', toNodeHandler(handler)) from @modelcontextprotocol/node ``` A v1 stateless `StreamableHTTPServerTransport` hosting (`sessionIdGenerator: undefined`, fresh transport per request) maps directly onto the default entry. An existing **sessionful** v1 Streamable HTTP setup keeps serving 2025 clients by routing it in front of a strict (`legacy: 'reject'`) entry with `isLegacyRequest(request)`: ```typescript const modern = createMcpHandler(factory, { legacy: 'reject' }); export default { async fetch(request: Request) { if (await isLegacyRequest(request)) return myExistingLegacyHandler(request); return modern.fetch(request); } }; ``` `isLegacyRequest` returns `true` only for requests with no per-request `_meta` envelope claim; route `false` traffic to the modern handler (a malformed modern claim is `false` and answered `-32602` / `-32020` by the modern path). The handler is web-standards-only (`{ fetch, close, notify, bus }`); on Node frameworks wrap once with `toNodeHandler(handler, { onerror? })` from `@modelcontextprotocol/node`. The exported `legacyStatelessFallback(factory)` is the same stateless 2025 serving as a standalone fetch-shaped handler. > **If you were on a v2 alpha:** `handler.node(req, res, body)` is gone — replace with > `toNodeHandler(handler)` and add the `@modelcontextprotocol/node` import. > `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from > `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`. > > Also: a `MissingRequiredClientCapabilityError` (`-32021`) produced **after** dispatch > — the `input_required` gate refusing an embedded request whose capability the caller > did not declare — now answers HTTP **400** (earlier alphas surfaced it in-band on > 200). The spec mandates 400 for this error wherever it arises; the JSON-RPC body is > unchanged. This applies to a handler-thrown `-32021` too: a proxy relaying a > downstream server's `-32021` should translate it (its `requiredCapabilities` > describes the downstream hop's envelope) rather than rethrow the bare error. Every > other handler-produced code (including a relayed `-32020`/`-32022`) > keeps the in-band 200, and an exchange whose response stream is already open — the > handler streamed first, or `responseMode: 'sse'` — keeps its committed 200 and > carries the error in-stream. ### Server over stdio / long-lived connections: `serveStdio` A hand-constructed `Server`/`McpServer` connected directly to a `StdioServerTransport` serves only the 2025-era protocol — upgrading the SDK changes nothing about what it puts on the wire. Serving 2026-07-28 (or both eras) on stdio goes through the connection-pinned `serveStdio(() => buildServer())` entry from `@modelcontextprotocol/server/stdio`; the opening exchange selects the connection's era, and one factory instance is pinned per connection. See [Serve over stdio](https://ts.sdk.modelcontextprotocol.io/v2/serving/stdio.md). To migrate an existing stdio server, replace `await server.connect(new StdioServerTransport())` with `serveStdio(() => buildServer())`. Pass `{ legacy: 'reject' }` to refuse 2025-era openings. On 2026-pinned connections, `getClientCapabilities()` / `getClientVersion()` return `undefined` (no `initialize` ever runs there) and handlers read per-request identity from `ctx.mcpReq.envelope`; `getNegotiatedProtocolVersion()` reports the pinned revision. A client whose connection negotiated a modern era drops inbound server→client JSON-RPC requests (the 2026 era has no such channel) instead of answering them; legacy-era connections are unchanged. ### In-process testing There is no in-memory serving entry — `InMemoryTransport.createLinkedPair()` connects 2025-era instances only. To exercise 2026-07-28 behavior in tests without sockets, drive `createMcpHandler` directly through its fetch function: ```typescript const handler = createMcpHandler(buildServer); const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), { fetch: (url, init) => handler.fetch(new Request(url, init)) }); ``` The URL is never dialed — `handler.fetch` serves the request in-process. For stdio-era coverage, spawn `serveStdio` as a child process. ### Client cancellation on Streamable HTTP On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request (`signal` / timeout) closes that request's SSE response stream — the spec cancellation signal — instead of POSTing `notifications/cancelled`. Nothing to change in calling code. 2025-era connections and stdio at any era still send `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound message and honor `TransportSendOptions.requestSignal` may opt in by declaring `readonly hasPerRequestStream = true`. ### `ctx.mcpReq.log()` and the per-request `logLevel` On a 2026-07-28 request, `ctx.mcpReq.log()` reads its level filter from the `io.modelcontextprotocol/logLevel` `_meta` envelope key (the modern replacement for the `logging/setLevel` RPC). When the key is **absent** the server emits no `notifications/message` for that request — absence is opt-out, not "no filter". The SDK `Client` does not auto-attach `logLevel`, so handler logs on a default 2026-era exchange are silently suppressed until the client opts in. --- ## Replacing per-session state: `requestState` The 2026-07-28 revision is **per request** — `createMcpHandler` builds a fresh server per request and there is no `Mcp-Session-Id`. If your v1 server kept state keyed on the session id (`ctx.sessionId` / `extra.sessionId`), the 2026 answer is `requestState`: an opaque string the server returns with `inputRequired(...)` and the client echoes byte-for-byte on the retry. Read it back with the typed accessor `ctx.mcpReq.requestState()` — it returns the payload your configured verify hook decoded (see below), the raw wire string when no hook is configured, or `undefined` when the round carried no state. `requestState` round-trips through the client and is therefore **untrusted input** — integrity-protect it (HMAC / AEAD over the payload, bound to principal, originating method/parameters, and an expiry) and reject failed verification on re-entry. Configure `ServerOptions.requestState.verify` and the seam runs it before the handler whenever `requestState` is present (a thrown rejection answers `-32602` above the tool funnel). The `createRequestStateCodec({ key, ttlSeconds?, bind? })` helper returns `{ mint, verify }` — `mint` HMAC-SHA256-seals a JSON-serializable payload and `verify` is exactly the function you assign to the hook. The codec is **signed, not encrypted** (the client can base64url-decode the payload). `mint` and `ctx.mcpReq.requestState()` are the typed encode/read pair: the seam captures what `verify` returns and the accessor hands it to the handler already decoded — no second `verify` call. See `examples/mrtr/server.ts` and [Multi-round-trip requests](#multi-round-trip-requests) for the full handler shape. **Multi-step flows: the phase switch.** `inputResponses` are **per round** — each retry carries only that round's responses, never earlier rounds' (the modern client driver and the [legacy shim](#legacy-shim-for-input_required) both guarantee replace, not accumulate). A flow with more than one input round therefore threads everything it has learned through `requestState`, as a discriminated union of phases, and switches on the phase rather than probing which response keys arrived: ```typescript type BrainstormState = | { step: 'awaiting-count' } | { step: 'awaiting-custom-count'; topic: string } | { step: 'awaiting-ideas'; topic: string; count: number }; const stateCodec = createRequestStateCodec({ key: SECRET }); // ServerOptions: { requestState: { verify: stateCodec.verify } } async (args, ctx) => { const state = ctx.mcpReq.requestState(); switch (state?.step) { case undefined: // first call — ask for the count return inputRequired({ inputRequests: { count: inputRequired.elicit({ … }) }, requestState: await stateCodec.mint({ step: 'awaiting-count' }) }); case 'awaiting-count': { const accepted = acceptedContent(ctx.mcpReq.inputResponses, 'count', COUNT_SCHEMA); // …decide: follow-up question or the sampling round, carrying // everything learned so far inside the next minted state… } case 'awaiting-ideas': { const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); return finish(ideas.kind === 'sampling' ? ideas.result : undefined, state.count, state.topic); } } }; ``` Each `case` knows exactly which answer to read and which data is in scope — the state machine is explicit, and the same handler runs unchanged on 2025-era connections through the legacy shim. --- ## Auth on 2026-07-28 The 2026-07-28 specification's authorization requirements (RFC 9207 `iss` validation, SEP-2352 credential isolation, SEP-2350 scope step-up, SEP-837/SEP-2207 DCR + TLS) are implemented in v2 as **SDK-level opt-ins, not protocol-era gates** — they apply on every era once enabled. The migration steps live in [upgrade-to-v2.md › Auth](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md#auth). To be **2026-07-28-conformant**, enable the spec-2026 opt-ins listed there: pass `iss` (or the callback `URLSearchParams`) to `finishAuth`; round-trip the `issuer` stamp on stored credentials; implement `discoveryState()`; and either keep `onInsufficientScope: 'reauthorize'` or handle `InsufficientScopeError` yourself. Nothing in this section is era-switched at the wire layer. --- ## Per-era wire codecs The wire layer is split into per-revision codecs inside the (private, bundled) core: one codec serves every 2025-era protocol version (2024-10-07 … 2025-11-25) and one serves 2026-07-28. The codec is selected by the negotiated protocol version, which is **connection state** on the `Client`/`Server` instance (instances with no negotiated version default to the 2025 era). An edge classification (`MessageExtraInfo.classification`) no longer switches the era per message — it is validated against the instance era, and a mismatch is rejected as an entry/routing error (`-32022 Unsupported protocol version` for requests; drop + `onerror` for notifications). Methods deleted by a protocol revision are **physically absent** from that era's registry: an inbound `tasks/get` on a 2026-era connection gets `-32601` even if a handler is registered, and sending an era-mismatched spec method (e.g. `server/discover` toward a 2025-era peer, or any `tasks/*` method toward a 2026-era peer) throws `SdkError(MethodNotSupportedByProtocolVersion)` before anything reaches the transport. If you were on a v2 alpha and consumed wire schemas directly: | v2-alpha pattern | Mechanical fix | | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | parsing wire bytes with `EmptyResultSchema` that may carry `resultType` | strip `resultType` first (the schema now rejects it as an unknown key) | | `specTypeSchemas` / `SpecTypeName` references to task message types or `RequestMetaEnvelope` | remove — these validators left the public set (the **types** remain importable) | | `ClientRequest` / `ServerResult` / … aggregate types expected to include task members | use the individual deprecated `Task*` types — role aggregates are now the neutral (task-free) sets | | relying on `isCallToolResult` to reject wire-only members | guards validate neutral shapes (loose passthrough); validate raw wire traffic with a transport-level parse | The `resultType` / `EmptyResultSchema` / `specTypeSchemas` rules above have **no v1.x impact** — these members did not exist before 2026-07-28. The neutral-model wire tightening that **does** affect v1 code (custom-handler `_meta` passthrough, `specTypeSchemas` narrowing) is in [upgrade-to-v2.md › Wire tightening](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md#wire-tightening-every-era); `CallToolResult.content` keeps its v1 default on the legacy era (2026-07-28 connections require it explicitly). > **If you were on a v2 alpha:** the 2026-07-28 draft error codes were renumbered: > `HeaderMismatch` `-32001`→`-32020`, `MissingRequiredClientCapability` `-32003`→`-32021`, > `UnsupportedProtocolVersion` `-32004`→`-32022`. No v1.x impact (these codes never > existed in v1); v2-alpha code that hard-coded the old literals must update — prefer > `ProtocolErrorCode.*` / `HEADER_MISMATCH_ERROR_CODE`. --- ## Wire-only members hidden from public types The 2026-07-28 wire-level bookkeeping is handled internally and never reaches application code: the `resultType` discrimination field, the reserved per-request `_meta` envelope keys (`io.modelcontextprotocol/{protocolVersion,clientInfo,clientCapabilities,logLevel}`), and the multi-round-trip retry fields (`inputResponses`, `requestState`). - **`resultType` is gone from every public result type** (`Result`, `CallToolResult`, `GetPromptResult`, …). The wire schemas keep parsing it, and the protocol layer consumes it before results reach your code. - **`DiscoverResult` hides its cache fields at the type level only.** `ttlMs` / `cacheScope` on `server/discover` are read by the client's response-cache layer and are absent from the public `DiscoverResult` type returned by `getDiscoverResult()` — but they are not removed at runtime: the returned object still carries both, readable via a cast. The wire parse defaults absent or malformed hints to `0` / `'private'`, so only tooling that must distinguish an omitted hint from an advertised default needs raw frames. - **High-level methods return the named public types** (`client.callTool()` → `Promise`, etc.). Handler return positions are unaffected. - **Reserved envelope keys and retry fields appear in no public params/result type.** The `RequestMetaEnvelope` type and the four envelope `*_META_KEY` constants stay exported. The protocol layer enforces the same boundary at runtime: - **Envelope lift.** On inbound requests and notifications, the reserved `io.modelcontextprotocol/*` keys are lifted out of `params._meta` before handlers run. For requests the envelope is readable at `ctx.mcpReq.envelope` (typed `Partial`); for notifications there is no per-message context, so lifted envelope keys are dropped. On requests only, `inputResponses` / `requestState` are lifted from top-level params to `ctx.mcpReq.inputResponses` / the `ctx.mcpReq.requestState()` accessor; notification params are never touched. - **Collision note for 2025-era peers.** The `_meta` lift is invisible to conforming 2025 traffic (the `io.modelcontextprotocol/` prefix is reserved in 2025-11-25 too). The retry-field lift is the one collision: 2025-11-25 does not reserve the bare names `inputResponses`/`requestState`, so a 2025 peer's **custom-method request** that uses them as ordinary top-level params has them lifted out of `request.params` (still readable at `ctx.mcpReq.inputResponses` / `ctx.mcpReq.requestState()`). - **Raw-first result discrimination.** On a 2026-era exchange, `'complete'` is consumed and stripped; `'input_required'` is fulfilled by the client's auto-fulfilment driver; any other kind rejects with `SdkError(UnsupportedResultType)` (kind in `error.data.resultType`). On a 2025-era connection a foreign `resultType` is stripped before validation. On a 2026-era exchange `resultType` is REQUIRED; an absent value is a spec violation surfaced as a typed error. **If you were on a v2 alpha** and read the wire shape directly: | Pattern | Mechanical fix | | -------------------------------------- | --------------------------------------------------------------------------------- | | `result.resultType` (typed read) | delete the read — the SDK consumes the field; results are complete when delivered | | `Result['resultType']` type reference | remove; the member is no longer declared | | return-type capture of `callTool` etc. | use the named public types (`CallToolResult`, `ListToolsResult`, …) | `MessageExtraInfo.classification` is an optional carrier (`{ era, revision?, envelope? }`) for transports that classify inbound messages at the edge; dispatch validates it against the instance's negotiated era. --- ## Multi-round-trip requests The 2026-07-28 revision removes the server→client JSON-RPC request channel. Servers obtain client input (elicitation, sampling, roots) **in-band** by returning `inputRequired(...)` from a `tools/call` / `prompts/get` / `resources/read` handler; the client retries the original call with the responses. | Handler serving 2026-07-28 requests | Mechanical fix | | ------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `await ctx.mcpReq.elicitInput({…})` / `requestSampling({…})` | `return inputRequired({ inputRequests: { id: inputRequired.elicit({…}) } })`; read `acceptedContent(ctx.mcpReq.inputResponses, 'id')` on re-entry | | `throw new UrlElicitationRequiredError([…])` | `return inputRequired({ inputRequests: { id: inputRequired.elicitUrl({…}) } })` | | handler shared across both eras | **no branch needed** — write the `inputRequired(...)` form once; the [legacy shim](#legacy-shim-for-input_required) serves it to 2025-era connections by issuing real server→client requests | `inputRequired` / `acceptedContent` / `InputRequiredSpec` are exported from `@modelcontextprotocol/server`. On 2026-era requests the push-style APIs (`ctx.mcpReq.send` of server→client requests, `ctx.mcpReq.elicitInput`, `ctx.mcpReq.requestSampling`, instance-level `createMessage()`/`elicitInput()`/`listRoots()`/`ping()`) fail with a typed local error before anything reaches the wire; their behavior toward 2025-era requests is unchanged. The same split applies to `throw new UrlElicitationRequiredError(...)`: on 2025-era connections it is unchanged — the throw still produces the `-32042` protocol error, not an `isError` result; on 2026-07-28 requests it fails with a clear error steering to `inputRequired.elicitUrl(...)` rather than being converted silently. `requestState` round-trips as an opaque, **untrusted** string — see [Replacing per-session state: `requestState`](#replacing-per-session-state-requeststate) for the sealing helper and verification hook. **Client side — auto-fulfilment by default.** When a 2026-07-28 call answers `input_required`, the client fulfils the embedded requests through the same handlers registered with `setRequestHandler('elicitation/create' | 'sampling/createMessage' | 'roots/list', …)` and retries (fresh request id, `inputResponses`, byte-exact `requestState` echo) up to `inputRequired.maxRounds` rounds (default 10). Configure or opt out via `ClientOptions.inputRequired` (`{ autoFulfill: false }`); drive manually per call with `allowInputRequired: true` plus `withInputRequired()`. Expect `SdkError(InputRequiredRoundsExceeded)` when the cap is exhausted. **Typed readers for `inputResponses`.** Beyond `acceptedContent(responses, key)` (a structural read with an unvalidated cast), two typed readers ship from `@modelcontextprotocol/server`: - `acceptedContent(responses, key, schema)` — schema-aware overload (any synchronous Standard Schema, e.g. a zod object): validates the untrusted accepted content and returns it typed, or `undefined` on mismatch/decline/missing. - `inputResponse(responses, key)` — discriminated view (`{kind:'missing'} | {kind:'elicit', action, content?} | {kind:'sampling', result} | {kind:'roots', roots}`) for decline/cancel detection and the non-elicitation kinds. Content conveniences stay in your code — e.g. the text of a sampling response is a one-liner over the discriminated view: ```typescript const ideas = inputResponse(ctx.mcpReq.inputResponses, 'ideas'); const block = ideas.kind === 'sampling' && !Array.isArray(ideas.result.content) ? ideas.result.content : undefined; const text = block?.type === 'text' ? block.text : undefined; ``` --- ## Legacy shim for `input_required` An `input_required` return on a **2025-era** connection is served by the SDK's legacy shim, on by default: each embedded request is sent as a real server→client request (`elicitation/create`, `sampling/createMessage`, `roots/list`) over the live session — stamped with the originating request's id, so on sessionful Streamable HTTP the requests ride the originating POST's stream — and the handler is re-entered with the collected `inputResponses` until it returns a final result. Handlers are **written once** in the 2026 `inputRequired(...)` style and serve both eras; the push-style APIs remain available for code that still calls them directly. The handler cannot tell which era fulfilled it — the shim mirrors the modern client driver's semantics exactly: - `inputResponses` are **per round** (replaced on every re-entry, never accumulated); multi-step flows thread earlier answers through `requestState`. - `requestState` is echoed byte-exact, and the configured `ServerOptions.requestState.verify` hook runs on **every** round, exactly as it would on a modern wire retry (so TTL expiry behaves identically; a rejection answers the frozen `-32602`). - Responses arrive as the bare result objects, era-wire-shape-validated only: elicitation accepted content is NOT re-checked against `requestedSchema` — exactly as on the modern era — so the handler validates with the schema-aware `acceptedContent(responses, key, schema)` overload and can re-issue the request instead of the call dying on a mistyped form field. - Rounds with no embedded requests (requestState-only) are paced at 250ms. - URL-mode elicitation legs are sent with a synthesized `elicitationId` (the 2025-11-25 wire requires one; the 2026 in-band shape has none). Knobs live at `ServerOptions.inputRequired`: | Member | Default | Meaning | | ---------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `maxRounds` | `8` | Handler re-entries per originating request before failing — deliberately tighter than the client driver's 10: the shim holds a live wire request open for the whole flow | | `roundTimeoutMs` | `600_000` | Per-leg timeout (with `resetTimeoutOnProgress`) — embedded requests are human-paced, so the 60s protocol default does not apply | | `legacyShim` | `true` | `false` restores the pre-shim loud failure (`-32603`) and the branch-on-era pattern | Failures surface **per family**: `tools/call` failures (capability refusal, a failed leg, round-cap exhaustion) become `isError` tool results — the 2025-era idiom hosts already render — while `prompts/get` / `resources/read` failures surface as JSON-RPC errors. Server bugs (malformed input-required results) fail loudly on both eras. The shim emits no progress of its own. The originating request's `progressToken` identifies a single must-increase stream that belongs to the handler — injecting synthetic ticks into it cannot compose with handler-emitted progress (one stream, one author), so the shim never writes to it: a 2025 client watching a multi-round flow sees exactly what a hand-written 2025 push-style handler would have produced. A handler that reports progress across rounds should derive its values from its phase state so they increase across re-entries — the token spans the whole flow. **Inherited limits** (the same ones hand-written push-style handlers have today): - The shim pre-checks each embedded request kind against the client capabilities declared at the 2025 `initialize` handshake (a bare `elicitation: {}` declaration counts as form support — the pre-mode meaning, same as the modern `-32021` gate). Capability-less clients get a clean refusal, never a hang. - **Stateless legacy HTTP** (`createMcpHandler` with `legacy: 'stateless'`) builds a fresh instance per request: no initialize handshake, no return path for server→client requests. The shim degrades to the clean capability refusal there — full shim behavior needs stdio (`serveStdio`) or a sessionful legacy wiring. - JSON-mode legacy hosting (`enableJsonResponse`) cannot deliver server→client requests mid-call: the transport drops them, so a shim leg waits out `roundTimeoutMs` before failing per family — the same undeliverable class as today's `elicitInput` in that configuration, which waits out its own 60s default. Interactive tools need a streaming-capable session. - The 2025-era `notifications/elicitation/complete` channel for URL-mode elicitation is not bridged: URL-mode legs complete like any other elicitation response. The sender API for that channel, `Server.createElicitationCompletionNotifier()`, is itself unchanged from v1 for 2025-era URL-mode elicitation — only the shim does not bridge it. --- ## `subscriptions/listen` The 2026-07-28 revision delivers `tools/prompts/resources` `list_changed` and `resources/updated` only on a `subscriptions/listen` stream the client opened — the server never sends an un-requested notification type. **Server side.** Nothing to register: the serving entries handle `subscriptions/listen` themselves. `createMcpHandler` returns `.notify.{toolsChanged, promptsChanged, resourcesChanged, resourceUpdated(uri)}` typed publish sugar over an in-process bus (supply your own `ServerEventBus` for multi-process deployments). On stdio, `serveStdio` routes the pinned instance's existing `send*ListChanged()` calls onto the active subscriptions automatically. The 2025-era unsolicited delivery model is unchanged on legacy connections. **Client side.** `ClientOptions.listChanged` keeps working: on a 2026-07-28 connection the SDK auto-opens a `subscriptions/listen` stream whose filter is the intersection of the configured sub-options and the server-advertised `listChanged` capabilities, so the same handlers fire on every published change. `client.listen(filter)` opens a stream explicitly. `resources/subscribe` is 2025-only — on a 2026-07-28 connection, request `notifications/resources/updated` via the `resourceSubscriptions` field of the listen filter instead. **Graceful close.** When the server closes the listen stream deliberately (entry `close()`/shutdown), it sends the empty `subscriptions/listen` JSON-RPC result before closing the stream; `McpSubscription.closed` resolves `'graceful'`. A stream close without a result resolves `'remote'` and indicates an unexpected disconnect — re-listen if you still want events. --- ## `Mcp-Param-*` and standard headers (SEP-2243) On a 2026-07-28 connection over Streamable HTTP, `Client.callTool()` mirrors tool arguments designated with `x-mcp-header` in the tool's `inputSchema` into `Mcp-Param-{Name}` HTTP request headers (Base64-sentinel-encoded where needed), and `createMcpHandler` rejects a `tools/call` whose `Mcp-Param-*` headers are missing for a present body value, malformed, or disagree with the body — `400 Bad Request` with JSON-RPC `-32020` (`HeaderMismatch`). The Streamable HTTP transport also emits the `Mcp-Name` standard header on every modern-enveloped request, and `createMcpHandler` validates the SEP-2243 standard headers (`MCP-Protocol-Version`, `Mcp-Method`, `Mcp-Name`) against the body on the modern path with the same rejection. **Modern-era exception** to the `SdkHttpError` mapping: on a modern-enveloped request, an HTTP `400` whose body is a well-formed JSON-RPC error response addressed to the pending request id is delivered in-band as a `ProtocolError` (so the `-32020` recovery retry can fire). Legacy-era exchanges and generic HTTP failures still surface as `SdkHttpError`. Additive options: `CallToolRequestOptions.toolDefinition` (pass the tool definition directly so mirroring and output-schema validation run without a prior `tools/list`), `TransportSendOptions.headers` (per-request HTTP headers; reserved standard/auth header names are skipped). Browser clients skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS). --- ## Cache fields and cache hints The 2026-07-28 revision requires `ttlMs` and `cacheScope` on the cacheable results. When serving that revision, the SDK always emits both fields, defaulting to `ttlMs: 0` and `cacheScope: 'private'` (the most conservative policy). To advertise a real cache policy, set `ServerOptions.cacheHints` (per-operation) or `cacheHint` on a `registerResource` metadata object; resolution is per field, most-specific author first. 2025-era responses never carry these fields. --- ## Tasks: deprecated wire vocabulary The task **wire surface** defined by the 2025-11-25 protocol revision is still exported for interoperability with peers on that revision: the task Zod schemas and inferred types (`Task`, `TaskStatus`, `TaskMetadata`, `RelatedTaskMetadata`, `CreateTaskResult`, `GetTask*`, `ListTasks*`, `CancelTask*`, `TaskStatusNotification*`, `TaskAugmentedRequestParams`), the task members of the request/result/notification union types, the `tasks` capability key, `isTaskAugmentedRequestParams`, and `RELATED_TASK_META_KEY`. All are now `@deprecated` (importable wire vocabulary only; removable at the major version that drops 2025-era support). Task methods are excluded from the typed method maps: `RequestMethod` / `RequestTypeMap` / `ResultTypeMap` / `NotificationTypeMap` have no `tasks/*` or `notifications/tasks/status` entries, so the method-keyed overloads of `request()`, `ctx.mcpReq.send()`, `setRequestHandler()`, `setNotificationHandler()` reject task methods at compile time. `ResultTypeMap['tools/call']` is plain `CallToolResult` (no `| CreateTaskResult`); same for `sampling/createMessage` and `elicitation/create`. (Typings published before `2.0.0-alpha.4` predate this exclusion: there the typed maps still carry the `tasks/*` entries and the `CreateTaskResult` unions; narrow with the `isCallToolResult` guard if you are pinned to one of those alphas. `2.0.0-alpha.4` and later include the exclusion.) Where task interop is genuinely required, use the explicit-schema custom-method form (`request({ method: 'tasks/get', params }, GetTaskResultSchema)`). Inbound `tasks/*` requests → `-32601`. The experimental tasks **interception** layer is removed entirely — see [upgrade-to-v2.md › Experimental tasks interception removed](https://ts.sdk.modelcontextprotocol.io/v2/migration/upgrade-to-v2.md#experimental-tasks-interception-removed). --- ## Appendix: 2025-era vs 2026-era behavior matrix | Axis | 2025-era (2024-10-07 … 2025-11-25) | 2026-07-28 | | ------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------ | | Server HTTP entry | `*StreamableHTTPServerTransport` | `createMcpHandler` (`legacy: 'stateless'` also serves 2025) | | Server stdio entry | `server.connect(new StdioServerTransport())` | `serveStdio(factory)` (also serves 2025 unless `legacy: 'reject'`) | | Client connect | `initialize` handshake | `server/discover` probe (`versionNegotiation`) | | Client identity | `getClientCapabilities()` / `getClientVersion()` (initialize-scoped) | `ctx.mcpReq.envelope` (per request) | | Server→client requests | `ctx.mcpReq.elicitInput` / `requestSampling`, instance `createMessage()` etc. | `return inputRequired(...)` from handler | | Change notifications | unsolicited `list_changed` / `resources/updated` | `subscriptions/listen` stream | | Client cancellation (Streamable HTTP) | POST `notifications/cancelled` | close the request's SSE response stream | | `ctx.mcpReq.log()` level filter | session-scoped `logging/setLevel` | per-request `_meta.logLevel` envelope key (absent = opt-out) | | `400` JSON-RPC error body | `SdkHttpError` | `ProtocolError` (in-band) | | Era-mismatched spec method (outbound) | n/a | `SdkError(MethodNotSupportedByProtocolVersion)` |