Skip to content
Open
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
4 changes: 2 additions & 2 deletions .changeset/create-mcp-handler.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Add `createMcpHandler(factory, { legacy?, onerror?, responseMode? })`, an HTTP entry point that serves the 2026-07-28 draft revision per request: each envelope-carrying request is classified once, served on a fresh instance from the factory bound to the claimed revision,
and answered with a JSON body or a lazily-upgraded SSE stream. 2025-era serving is selected with the `legacy` option (`'stateless'` — the default — for per-request stateless serving via the existing streamable HTTP transport, `'reject'` for a modern-only strict endpoint
that answers 2025-era requests with the unsupported-protocol-version error naming its supported revisions). The handler exposes a web-standard `fetch(request, { authInfo?, parsedBody? })` face and a duck-typed `node(req, res, parsedBody?)`
face, plus `close()` for tearing down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the same stateless legacy serving as a standalone fetch-shaped handler), the `PerRequestHTTPServerTransport` single-exchange transport and the
that answers 2025-era requests with the unsupported-protocol-version error naming its supported revisions). The handler is a web-standard `{ fetch, close, notify }` object: `fetch(request, { authInfo?, parsedBody? })` is the only request face (Node frameworks wrap it with
`toNodeHandler(handler)` from `@modelcontextprotocol/node`), and `close()` tears down in-flight modern exchanges. Also exported: `legacyStatelessFallback` (the same stateless legacy serving as a standalone fetch-shaped handler), the `PerRequestHTTPServerTransport` single-exchange transport and the
`classifyInboundRequest` classifier for hand-wired compositions, and the supporting types. `responseMode: 'json'` never streams and drops mid-call notifications (progress, logging and other related messages emitted before the result); listen-class subscription streams are
always served over SSE. The entry performs no Origin/Host validation (use the middleware packages) and no token verification — `authInfo` is pass-through and never derived from request headers.
9 changes: 9 additions & 0 deletions .changeset/handler-drop-node-face.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@modelcontextprotocol/server': major
'@modelcontextprotocol/node': minor
---

`createMcpHandler` now returns a web-standards-only `{ fetch, close, notify, bus }` handler — the shape Workers/Bun/Deno expect from `export default`. The duck-typed `.node(req, res, parsedBody?)` face is removed; Node frameworks (Express, Fastify, plain `node:http`) wrap the
handler once with the new `toNodeHandler(handler, { onerror? })` exported from `@modelcontextprotocol/node`, which converts the Node request to a web-standard `Request`, calls `handler.fetch`, and writes the `Response` back honoring write backpressure. The optional `onerror`
receives the adapter-level error fallback (request conversion / `handler.fetch` throw) before the `500` response is written, restoring observability parity with the removed `.node` face. `NodeIncomingMessageLike` and `NodeServerResponseLike` move from
`@modelcontextprotocol/server` to `@modelcontextprotocol/node`.
3 changes: 3 additions & 0 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,9 @@ New in 2.0 — v1 has no equivalent API. How v1 Streamable HTTP hosting maps ont
GET/DELETE session operations, all-legacy batches, posted responses, non-JSON bodies). Returns `false` for envelope-claiming requests AND for malformed/incomplete modern claims (the modern path answers those with `-32602`/`-32001`) — route `false` traffic to the modern handler,
never to a legacy handler. The predicate classifies a clone (the body stays readable); pass the parsed body as the second argument when the stream was already consumed.
- `legacyStatelessFallback(factory)` is exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default.
- The handler is web-standards-only (`{ fetch, close, notify }`). On Workers / Bun / Deno, `export default handler` works directly. On Node frameworks (Express, Fastify, plain `node:http`), wrap once with `toNodeHandler(handler)` from `@modelcontextprotocol/node`:
`app.all('/mcp', toNodeHandler(handler))`, or `const node = toNodeHandler(handler); app.all('/mcp', (req, res) => void node(req, res, req.body))` when a body parser already consumed the stream. Earlier 2.x alphas exposed this as `handler.node(req, res, req.body)` — replace with
the `toNodeHandler` wrap and add the `@modelcontextprotocol/node` import. `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`.

### Server (stdio / long-lived connections)

Expand Down
16 changes: 10 additions & 6 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -1081,11 +1081,15 @@ const handler = createMcpHandler(ctx => {
});

