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

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.