Skip to content

Commit 8043e9a

Browse files
feat(server)\!: drop McpHttpHandler.node; ship toNodeHandler(handler) in @modelcontextprotocol/node (#2331)
1 parent 5f18fcd commit 8043e9a

28 files changed

Lines changed: 796 additions & 403 deletions

File tree

.changeset/create-mcp-handler.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
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,
66
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
7-
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?)`
8-
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
7+
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
8+
`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
99
`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
1010
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.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@modelcontextprotocol/server': major
3+
'@modelcontextprotocol/node': minor
4+
---
5+
6+
`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
7+
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`
8+
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
9+
`@modelcontextprotocol/server` to `@modelcontextprotocol/node`.

docs/migration-SKILL.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,9 @@ New in 2.0 — v1 has no equivalent API. How v1 Streamable HTTP hosting maps ont
610610
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,
611611
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.
612612
- `legacyStatelessFallback(factory)` is exported as a standalone fetch-shaped handler producing the same stateless legacy serving as the default.
613+
- 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`:
614+
`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
615+
the `toNodeHandler` wrap and add the `@modelcontextprotocol/node` import. `NodeIncomingMessageLike` / `NodeServerResponseLike` are now exported from `@modelcontextprotocol/node`, not `@modelcontextprotocol/server`.
613616

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

docs/migration.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,11 +1081,15 @@ const handler = createMcpHandler(ctx => {
10811081
});
10821082

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

1091+
`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.
1092+
10891093
How the `legacy` option behaves:
10901094

10911095
- **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`.
@@ -1121,9 +1125,9 @@ The optional `responseMode` controls how modern request exchanges are answered:
11211125
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
11221126
rejected requests for logging.
11231127
1124-
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
1125-
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
1126-
(`const { fetch } = handler`).
1128+
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
1129+
`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
1130+
passed around (`const { fetch } = handler`).
11271131
11281132
### `Mcp-Param-*` request-metadata headers (SEP-2243, 2026-07-28 draft)
11291133

examples/bearer-auth/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"dependencies": {
1010
"@modelcontextprotocol/client": "workspace:*",
1111
"@modelcontextprotocol/express": "workspace:*",
12+
"@modelcontextprotocol/node": "workspace:*",
1213
"@modelcontextprotocol/server": "workspace:*",
1314
"zod": "catalog:runtimeShared"
1415
},

examples/bearer-auth/server.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
mcpAuthMetadataRouter,
1515
requireBearerAuth
1616
} from '@modelcontextprotocol/express';
17+
import { toNodeHandler } from '@modelcontextprotocol/node';
1718
import type { AuthInfo, OAuthMetadata } from '@modelcontextprotocol/server';
1819
import { createMcpHandler, McpServer, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
1920
import * as z from 'zod/v4';
@@ -61,9 +62,10 @@ const auth = requireBearerAuth({
6162
requiredScopes: ['mcp'],
6263
resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(mcpServerUrl)
6364
});
64-
// `requireBearerAuth` sets `req.auth`; `handler.node` reads it and passes it
65+
// `requireBearerAuth` sets `req.auth`; `toNodeHandler` reads it and passes it
6566
// to the factory as `ctx.authInfo`.
66-
app.all('/mcp', auth, (req, res) => void handler.node(req, res, req.body));
67+
const node = toNodeHandler(handler);
68+
app.all('/mcp', auth, (req, res) => void node(req, res, req.body));
6769

6870
app.listen(PORT, () => {
6971
console.error(`bearer-auth example server on http://127.0.0.1:${PORT}/mcp`);

examples/harness.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import path from 'node:path';
2323
import type { ClientOptions } from '@modelcontextprotocol/client';
2424
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
2525
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
26-
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
26+
import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node';
2727
import type { McpServerFactory } from '@modelcontextprotocol/server';
2828
import { createMcpHandler, isInitializeRequest, isLegacyRequest } from '@modelcontextprotocol/server';
2929
import { serveStdio } from '@modelcontextprotocol/server/stdio';
@@ -55,6 +55,7 @@ export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000)
5555
legacy: 'reject',
5656
onerror: e => console.error('[server] handler error:', e.message)
5757
});
58+
const modernNode = toNodeHandler(modern);
5859

5960
// --- legacy (2025): sessionful streamable HTTP — the deployable shape ---
6061
const sessions = new Map<string, NodeStreamableHTTPServerTransport>();
@@ -104,7 +105,7 @@ export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000)
104105
method: req.method,
105106
headers: req.headers as Record<string, string>
106107
});
107-
await ((await isLegacyRequest(probe, body)) ? handleLegacy(req, res, body) : modern.node(req, res, body));
108+
await ((await isLegacyRequest(probe, body)) ? handleLegacy(req, res, body) : modernNode(req, res, body));
108109
})().catch(error => {
109110
console.error('[server] request error:', error instanceof Error ? error.message : error);
110111
if (!res.headersSent) res.writeHead(500).end();

examples/json-response/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
},
99
"dependencies": {
1010
"@modelcontextprotocol/client": "workspace:*",
11+
"@modelcontextprotocol/node": "workspace:*",
1112
"@modelcontextprotocol/server": "workspace:*",
1213
"zod": "catalog:runtimeShared"
1314
},

examples/json-response/server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77
import { createServer } from 'node:http';
88

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

@@ -25,6 +26,6 @@ const handler = createMcpHandler(
2526
const argv = process.argv.slice(2);
2627
const portIdx = argv.indexOf('--port');
2728
const port = portIdx === -1 ? 3000 : Number(argv[portIdx + 1]);
28-
createServer((req, res) => void handler.node(req, res)).listen(port, () => {
29+
createServer(toNodeHandler(handler)).listen(port, () => {
2930
console.error(`json-response example server listening on http://127.0.0.1:${port}/`);
3031
});

examples/legacy-routing/server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import { randomUUID } from 'node:crypto';
1414

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

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

5960
const app = createMcpExpressApp();
6061
// Browser-client CORS recipe: expose the response headers a browser-based MCP
@@ -77,7 +78,7 @@ app.post('/mcp', async (req: Request, res: Response) => {
7778
method: req.method,
7879
headers: req.headers as Record<string, string>
7980
});
80-
await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modern.node(req, res, req.body));
81+
await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modernNode(req, res, req.body));
8182
});
8283
// GET (standalone SSE stream / reconnect with Last-Event-ID) and DELETE
8384
// (explicit session termination per the MCP spec) are sessionful-2025-only —

0 commit comments

Comments
 (0)