docs: rebuild the v2 documentation around a task-based page tree (#2397)
This commit is contained in:
parent
708d5455d9
commit
b153505428
9 changed files with 11884 additions and 4971 deletions
113
docs/serving/authorization.md
Normal file
113
docs/serving/authorization.md
Normal 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
90
docs/serving/express.md
Normal 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
90
docs/serving/fastify.md
Normal 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
89
docs/serving/hono.md
Normal 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
129
docs/serving/http.md
Normal 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.
|
||||
102
docs/serving/legacy-clients.md
Normal file
102
docs/serving/legacy-clients.md
Normal 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`.
|
||||
107
docs/serving/sessions-state-scaling.md
Normal file
107
docs/serving/sessions-state-scaling.md
Normal 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
79
docs/serving/stdio.md
Normal 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.
|
||||
88
docs/serving/web-standard.md
Normal file
88
docs/serving/web-standard.md
Normal 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`.
|
||||
Loading…
Reference in a new issue