// Web-standard runtimes (Cloudflare Workers, Deno, Bun, Hono):
// handler.fetch(request)
// Node frameworks (Express, Fastify, plain node:http):
// handler.node(req, res, req.body)
// export default handler; // or handler.fetch(request)
// Node frameworks (Express, Fastify, plain node:http) — wrap once:
// import { toNodeHandler } from '@modelcontextprotocol/node';
// const node = toNodeHandler(handler, { onerror: console.error });
// app.all('/mcp', (req, res) => void node(req, res, req.body));
```

`toNodeHandler` accepts an optional `{ onerror }` callback that receives the adapter-level error fallback (request conversion / `handler.fetch` throw) before the `500` response is written — entry-internal failures continue to surface through the entry's own `onerror` option.

How the `legacy` option behaves:

- **omitted / `legacy: 'stateless'`** (the default) — 2025-era (non-envelope) traffic is served per request through the established stateless idiom: a fresh instance from the same factory and a streamable HTTP transport constructed with only `sessionIdGenerator: undefined`.
Expand Down Expand Up @@ -1121,9 +1125,9 @@ The optional `responseMode` controls how modern request exchanges are answered:
DROPS mid-call notifications** (progress, logging, and any other related message emitted before the result) — only the terminal result is delivered. Subscription (listen-class) streams are always served over SSE regardless of the setting. `onerror` receives out-of-band errors and
rejected requests for logging.

The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` / attached as `req.auth` on the Node face is forwarded to handlers as-is and never derived from request
headers. Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; the handler faces are bound properties, so they can be detached and passed around
(`const { fetch } = handler`).
The entry performs no Origin/Host validation (see the origin-validation middleware below) and no token verification: `authInfo` passed to `handler.fetch(request, { authInfo })` is forwarded to handlers as-is and never derived from request headers (the Node adapter forwards
`req.auth` to that same option). Power users who want to compose routing themselves can use the exported `isLegacyRequest`, `classifyInboundRequest` and `PerRequestHTTPServerTransport` building blocks directly; `handler.fetch` is a bound property, so it can be detached and
passed around (`const { fetch } = handler`).

### `Mcp-Param-*` request-metadata headers (SEP-2243, 2026-07-28 draft)

Expand Down
1 change: 1 addition & 0 deletions examples/bearer-auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"dependencies": {
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/express": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
Expand Down
6 changes: 4 additions & 2 deletions examples/bearer-auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
mcpAuthMetadataRouter,
requireBearerAuth
} from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';
Expand Down Expand Up @@ -61,9 +62,10 @@ const auth = requireBearerAuth({
requiredScopes: ['mcp'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it
// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it
// to the factory as `ctx.authInfo`.
app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body));
const node = toNodeHandler(handler);
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));

app.listen(PORT, () => {
console.error(`bearer-auth example server on http://127.0.0.1:${PORT}/mcp`);
Expand Down
5 changes: 3 additions & 2 deletions examples/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import path from 'node:path';
import type { ClientOptions } from '@modelcontextprotocol/client';
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node';
import type { McpServerFactory } from '@modelcontextprotocol/server';
import { createMcpHandler, isInitializeRequest, isLegacyRequest } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
Expand Down Expand Up @@ -55,6 +55,7 @@ export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000)
legacy: 'reject',
onerror: e => console.error('[server] handler error:', e.message)
});
const modernNode = toNodeHandler(modern);

// --- legacy (2025): sessionful streamable HTTP — the deployable shape ---
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
Expand Down Expand Up @@ -104,7 +105,7 @@ export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000)
method: req.method,
headers: req.headers as Record<string, string>
});
await ((await isLegacyRequest(probe, body)) ? handleLegacy(req, res, body) : modern.node(req, res, body));
await ((await isLegacyRequest(probe, body)) ? handleLegacy(req, res, body) : modernNode(req, res, body));
})().catch(error => {
console.error('[server] request error:', error instanceof Error ? error.message : error);
if (!res.headersSent) res.writeHead(500).end();
Expand Down
1 change: 1 addition & 0 deletions examples/json-response/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"dependencies": {
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
Expand Down
3 changes: 2 additions & 1 deletion examples/json-response/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/
import { createServer } from 'node:http';

import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

Expand All @@ -25,6 +26,6 @@ const handler = createMcpHandler(
const argv = process.argv.slice(2);
const portIdx = argv.indexOf('--port');
const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]);
createServer((req, res) => void handler.node(req, res)).listen(port, () => {
createServer(toNodeHandler(handler)).listen(port, () => {
console.error(`json-response example server listening on http://127.0.0.1:${port}/`);
});
5 changes: 3 additions & 2 deletions examples/legacy-routing/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { randomUUID } from 'node:crypto';

import { createMcpExpressApp } from '@modelcontextprotocol/express';
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node';
import type { McpRequestContext } from '@modelcontextprotocol/server';
import { createMcpHandler, isInitializeRequest, isLegacyRequest, McpServer } from '@modelcontextprotocol/server';
import cors from 'cors';
Expand Down Expand Up @@ -55,6 +55,7 @@ const handleLegacy = async (req: Request, res: Response) => {

// --- the strict modern entry alongside it ---
const modern = createMcpHandler((ctx: McpRequestContext) => buildServer(ctx.era), { legacy: 'reject' });
const modernNode = toNodeHandler(modern);

const app = createMcpExpressApp();
// Browser-client CORS recipe: expose the response headers a browser-based MCP
Expand All @@ -77,7 +78,7 @@ app.post('/mcp', async (req: Request, res: Response) => {
method: req.method,
headers: req.headers as Record<string, string>
});
await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modern.node(req, res, req.body));
await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modernNode(req, res, req.body));
});
// GET (standalone SSE stream / reconnect with Last-Event-ID) and DELETE
// (explicit session termination per the MCP spec) are sessionful-2025-only —
Expand Down
1 change: 1 addition & 0 deletions examples/oauth-client-credentials/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/express": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
Expand Down
6 changes: 4 additions & 2 deletions examples/oauth-client-credentials/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
mcpAuthMetadataRouter,
requireBearerAuth
} from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

