Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/examples-protected-wiring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/node': patch
---

Document composing the host and origin validation guards in front of `toNodeHandler` for hand-wired `node:http` servers, matching the protected wiring the examples and serving guide now demonstrate.
14 changes: 10 additions & 4 deletions docs/serving/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,25 @@ Because no state lives on the instance, the endpoint is stateless and scales hor

## 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`:
On a web-standard runtime — Cloudflare Workers, Deno, Bun — `export default handler` is the entire mount. Node frameworks wrap the handler once with `toNodeHandler` from `@modelcontextprotocol/node`; on plain `node:http`, bind loopback explicitly and compose the `localhostHostValidation` / `localhostOriginValidation` guards (also from `@modelcontextprotocol/node`) in front of it, matching the framework factories' defaults:

```ts source="../../examples/guides/serving/http.examples.ts#mountNode"
createServer(toNodeHandler(handler)).listen(3000);
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(3000, '127.0.0.1');
```

`POST http://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.
`POST http://127.0.0.1:3000/mcp` now reaches the factory; the guards answer anything else with `403` before the handler sees it — [the next section](#validate-host-and-origin-in-front-of-it) explains why they belong in front. The same wrapped handler mounts under [Express](./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.
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 plain `node:http`, compose `localhostHostValidation` and `localhostOriginValidation` (from `@modelcontextprotocol/node`) in front of the wrapped handler, as [the mount above](#mount-it-on-your-runtime) does. On a bare fetch runtime, put `hostHeaderValidationResponse` and `originValidationResponse` (from `@modelcontextprotocol/server`) in front of `handler.fetch` — [Serve on web-standard runtimes](./web-standard.md#protect-against-dns-rebinding) builds that wrapper.

## Pass authentication through

Expand Down
19 changes: 15 additions & 4 deletions examples/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@ it (HTTP-only auth, sessionful transports, framework adapters, etc.).
### `server.ts`

```ts
import { createServer } from 'node:http';

import { serve } from '@hono/node-server';
import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';

Expand All @@ -46,12 +45,24 @@ if (transport === 'stdio') {
console.error('[server] serving over stdio');
} else {
const handler = createMcpHandler(buildServer);
createServer(toNodeHandler(handler)).listen(port, () => {
// `createMcpHonoApp()` arms localhost Host/Origin validation by default.
const app = createMcpHonoApp();
app.all('/mcp', c => handler.fetch(c.req.raw));
serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}
```

The HTTP leg binds loopback explicitly and mounts the handler behind
`createMcpHonoApp()`, which applies host/origin validation by default —
matching the framework factories' defaults. A story whose point is the raw
`node:http` wiring (e.g. `gateway/`, `elicitation/`) keeps
`createServer` + `toNodeHandler` but must then bind
`.listen(port, '127.0.0.1')` and compose the `localhostHostValidation()` /
`localhostOriginValidation()` guards from `@modelcontextprotocol/node` in
front of the handler. Either way, no example teaches an unguarded HTTP mount.

### `client.ts`

```ts
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pnpm --filter @mcp-examples/<story> client -- --http http://127.0.0.1:3000/mcp

Add `-- --legacy` to the client command for the 2025-era handshake.

Every HTTP leg serves the handler behind host/origin validation — via `createMcpHonoApp()` (or another framework app factory, which arm the checks by default on localhost binds), or, for raw `node:http` stories, via the `localhostHostValidation()` / `localhostOriginValidation()` guards from `@modelcontextprotocol/node` composed in front of `toNodeHandler` on an explicit loopback bind. New stories follow the canonical skeleton — see [CONTRIBUTING.md](./CONTRIBUTING.md).

The one exception to the generic commands is the reference pair: [`cli-client/`](./cli-client/README.md) and [`todos-server/`](./todos-server/README.md) have their own entry points (`pnpm --filter @mcp-examples/cli-client start`, `pnpm --filter @mcp-examples/todos-server start:http`) — see their READMEs.

## Start here
Expand Down
3 changes: 2 additions & 1 deletion examples/caching/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
"client": "tsx client.ts"
},
"dependencies": {
"@hono/node-server": "catalog:runtimeServerOnly",
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/hono": "workspace:*",
"@modelcontextprotocol/server": "workspace:*"
},
"devDependencies": {
Expand Down
11 changes: 7 additions & 4 deletions examples/caching/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@
* The fields are emitted ONLY toward 2026-era clients — a 2025-era response
* is byte-for-byte unchanged. One binary, either transport.
*/
import { createServer } from 'node:http';

import { serve } from '@hono/node-server';
import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';

Expand Down Expand Up @@ -96,7 +95,11 @@ if (transport === 'stdio') {
console.error('[server] serving over stdio');
} else {
const handler = createMcpHandler(buildServer);
createServer(toNodeHandler(handler)).listen(port, () => {
// `createMcpHonoApp()` binds the endpoint behind localhost host/origin
// validation by default, matching the framework factories' defaults.
const app = createMcpHonoApp();
app.all('/mcp', c => handler.fetch(c.req.raw));
serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}
3 changes: 2 additions & 1 deletion examples/custom-methods/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
"client": "tsx client.ts"
},
"dependencies": {
"@hono/node-server": "catalog:runtimeServerOnly",
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/hono": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
Expand Down
10 changes: 6 additions & 4 deletions examples/custom-methods/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@
* One binary, either transport — selected by `--http --port <N>` (defaults to
* stdio). See `examples/CONTRIBUTING.md` for the canonical shape.
*/
import { createServer } from 'node:http';

import { serve } from '@hono/node-server';
import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
import { z } from 'zod/v4';
Expand Down Expand Up @@ -36,7 +35,10 @@ if (transport === 'stdio') {
console.error('[server] serving over stdio');
} else {
const handler = createMcpHandler(buildServer);
createServer(toNodeHandler(handler)).listen(port, () => {
// `createMcpHonoApp()` arms localhost host/origin validation by default.
const app = createMcpHonoApp();
app.all('/mcp', c => handler.fetch(c.req.raw));
serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}
3 changes: 2 additions & 1 deletion examples/custom-version/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
"client": "tsx client.ts"
},
"dependencies": {
"@hono/node-server": "catalog:runtimeServerOnly",
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/hono": "workspace:*",
"@modelcontextprotocol/server": "workspace:*"
},
"devDependencies": {
Expand Down
11 changes: 7 additions & 4 deletions examples/custom-version/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@
* The first version in the list is the fallback when the client requests an
* unsupported one. One binary, either transport.
*/
import { createServer } from 'node:http';

import { serve } from '@hono/node-server';
import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
import { createMcpHandler, McpServer, SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';

Expand All @@ -33,7 +32,11 @@ if (transport === 'stdio') {
console.error('[server] serving over stdio');
} else {
const handler = createMcpHandler(buildServer);
createServer(toNodeHandler(handler)).listen(port, () => {
// `createMcpHonoApp()` binds the endpoint behind localhost host/origin
// validation by default, matching the framework factories' defaults.
const app = createMcpHonoApp();
app.all('/mcp', c => handler.fetch(c.req.raw));
serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}
3 changes: 2 additions & 1 deletion examples/dual-era/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
"client": "tsx client.ts"
},
"dependencies": {
"@hono/node-server": "catalog:runtimeServerOnly",
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/hono": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
Expand Down
11 changes: 7 additions & 4 deletions examples/dual-era/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@
* (`createMcpHandler(buildServer)` on its default posture — modern served per
* request, 2025-era traffic served stateless from the same factory).
*/
import { createServer } from 'node:http';

import { serve } from '@hono/node-server';
import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
import type { CallToolResult, McpRequestContext } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
Expand Down Expand Up @@ -49,7 +48,11 @@ if (transport === 'stdio') {
console.error('[server] serving over stdio');
} else {
const handler = createMcpHandler(buildServer);
createServer(toNodeHandler(handler)).listen(port, () => {
// `createMcpHonoApp()` binds the endpoint behind localhost host/origin
// validation by default, matching the framework factories' defaults.
const app = createMcpHonoApp();
app.all('/mcp', c => handler.fetch(c.req.raw));
serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}
18 changes: 16 additions & 2 deletions examples/elicitation/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ import type { IncomingMessage, ServerResponse } from 'node:http';
import { createServer } from 'node:http';

import { parseExampleArgs } from '@mcp-examples/shared';
import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node';
import {
localhostHostValidation,
localhostOriginValidation,
NodeStreamableHTTPServerTransport,
toNodeHandler,
toWebRequest
} from '@modelcontextprotocol/node';
import type {
CallToolResult,
ElicitRequestFormParams,
Expand Down Expand Up @@ -274,7 +280,15 @@ if (transport === 'stdio') {
}
};

// Host/Origin guards for the hand-wired `node:http` server — plain
// `createServer` has no middleware chain, so compose the boolean-returning
// guards from `@modelcontextprotocol/node` in front of both arms,
// matching the framework factories' localhost defaults.
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();

createServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void (async () => {
// `toWebRequest` reads the Node body into a web-standard `Request`,
// so the body now lives in `request`, not `req`. Ask the predicate
Expand All @@ -289,7 +303,7 @@ if (transport === 'stdio') {
console.error('[server] request error:', error instanceof Error ? error.message : error);
if (!res.headersSent) res.writeHead(500).end();
});
}).listen(port, () => {
}).listen(port, '127.0.0.1', () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}
3 changes: 2 additions & 1 deletion examples/extension-capabilities/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
"client": "tsx client.ts"
},
"dependencies": {
"@hono/node-server": "catalog:runtimeServerOnly",
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/hono": "workspace:*",
"@modelcontextprotocol/server": "workspace:*"
},
"devDependencies": {
Expand Down
11 changes: 7 additions & 4 deletions examples/extension-capabilities/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,9 @@
* One binary, either transport — selected by `--http --port <N>` (defaults to
* stdio). See `examples/CONTRIBUTING.md` for the canonical shape.
*/
import { createServer } from 'node:http';

import { serve } from '@hono/node-server';
import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';

Expand All @@ -31,7 +30,11 @@ if (transport === 'stdio') {
console.error('[server] serving over stdio');
} else {
const handler = createMcpHandler(buildServer);
createServer(toNodeHandler(handler)).listen(port, () => {
// `createMcpHonoApp()` arms localhost host/origin validation by default;
// bind loopback explicitly to match.
const app = createMcpHonoApp();
app.all('/mcp', c => handler.fetch(c.req.raw));
serve({ fetch: app.fetch, port, hostname: '127.0.0.1' }, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
}
14 changes: 12 additions & 2 deletions examples/gateway/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import { createServer } from 'node:http';

import { parseExampleArgs } from '@mcp-examples/shared';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

Expand Down Expand Up @@ -44,6 +44,16 @@ function buildServer(): McpServer {
const { port } = parseExampleArgs();

const handler = createMcpHandler(buildServer);
createServer(toNodeHandler(handler)).listen(port, () => {
const nodeHandler = toNodeHandler(handler);
// Bind loopback explicitly and apply host/origin validation in front of the
// handler, matching the framework factories' defaults. The guards answer
// rejected requests themselves and never reach `createMcpHandler`, so the
// per-request factory count the client asserts on is unchanged.
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(port, '127.0.0.1', () => {
console.error(`[server] listening on http://127.0.0.1:${port}/mcp`);
});
10 changes: 8 additions & 2 deletions examples/guides/serving/http.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
/* eslint-disable no-console */
import { createServer } from 'node:http';

import { toNodeHandler } from '@modelcontextprotocol/node';
import { localhostHostValidation, localhostOriginValidation, toNodeHandler } from '@modelcontextprotocol/node';
import type { McpServerFactory } from '@modelcontextprotocol/server';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -62,7 +62,13 @@ const perCaller = createMcpHandler(({ authInfo }) => {
/** Example: mounting the handler on plain `node:http`. */
function mountNode(): void {
//#region mountNode
createServer(toNodeHandler(handler)).listen(3000);
const nodeHandler = toNodeHandler(handler);
const validateHost = localhostHostValidation();
const validateOrigin = localhostOriginValidation();
createServer((req, res) => {
if (!validateHost(req, res) || !validateOrigin(req, res)) return;
void nodeHandler(req, res);
}).listen(3000, '127.0.0.1');
//#endregion mountNode
}
void mountNode;
Expand Down
3 changes: 2 additions & 1 deletion examples/json-response/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
"client": "tsx client.ts"
},
"dependencies": {
"@hono/node-server": "catalog:runtimeServerOnly",
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/hono": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
Expand Down
Loading
Loading