docs: rebuild the v2 documentation around a task-based page tree (#2397)

This commit is contained in:
Felix Weinberger 2026-06-30 21:18:42 +01:00 • committed by GitHub
commit b153505428
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 11884 additions and 4971 deletions

View file

@ -0,0 +1,113 @@
---
shape: how-to
---
# Require authorization
Protecting a server you run → this page. Signing a user in from a client you build → [Authenticate a user with OAuth](../clients/oauth.md). No user present → [Authenticate without a user](../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](./express.md).
```ts source="../../examples/guides/serving/authorization.examples.ts#requireBearerAuth_basic"
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.
:::
## 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 source="../../examples/guides/serving/authorization.examples.ts#tokenVerifier_basic"
async function verifyAccessToken(token: string): Promise<AuthInfo> {
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 source="../../examples/guides/serving/authorization.examples.ts#metadataRouter_basic"
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.
## 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 source="../../examples/guides/serving/authorization.examples.ts#authInfo_handler"
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](./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 source="../../examples/guides/serving/authorization.examples.ts#perToolScopes_handler"
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](../clients/oauth.md).
:::
## Recap
- `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`.

90
docs/serving/express.md Normal file
View file

@ -0,0 +1,90 @@
---
shape: how-to
---
# 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 source="../../examples/guides/serving/express.examples.ts#createMcpExpressApp_mount"
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](./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 source="../../examples/guides/serving/express.examples.ts#createMcpExpressApp_allowedHosts"
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 source="../../examples/guides/serving/express.examples.ts#requireBearerAuth_mount"
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](./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 source="../../examples/guides/serving/express.examples.ts#createMcpExpressApp_listen"
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`.

90
docs/serving/fastify.md Normal file
View file

@ -0,0 +1,90 @@
---
shape: how-to
---
# 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 source="../../examples/guides/serving/fastify.examples.ts#createMcpFastifyApp_mount"
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](./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 source="../../examples/guides/serving/fastify.examples.ts#createMcpFastifyApp_allowedHosts"
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 source="../../examples/guides/serving/fastify.examples.ts#toNodeHandler_authInfo"
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](./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 source="../../examples/guides/serving/fastify.examples.ts#createMcpFastifyApp_listen"
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`.

89
docs/serving/hono.md Normal file
View file

@ -0,0 +1,89 @@
---
shape: how-to
---
# 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 source="../../examples/guides/serving/hono.examples.ts#createMcpHonoApp_mount"
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](./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 source="../../examples/guides/serving/hono.examples.ts#createMcpHonoApp_allowedHosts"
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 source="../../examples/guides/serving/hono.examples.ts#McpHttpHandler_fetch_authInfo"
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](./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`.

129
docs/serving/http.md Normal file
View file

@ -0,0 +1,129 @@
---
shape: how-to
---
# 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](./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 source="../../examples/guides/serving/http.examples.ts#createHandler"
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<Response>` — 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](../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](../servers/notifications.md).
::: info Coming from v1?
`createMcpHandler` replaces the per-request `StreamableHTTPServerTransport` + `connect()` wiring — run the codemod, then see the [upgrade guide](../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 source="../../examples/guides/serving/http.examples.ts#factoryContext"
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](../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](./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`:
```ts source="../../examples/guides/serving/http.examples.ts#mountNode"
createServer(toNodeHandler(handler)).listen(3000);
```
`POST http://localhost:3000/mcp` now reaches the factory. The same wrapped handler mounts under [Express](./express.md), [Fastify](./fastify.md), and [Hono](./hono.md); [Serve on web-standard runtimes](./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](./express.md), [Hono](./hono.md), and [Fastify](./fastify.md) recipes start there. On a bare fetch runtime, put `hostHeaderValidationResponse` and `originValidationResponse` (from `@modelcontextprotocol/server`) in front of `handler.fetch` — [Serve on web-standard runtimes](./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](./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 source="../../examples/guides/serving/http.examples.ts#shapeResponse"
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](./legacy-clients.md).
:::
## Shut down
`handler.close()` aborts in-flight exchanges and closes their per-request instances; the handler holds nothing else.
```ts source="../../examples/guides/serving/http.examples.ts#shutDown"
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<Response>`.
- 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.

View file

@ -0,0 +1,102 @@
---
shape: how-to
---
# 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](../protocol-versions.md) covers the era model itself.
## Choose a legacy posture
[`createMcpHandler`](./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 source="../../examples/guides/serving/legacy-clients.examples.ts#createMcpHandler_legacyReject"
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`](./stdio.md) takes the same option with a different default — `'serve'` — and applies it once per connection, not per request.
```ts source="../../examples/guides/serving/legacy-clients.examples.ts#serveStdio_legacyReject"
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 source="../../examples/guides/serving/legacy-clients.examples.ts#isLegacyRequest_route"
import { isLegacyRequest, legacyStatelessFallback } from '@modelcontextprotocol/server';
const legacy = legacyStatelessFallback(buildServer);
async function serve(request: Request): Promise<Response> {
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](../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`.

View file

@ -0,0 +1,107 @@
---
shape: how-to
---
# 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](./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](../protocol-versions.md)). On `NodeStreamableHTTPServerTransport`, `sessionIdGenerator` turns sessions on; leaving it `undefined` is stateless mode.
```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#sessions_stateful"
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](./express.md) covers the app itself).
```ts source="../../examples/guides/serving/sessions-state-scaling.examples.ts#sessions_routing"
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
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 source="../../examples/guides/serving/sessions-state-scaling.examples.ts#resumability_eventStore"
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](../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 source="../../examples/guides/serving/sessions-state-scaling.examples.ts#multiNode_bus"
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`.

79
docs/serving/stdio.md Normal file
View file

@ -0,0 +1,79 @@
---
shape: how-to
---
# 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](./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 source="../../examples/guides/serving/stdio.examples.ts#serveStdio_basic"
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](../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](./legacy-clients.md). The entry also owns which protocol revision each connection negotiates — see [Protocol versions](../protocol-versions.md).
:::
## Log to stderr, never stdout
Announce readiness with `console.error`, which writes to stderr.
```ts source="../../examples/guides/serving/stdio.examples.ts#serveStdio_logStderr"
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 source="../../examples/guides/serving/stdio.examples.ts#serveStdio_shutdown"
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 <command>` exercises a stdio server without configuring it in a host.
- The returned `StdioServerHandle`'s `close()` tears down the pinned instance and the transport.

View file

@ -0,0 +1,88 @@
---
shape: how-to
---
# 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 source="../../examples/guides/serving/webStandard.examples.ts#createMcpHandler_exportDefault"
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](./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 source="../../examples/guides/serving/webStandard.examples.ts#hostHeaderValidationResponse_guard"
import { hostHeaderValidationResponse, originValidationResponse } from '@modelcontextprotocol/server';
const guarded = {
async fetch(request: Request): Promise<Response> {
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 source="../../examples/guides/serving/webStandard.examples.ts#McpHttpHandler_fetch_authInfo"
const secured = {
async fetch(request: Request): Promise<Response> {
const authInfo = await verifyToken(request);
return handler.fetch(request, { authInfo });
}
};
```
`verifyToken` is your token verification. [Authorization](./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`.