Expand Down Expand Up @@ -69,8 +70,9 @@ const auth = requireBearerAuth({
requiredScopes: ['mcp:tools'],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it
// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it
// to the factory as `ctx.authInfo`.
app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body));
const node = toNodeHandler(handler);
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));

app.listen(PORT, () => console.error(`[resource-server] MCP on ${mcpServerUrl.href}`));
1 change: 1 addition & 0 deletions examples/oauth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"@mcp-examples/shared": "workspace:*",
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/express": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"cors": "catalog:runtimeServerOnly",
"open": "^11.0.0",
Expand Down
6 changes: 4 additions & 2 deletions examples/oauth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/
import { createProtectedResourceMetadataRouter, demoTokenVerifier, setupAuthServer } from '@mcp-examples/shared';
import { createMcpExpressApp, getOAuthProtectedResourceMetadataUrl, requireBearerAuth } from '@modelcontextprotocol/express';
import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import cors from 'cors';
import * as z from 'zod/v4';
Expand Down Expand Up @@ -72,9 +73,10 @@ const auth = requireBearerAuth({
requiredScopes: [],
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
});
// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it
// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it
// to the factory as `ctx.authInfo`.
app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body));
const node = toNodeHandler(handler);
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));

app.listen(MCP_PORT, () => {
console.error(`OAuth-protected MCP server listening on ${mcpServerUrl.href}`);
Expand Down
1 change: 1 addition & 0 deletions examples/stateless-legacy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"dependencies": {
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
Expand Down
3 changes: 2 additions & 1 deletion examples/stateless-legacy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/
import { createServer } from 'node:http';

import { toNodeHandler } from '@modelcontextprotocol/node';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import * as z from 'zod/v4';

Expand All @@ -25,6 +26,6 @@ const handler = createMcpHandler(() => {
const argv = process.argv.slice(2);
const portIdx = argv.indexOf('--port');
const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]);
createServer((req, res) => void handler.node(req, res)).listen(port, () => {
createServer(toNodeHandler(handler)).listen(port, () => {
console.error(`stateless-legacy example server listening on http://127.0.0.1:${port}/`);
});
1 change: 1 addition & 0 deletions examples/subscriptions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"dependencies": {
"@modelcontextprotocol/client": "workspace:*",
"@modelcontextprotocol/node": "workspace:*",
"@modelcontextprotocol/server": "workspace:*",
"zod": "catalog:runtimeShared"
},
Expand Down
3 changes: 2 additions & 1 deletion examples/subscriptions/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/
import { createServer } from 'node:http';

import { toNodeHandler } from '@modelcontextprotocol/node';
import type { RegisteredTool, ServerEventBus, ServerNotifier } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { serveStdio } from '@modelcontextprotocol/server/stdio';
Expand Down Expand Up @@ -78,7 +79,7 @@ if (argv.includes('--http')) {
const notify: ServerNotifier = handler.notify;
void bus; // (the typed publish facade `notify` wraps `bus.publish`)
publish = () => notify.toolsChanged();
createServer((req, res) => void handler.node(req, res)).listen(port, () => {
createServer(toNodeHandler(handler)).listen(port, () => {
console.error(`[server] listening on http://127.0.0.1:${port}/ (HTTP)`);
});
} else {
Expand Down
3 changes: 3 additions & 0 deletions packages/middleware/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ npm install @modelcontextprotocol/server @modelcontextprotocol/node

- `NodeStreamableHTTPServerTransport`
- `StreamableHTTPServerTransportOptions` (type alias for `WebStandardStreamableHTTPServerTransportOptions`)
- `toNodeHandler(handler, opts?)` — adapt a web-standard `{ fetch }` MCP handler to a Node `(req, res, parsedBody?)` handler
- `ToNodeHandlerOptions`, `FetchLikeMcpHandler`, `NodeMcpRequestHandler` (types for `toNodeHandler`)
- `NodeIncomingMessageLike`, `NodeServerResponseLike` (structural Node request/response shapes)

## Usage

Expand Down
8 changes: 8 additions & 0 deletions packages/middleware/node/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
export * from './middleware/hostHeaderValidation.js';
export * from './middleware/originValidation.js';
export * from './streamableHttp.js';
export type {
FetchLikeMcpHandler,
NodeIncomingMessageLike,
NodeMcpRequestHandler,
NodeServerResponseLike,
ToNodeHandlerOptions
} from './toNodeHandler.js';
export { toNodeHandler } from './toNodeHandler.js';
Loading
Loading