From 8a5395569711c98af39123e2a77436383d12836e Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 22:41:50 +0000 Subject: [PATCH 1/2] fix(server): ctx.mcpReq.log emits request-related so per-request hosting delivers it; 2026-era requests filter by _meta.logLevel --- .changeset/server-ctx-log-request-related.md | 5 +++ docs/migration.md | 4 +++ packages/server/src/server/server.ts | 35 +++++++++++++++++++- test/e2e/requirements.ts | 4 +-- test/e2e/scenarios/handler-context.test.ts | 5 ++- 5 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 .changeset/server-ctx-log-request-related.md diff --git a/.changeset/server-ctx-log-request-related.md b/.changeset/server-ctx-log-request-related.md new file mode 100644 index 0000000000..d84c533426 --- /dev/null +++ b/.changeset/server-ctx-log-request-related.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`ctx.mcpReq.log()` now emits its `notifications/message` notification request-related (like progress and `ctx.mcpReq.notify`), so handler-emitted log messages are delivered when the server is hosted per request via `createMcpHandler` instead of being silently dropped. On a 2026-07-28 request the level filter consults the per-request `_meta` `io.modelcontextprotocol/logLevel` key (the modern equivalent of `logging/setLevel`); per the spec, an absent key means no `notifications/message` is sent for that request. Because the message now rides the in-flight exchange instead of the session-wide channel, a 2025-era stateful Streamable HTTP deployment that answers POSTs in JSON mode and delivers server notifications over the standalone GET stream will no longer receive handler-emitted log messages on that GET stream. The session-scoped `Server.sendLoggingMessage()` API is unchanged. diff --git a/docs/migration.md b/docs/migration.md index 668fc6dec4..d1b4dab3a8 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -1229,6 +1229,10 @@ On a 2026-07-28 Streamable HTTP connection, aborting an in-flight client request connection, and on stdio at any era, is unchanged and still sends `notifications/cancelled`. Custom `Transport` implementations that open one underlying request per outbound JSON-RPC request and honor `TransportSendOptions.requestSignal` may opt into the same routing by declaring `readonly hasPerRequestStream = true`. +### `ctx.mcpReq.log()` on 2026-07-28: per-request opt-in via the `_meta.logLevel` envelope key + +On a 2026-07-28 request, `ctx.mcpReq.log()` reads the per-request level filter from the `io.modelcontextprotocol/logLevel` `_meta` envelope key (the modern replacement for the `logging/setLevel` RPC, which is not a request method on that revision). When the key is **absent** the server emits no `notifications/message` for that request — absence is opt-out, not "no filter". The SDK `Client` does not auto-attach a `logLevel` key, so handler logs on a default 2026-era exchange are silently suppressed until the client opts in. The notification is now emitted request-related (it rides the in-flight exchange like progress and `ctx.mcpReq.notify`). 2025-era connections are unchanged: the session-scoped `logging/setLevel` filter applies as before, and an unset session level continues to mean no filter. + ### Multi round-trip requests (2026-07-28): write-once handlers and the client auto-fulfilment driver The 2026-07-28 revision removes the server→client JSON-RPC request channel: servers obtain client input (elicitation, sampling, roots) **in-band**, by answering `tools/call`, `prompts/get`, or `resources/read` with an `input_required` result that embeds the requests, and the diff --git a/packages/server/src/server/server.ts b/packages/server/src/server/server.ts index f5eccb2dba..a59258a973 100644 --- a/packages/server/src/server/server.ts +++ b/packages/server/src/server/server.ts @@ -47,6 +47,7 @@ import { isModernProtocolVersion, LATEST_PROTOCOL_VERSION, legacyProtocolVersions, + LOG_LEVEL_META_KEY, LoggingLevelSchema, mergeCapabilities, missingClientCapabilities, @@ -304,7 +305,39 @@ export class Server extends Protocol { // Deprecated as of protocol version 2026-07-28 (SEP-2577): `log` and // `requestSampling` remain functional during the deprecation window // (at least twelve months). See ServerContext for migration guidance. - log: (level, data, logger) => this.sendLoggingMessage({ level, data, logger }), + log: (level, data, logger) => { + if (!this._capabilities.logging) { + return Promise.resolve(); + } + // Level filter: on a 2026-era request the client declares its + // threshold per request via the `_meta.logLevel` envelope key + // (the modern equivalent of `logging/setLevel`, which is not a + // request method on that revision). The spec at 2026-07-28 + // says an absent key means the server MUST NOT send + // `notifications/message` for the request — so an absent key + // suppresses, it does not mean "send everything". On + // 2025-era connections the session-scoped level set via + // `logging/setLevel` applies exactly as before (an absent + // session level there continues to mean no filter). + let threshold: LoggingLevel | undefined; + if (this._servedModernEra()) { + threshold = ctx.mcpReq.envelope?.[LOG_LEVEL_META_KEY] as LoggingLevel | undefined; + if (threshold === undefined) { + return Promise.resolve(); + } + } else { + threshold = this._loggingLevels.get(ctx.sessionId) ?? this._loggingLevels.get(undefined); + } + if (threshold !== undefined && this.LOG_LEVEL_SEVERITY.get(level)! < this.LOG_LEVEL_SEVERITY.get(threshold)!) { + return Promise.resolve(); + } + // Emit request-related (like progress and `ctx.mcpReq.notify`) + // so the notification rides the in-flight exchange. Without the + // related-request stamp, per-request hosting (`createMcpHandler`, + // either era) silently drops the message because it has no + // session-wide stream to deliver it on. + return ctx.mcpReq.notify({ method: 'notifications/message', params: { level, data, logger } }); + }, elicitInput: (params, options) => this.elicitInput(params, options), requestSampling: (params, options) => this.createMessage(params, options) }, diff --git a/test/e2e/requirements.ts b/test/e2e/requirements.ts index 291ff22575..2c0d89cdf3 100644 --- a/test/e2e/requirements.ts +++ b/test/e2e/requirements.ts @@ -2384,8 +2384,8 @@ export const REQUIREMENTS: Record = { source: 'sdk', behavior: 'ctx.mcpReq.log() inside a registered tool handler emits a notifications/message logging notification that the client receives while the call is in flight.', - transports: STATEFUL_TRANSPORTS, - note: 'Stateless hosting creates a fresh server per request and has no standalone GET stream, so there is no server→client channel to deliver/observe these.' + transports: [...STATEFUL_TRANSPORTS, 'entryStateless', 'entryModern'], + note: 'Emitted request-related, so on per-request hosting (createMcpHandler, either era) the notification rides the in-flight exchange like progress; the streamableHttpStateless arm has no per-request stream visible to the body and stays restricted.' }, 'mcpserver:context:elicit-from-handler': { source: 'sdk', diff --git a/test/e2e/scenarios/handler-context.test.ts b/test/e2e/scenarios/handler-context.test.ts index 81c5a776ba..f5624faf99 100644 --- a/test/e2e/scenarios/handler-context.test.ts +++ b/test/e2e/scenarios/handler-context.test.ts @@ -44,7 +44,10 @@ verifies('mcpserver:context:log-from-handler', async ({ transport }: TestArgs) = await using _ = await wire(transport, makeServer, client); - const inFlightCall = client.callTool({ name: 'emit-log', arguments: {} }); + // On a 2026-era request the spec says an absent `_meta.logLevel` envelope key means the server MUST NOT + // send notifications/message — so the entryModern arm needs the key set explicitly for the log to be + // emitted. Legacy-era arms ignore the key (the session-scoped level applies; absent → no filter). + const inFlightCall = client.callTool({ name: 'emit-log', arguments: {}, _meta: { 'io.modelcontextprotocol/logLevel': 'debug' } }); try { // The handler is parked on the gate, so the tools/call request is still in flight when the log arrives. await vi.waitFor(() => expect(logs).toHaveLength(1)); From 7327fb9d9db9cb8cad4457383084ef26460a91e9 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Thu, 18 Jun 2026 22:51:35 +0000 Subject: [PATCH 2/2] feat(server)\!: drop McpHttpHandler.node face; ship toNodeHandler(handler) in @modelcontextprotocol/node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createMcpHandler now returns the web-standards-idiomatic { fetch, close, notify, bus } shape — what 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 toNodeHandler(handler) from @modelcontextprotocol/node, which converts the Node request to a web-standard Request, calls handler.fetch, and writes the Response back honoring write backpressure. NodeIncomingMessageLike / NodeServerResponseLike move from @modelcontextprotocol/server to @modelcontextprotocol/node. The Node-conversion helpers and the .node-face unit tests move with them (toNodeHandler.test.ts). Consumers updated: examples/server (dualEraStreamableHttp, multiRoundTrip), test/integration createMcpHandler over real HTTP, test/conformance everythingServer. BREAKING CHANGE: McpHttpHandler.node is removed. Use toNodeHandler(handler) from @modelcontextprotocol/node. --- .changeset/create-mcp-handler.md | 4 +- .changeset/handler-drop-node-face.md | 9 + docs/migration-SKILL.md | 3 + docs/migration.md | 16 +- examples/bearer-auth/package.json | 1 + examples/bearer-auth/server.ts | 6 +- examples/harness.ts | 5 +- examples/json-response/package.json | 1 + examples/json-response/server.ts | 3 +- examples/legacy-routing/server.ts | 5 +- .../oauth-client-credentials/package.json | 1 + examples/oauth-client-credentials/server.ts | 6 +- examples/oauth/package.json | 1 + examples/oauth/server.ts | 6 +- examples/stateless-legacy/package.json | 1 + examples/stateless-legacy/server.ts | 3 +- examples/subscriptions/package.json | 1 + examples/subscriptions/server.ts | 3 +- packages/middleware/node/README.md | 3 + packages/middleware/node/src/index.ts | 8 + packages/middleware/node/src/toNodeHandler.ts | 262 ++++++++++++++ .../node/test/toNodeHandler.test.ts | 331 ++++++++++++++++++ packages/server/src/index.ts | 4 +- .../server/src/server/createMcpHandler.ts | 193 +--------- .../test/server/createMcpHandler.test.ts | 201 +---------- pnpm-lock.yaml | 18 + test/conformance/src/everythingServer.ts | 5 +- .../test/server/createMcpHandler.test.ts | 5 +- 28 files changed, 702 insertions(+), 403 deletions(-) create mode 100644 .changeset/handler-drop-node-face.md create mode 100644 packages/middleware/node/src/toNodeHandler.ts create mode 100644 packages/middleware/node/test/toNodeHandler.test.ts diff --git a/.changeset/create-mcp-handler.md b/.changeset/create-mcp-handler.md index 35eeccada2..238593202d 100644 --- a/.changeset/create-mcp-handler.md +++ b/.changeset/create-mcp-handler.md @@ -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. diff --git a/.changeset/handler-drop-node-face.md b/.changeset/handler-drop-node-face.md new file mode 100644 index 0000000000..6442e14148 --- /dev/null +++ b/.changeset/handler-drop-node-face.md @@ -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`. diff --git a/docs/migration-SKILL.md b/docs/migration-SKILL.md index 14eb419f57..53979aa508 100644 --- a/docs/migration-SKILL.md +++ b/docs/migration-SKILL.md @@ -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) diff --git a/docs/migration.md b/docs/migration.md index d1b4dab3a8..ce292f18da 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -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`. @@ -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) diff --git a/examples/bearer-auth/package.json b/examples/bearer-auth/package.json index 67e0a83eec..56cc25e165 100644 --- a/examples/bearer-auth/package.json +++ b/examples/bearer-auth/package.json @@ -9,6 +9,7 @@ "dependencies": { "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/bearer-auth/server.ts b/examples/bearer-auth/server.ts index 00e8d616ec..543e5dfc11 100644 --- a/examples/bearer-auth/server.ts +++ b/examples/bearer-auth/server.ts @@ -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'; @@ -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`); diff --git a/examples/harness.ts b/examples/harness.ts index c32db17911..1a2d216a1f 100644 --- a/examples/harness.ts +++ b/examples/harness.ts @@ -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'; @@ -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(); @@ -104,7 +105,7 @@ export function runServerFromArgs(factory: McpServerFactory, defaultPort = 3000) method: req.method, headers: req.headers as Record }); - 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(); diff --git a/examples/json-response/package.json b/examples/json-response/package.json index 242ccce148..18837eef62 100644 --- a/examples/json-response/package.json +++ b/examples/json-response/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/json-response/server.ts b/examples/json-response/server.ts index a5c82f883d..bb5cad2be8 100644 --- a/examples/json-response/server.ts +++ b/examples/json-response/server.ts @@ -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'; @@ -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}/`); }); diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts index 2e9d5a583f..dae1c46c7d 100644 --- a/examples/legacy-routing/server.ts +++ b/examples/legacy-routing/server.ts @@ -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'; @@ -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 @@ -77,7 +78,7 @@ app.post('/mcp', async (req: Request, res: Response) => { method: req.method, headers: req.headers as Record }); - 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 — diff --git a/examples/oauth-client-credentials/package.json b/examples/oauth-client-credentials/package.json index c0ee598b83..a9cf773f33 100644 --- a/examples/oauth-client-credentials/package.json +++ b/examples/oauth-client-credentials/package.json @@ -10,6 +10,7 @@ "@mcp-examples/shared": "workspace:*", "@modelcontextprotocol/client": "workspace:*", "@modelcontextprotocol/express": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/oauth-client-credentials/server.ts b/examples/oauth-client-credentials/server.ts index f125f4cd2b..88ec54ebc1 100644 --- a/examples/oauth-client-credentials/server.ts +++ b/examples/oauth-client-credentials/server.ts @@ -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'; @@ -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}`)); diff --git a/examples/oauth/package.json b/examples/oauth/package.json index ab1fc2ea1f..3cae607b54 100644 --- a/examples/oauth/package.json +++ b/examples/oauth/package.json @@ -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", diff --git a/examples/oauth/server.ts b/examples/oauth/server.ts index cf380a39f2..d67f2c6e5c 100644 --- a/examples/oauth/server.ts +++ b/examples/oauth/server.ts @@ -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'; @@ -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}`); diff --git a/examples/stateless-legacy/package.json b/examples/stateless-legacy/package.json index 3066b195ec..2f5a75c72e 100644 --- a/examples/stateless-legacy/package.json +++ b/examples/stateless-legacy/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/stateless-legacy/server.ts b/examples/stateless-legacy/server.ts index fce9094076..2183fff03e 100644 --- a/examples/stateless-legacy/server.ts +++ b/examples/stateless-legacy/server.ts @@ -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'; @@ -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}/`); }); diff --git a/examples/subscriptions/package.json b/examples/subscriptions/package.json index 8e1fda7df6..0e0517902d 100644 --- a/examples/subscriptions/package.json +++ b/examples/subscriptions/package.json @@ -8,6 +8,7 @@ }, "dependencies": { "@modelcontextprotocol/client": "workspace:*", + "@modelcontextprotocol/node": "workspace:*", "@modelcontextprotocol/server": "workspace:*", "zod": "catalog:runtimeShared" }, diff --git a/examples/subscriptions/server.ts b/examples/subscriptions/server.ts index fa85321421..e2bc5cb23a 100644 --- a/examples/subscriptions/server.ts +++ b/examples/subscriptions/server.ts @@ -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'; @@ -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 { diff --git a/packages/middleware/node/README.md b/packages/middleware/node/README.md index fe10c9f2ae..15a8e8b9c1 100644 --- a/packages/middleware/node/README.md +++ b/packages/middleware/node/README.md @@ -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 diff --git a/packages/middleware/node/src/index.ts b/packages/middleware/node/src/index.ts index 8426de0757..9cff39c82c 100644 --- a/packages/middleware/node/src/index.ts +++ b/packages/middleware/node/src/index.ts @@ -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'; diff --git a/packages/middleware/node/src/toNodeHandler.ts b/packages/middleware/node/src/toNodeHandler.ts new file mode 100644 index 0000000000..0b8a7f75b8 --- /dev/null +++ b/packages/middleware/node/src/toNodeHandler.ts @@ -0,0 +1,262 @@ +/** + * `toNodeHandler` — adapt the web-standard {@linkcode McpHttpHandler} returned + * by `createMcpHandler` to a Node.js `(req, res, parsedBody?)` request handler. + * + * The handler itself is web-standards-only (`{ fetch, close, notify, bus }` — the + * shape Workers/Bun/Deno expect from `export default`). Node frameworks + * (Express, Fastify, plain `node:http`) wrap it once with this helper: + * + * ```ts + * import { createMcpHandler } from '@modelcontextprotocol/server'; + * import { toNodeHandler } from '@modelcontextprotocol/node'; + * + * const handler = createMcpHandler(factory); + * app.all('/mcp', toNodeHandler(handler)); + * // or, when a body parser already consumed the stream: + * const node = toNodeHandler(handler); + * app.all('/mcp', (req, res) => void node(req, res, req.body)); + * ``` + * + * The Node request/response shapes are duck-typed (kept structural so this + * module stays free of `node:` imports); the conversion reads `req.auth` + * (validated authentication info attached by upstream middleware) and forwards + * it as the handler's pass-through `authInfo`. + */ +import type { AuthInfo, McpHandlerRequestOptions } from '@modelcontextprotocol/server'; + +/** + * Minimal duck-typed shape of a Node.js `IncomingMessage` accepted by + * {@linkcode toNodeHandler}. Kept structural so the adapter stays free of + * `node:` imports. + */ +export interface NodeIncomingMessageLike extends AsyncIterable { + method?: string; + url?: string; + headers: Record; + /** Validated authentication info attached by upstream middleware (pass-through). */ + auth?: AuthInfo; +} + +/** Minimal duck-typed shape of a Node.js `ServerResponse` accepted by {@linkcode toNodeHandler}. */ +export interface NodeServerResponseLike { + writeHead(statusCode: number, headers?: Record): unknown; + write(chunk: string | Uint8Array): unknown; + end(chunk?: string | Uint8Array): unknown; + on(event: string, listener: (...args: unknown[]) => void): unknown; +} + +/** + * The web-standard fetch face of an `McpHttpHandler` (or any + * fetch-shaped MCP handler) — the only surface {@linkcode toNodeHandler} + * touches. Accepting the face structurally keeps the adapter usable with + * hand-wired compositions that route over `isLegacyRequest` and produce a + * `Response` directly. + */ +export interface FetchLikeMcpHandler { + fetch: (request: Request, options?: McpHandlerRequestOptions) => Promise; +} + +/** + * A Node.js `(req, res, parsedBody?)` request handler produced by + * {@linkcode toNodeHandler}. The third argument is an optional pre-parsed body + * (`req.body` from `express.json()`); a function third argument (Express's + * `next` when the handler is mounted as middleware) is ignored. + */ +export type NodeMcpRequestHandler = (req: NodeIncomingMessageLike, res: NodeServerResponseLike, parsedBody?: unknown) => Promise; + +/** Options for {@linkcode toNodeHandler}. */ +export interface ToNodeHandlerOptions { + /** + * Called when the adapter answers `500` because request conversion or + * `handler.fetch` itself threw (e.g. a closed handler). Restores the + * observability the removed `.node` face had via the entry's own + * `onerror` — entry-internal failures are still reported through + * `handler.fetch` and surface via the entry's `onerror` option as before. + */ + onerror?: (error: Error) => void; +} + +/** + * Adapts a web-standard MCP handler (`handler.fetch`) to a Node.js + * `(req, res, parsedBody?)` request handler. The returned function converts the + * Node request to a web-standard `Request`, calls `handler.fetch`, then writes + * the `Response` back to `res` (honoring write backpressure for streamed SSE + * responses). + * + * `req.auth` is forwarded as the handler's pass-through `authInfo`. A function + * third argument (Express's `next`) is ignored, never treated as a body. + * + * Pass `{ onerror }` to observe the adapter-level error fallback (request + * conversion / `handler.fetch` throw) before the `500` response is written. + */ +export function toNodeHandler(handler: FetchLikeMcpHandler, opts?: ToNodeHandlerOptions): NodeMcpRequestHandler { + return async (req, res, parsedBody) => { + // Express passes (req, res, next) when the handler is mounted as a + // middleware function; a function third argument is `next`, not a body. + if (typeof parsedBody === 'function') { + parsedBody = undefined; + } + + let finished = false; + const abort = new AbortController(); + res.on('close', () => { + if (!finished) { + abort.abort(); + } + }); + + let response: Response; + try { + const request = await nodeRequestToFetchRequest(req, parsedBody, abort.signal); + response = await handler.fetch(request, { + ...(req.auth !== undefined && { authInfo: req.auth }), + ...(parsedBody !== undefined && { parsedBody }) + }); + } catch (error) { + try { + opts?.onerror?.(error instanceof Error ? error : new Error(String(error))); + } catch { + // Reporting must never alter the response. + } + response = internalServerErrorResponse(echoableRequestId(parsedBody)); + } + + const headers: Record = {}; + for (const [name, value] of response.headers) { + headers[name] = value; + } + res.writeHead(response.status, headers); + if (response.body === null) { + finished = true; + res.end(); + return; + } + const reader = response.body.getReader(); + // Honor write backpressure: when write() reports a full buffer (Node's + // `false` return), wait for the response to drain before pulling the + // next chunk. A single listener resolves whichever wait is pending; a + // closed response also releases the wait so a vanished client cannot + // park the loop forever. + let drainResolve: (() => void) | undefined; + const releaseDrainWait = () => { + drainResolve?.(); + drainResolve = undefined; + }; + res.on('drain', releaseDrainWait); + res.on('close', releaseDrainWait); + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (value !== undefined && res.write(value) === false) { + await new Promise(resolve => { + drainResolve = resolve; + }); + } + } + } catch { + // The client went away while streaming; the abort signal already + // cancelled the exchange. + } + finished = true; + res.end(); + }; +} + +/* ------------------------------------------------------------------------ * + * Node request conversion (duck-typed; no node: imports) + * ------------------------------------------------------------------------ */ + +function singleHeaderValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + +async function nodeRequestToFetchRequest(req: NodeIncomingMessageLike, parsedBody: unknown, signal: AbortSignal): Promise { + const method = (req.method ?? 'GET').toUpperCase(); + const host = singleHeaderValue(req.headers['host']) ?? 'localhost'; + const url = `http://${host}${req.url ?? '/'}`; + + const headers = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + // HTTP/2 pseudo-headers (`:method`, `:path`, `:authority`, …) are + // connection metadata, not header fields — `Headers` rejects their + // names, so they are skipped rather than copied. + if (value === undefined || name.startsWith(':')) { + continue; + } + if (Array.isArray(value)) { + for (const item of value) { + headers.append(name, item); + } + } else { + headers.set(name, value); + } + } + + // The body is carried as text: MCP request bodies are JSON, and a string + // body keeps the constructed Request portable across runtime lib versions. + let body: string | undefined; + if (method !== 'GET' && method !== 'HEAD') { + if (parsedBody === undefined) { + const decoder = new TextDecoder(); + let collected = ''; + for await (const chunk of req) { + collected += typeof chunk === 'string' ? chunk : decoder.decode(chunk as Uint8Array, { stream: true }); + } + collected += decoder.decode(); + if (collected.length > 0) { + body = collected; + } + } else { + // The caller already consumed and parsed the Node stream (the + // documented `(req, res, req.body)` mounting behind + // `express.json()`), so the bytes cannot be re-read. Re-serialize + // the parsed value so consumers of the forwarded Request — anything + // on the legacy leg reading `request.json()`/`text()` instead of + // the pass-through parsedBody — still receive the body, and replace + // the entity headers that described the original raw bytes. + const serialized: string | undefined = JSON.stringify(parsedBody); + headers.delete('content-encoding'); + headers.delete('transfer-encoding'); + if (serialized === undefined) { + headers.delete('content-length'); + } else { + body = serialized; + headers.set('content-length', String(new TextEncoder().encode(serialized).byteLength)); + } + } + } + + return new Request(url, { + method, + headers, + signal, + ...(body !== undefined && { body }) + }); +} + +/* ------------------------------------------------------------------------ * + * Adapter-level error fallback (request conversion failure / closed handler) + * ------------------------------------------------------------------------ */ + +/** + * The JSON-RPC id to echo on an adapter-built error response: the body's `id` + * when the body is a single JSON-RPC request whose id is a string or number, + * `null` otherwise. + */ +function echoableRequestId(body: unknown): string | number | null { + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return null; + } + const { method, id } = body as { method?: unknown; id?: unknown }; + if (typeof method !== 'string') { + return null; + } + return typeof id === 'string' || typeof id === 'number' ? id : null; +} + +function internalServerErrorResponse(id: string | number | null): Response { + return Response.json({ jsonrpc: '2.0', error: { code: -32_603, message: 'Internal server error' }, id }, { status: 500 }); +} diff --git a/packages/middleware/node/test/toNodeHandler.test.ts b/packages/middleware/node/test/toNodeHandler.test.ts new file mode 100644 index 0000000000..029351d87a --- /dev/null +++ b/packages/middleware/node/test/toNodeHandler.test.ts @@ -0,0 +1,331 @@ +/** + * `toNodeHandler(handler)` — the Node `(req, res, parsedBody?)` adapter over a + * web-standard `McpHttpHandler`. Covers the request-stream conversion, the + * pre-parsed-body path (the documented `express.json()` mounting), `req.auth` + * pass-through, HTTP/2 pseudo-header skipping, and write-backpressure pacing. + * + * These tests previously lived in `@modelcontextprotocol/server`'s + * `createMcpHandler.test.ts` as the `.node` face tests; the body of the + * adapter is unchanged, only its home moved. + */ +import { Readable } from 'node:stream'; + +import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; +import type { McpRequestContext } from '@modelcontextprotocol/server'; +import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; +import { describe, expect, it, vi } from 'vitest'; +import * as z from 'zod/v4'; + +import type { NodeServerResponseLike } from '../src/toNodeHandler.js'; +import { toNodeHandler } from '../src/toNodeHandler.js'; + +const MODERN_REVISION = '2026-07-28'; + +const ENVELOPE = { + [PROTOCOL_VERSION_META_KEY]: MODERN_REVISION, + [CLIENT_INFO_META_KEY]: { name: 'node-adapter-test-client', version: '3.2.1' }, + [CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } } +}; + +function modernToolsCall(name: string, args: Record): unknown { + return { + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args, _meta: ENVELOPE } + }; +} + +/** SEP-2243 standard headers a conformant client derives from a modern body. */ +function bodyDerivedStandardHeaders(body: unknown): Record { + if (body === null || typeof body !== 'object' || Array.isArray(body)) return {}; + const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record } }; + if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {}; + const out: Record = {}; + if (typeof b.method === 'string') out['mcp-method'] = b.method; + const name = b.method === 'resources/read' ? b.params.uri : b.params.name; + if (typeof name === 'string') out['mcp-name'] = name; + return out; +} + +function testFactory(): { factory: (ctx: McpRequestContext) => McpServer; contexts: McpRequestContext[] } { + const contexts: McpRequestContext[] = []; + const factory = (ctx: McpRequestContext): McpServer => { + contexts.push(ctx); + const mcpServer = new McpServer({ name: 'node-adapter-test-server', version: '1.0.0' }); + mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({ + content: [{ type: 'text', text }] + })); + mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx2) => ({ + content: [{ type: 'text', text: ctx2.http?.authInfo?.clientId ?? 'anonymous' }] + })); + mcpServer.registerTool('progress-then-echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx2) => { + await ctx2.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'tok', progress: 1 } }); + return { content: [{ type: 'text', text }] }; + }); + return mcpServer; + }; + return { factory, contexts }; +} + +describe('toNodeHandler', () => { + it('serves through the duck-typed Node adapter, reading the request stream when no parsed body is given', async () => { + const { factory } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'node face' })); + // Express mounts pass `next` as the third argument; a function is never a parsed body. + await node(req, res, () => {}); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('node face'); + }); + + it('prefers a pre-parsed body over the request stream', async () => { + const { factory } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + const parsed = modernToolsCall('echo', { text: 'pre-parsed' }); + const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); + await node(req, res, parsed); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('pre-parsed'); + }); + + it('serves a pre-parsed legacy body on the default fallback (the documented express.json mounting)', async () => { + const { factory, contexts } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + // The documented Express mounting: express.json() consumed the stream + // and hands the parsed object as the third argument; the raw headers + // still describe the original (already-consumed) bytes. + const legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'echo', arguments: { text: 'node legacy' } } }; + const { req, res, body } = nodeRequestResponse(undefined); + req.headers['content-length'] = '999'; + req.headers['transfer-encoding'] = 'chunked'; + await node(req, res, legacyMessage); + + expect(res.statusCode).toBe(200); + expect(await body()).toContain('node legacy'); + expect(contexts).toHaveLength(1); + expect(contexts[0]?.era).toBe('legacy'); + }); + + it('forwards req.auth from upstream middleware as pass-through authInfo', async () => { + const { factory } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('whoami', {})); + req.auth = { token: 'verified', clientId: 'node-client', scopes: [] }; + await node(req, res); + expect(res.statusCode).toBe(200); + expect(await body()).toContain('node-client'); + }); + + it('skips HTTP/2 pseudo-headers when copying node request headers', async () => { + const { factory } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'http2 served' })); + Object.assign(req.headers, { + ':method': 'POST', + ':path': '/mcp', + ':scheme': 'http', + ':authority': 'localhost:3000' + }); + await node(req, res); + + expect(res.statusCode).toBe(200); + expect(await body()).toContain('http2 served'); + }); + + it('waits for drain before writing the next chunk when res.write reports backpressure', async () => { + const { factory } = testFactory(); + const node = toNodeHandler(createMcpHandler(factory)); + + const writes: string[] = []; + const listeners = new Map void>>(); + const res: NodeServerResponseLike & { statusCode: number } = { + statusCode: 0, + writeHead(statusCode: number) { + this.statusCode = statusCode; + return this; + }, + write(chunk: string | Uint8Array) { + writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); + // Always report a full buffer. + return false; + }, + end() { + return this; + }, + on(event: string, listener: (...args: unknown[]) => void) { + const existing = listeners.get(event) ?? []; + existing.push(listener); + listeners.set(event, existing); + return this; + } + }; + const emitDrain = () => { + for (const listener of listeners.get('drain') ?? []) { + listener(); + } + }; + + // The default (auto) response mode streams this exchange over SSE, so + // the loop sees at least two chunks (the progress frame and the result). + const { req } = nodeRequestResponse(modernToolsCall('progress-then-echo', { text: 'paced' })); + const served = node(req, res); + + await vi.waitFor(() => expect(writes.length).toBe(1)); + // With the buffer reported full and no drain yet, no further chunk is written. + await new Promise(resolve => setTimeout(resolve, 25)); + expect(writes).toHaveLength(1); + + // Draining releases the loop chunk by chunk until the stream completes. + const pump = setInterval(emitDrain, 5); + await served; + clearInterval(pump); + + const streamed = writes.join(''); + expect(writes.length).toBeGreaterThan(1); + expect(streamed).toContain('notifications/progress'); + expect(streamed).toContain('paced'); + }); + + it('answers with a 500 JSON-RPC error when handler.fetch throws (closed handler)', async () => { + const { factory } = testFactory(); + const handler = createMcpHandler(factory); + await handler.close(); + const node = toNodeHandler(handler); + + const parsed = modernToolsCall('echo', { text: 'late' }); + const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); + await node(req, res, parsed); + expect(res.statusCode).toBe(500); + const payload = JSON.parse(await body()) as { error: { code: number }; id: unknown }; + expect(payload.error.code).toBe(-32_603); + expect(payload.id).toBe(1); + }); + + it('reports the adapter-level fallback error to onerror before answering 500', async () => { + const thrown = new Error('fetch boom'); + const onerror = vi.fn(); + const node = toNodeHandler( + { + fetch: () => { + throw thrown; + } + }, + { onerror } + ); + + const parsed = modernToolsCall('echo', { text: 'late' }); + const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); + await node(req, res, parsed); + + expect(onerror).toHaveBeenCalledTimes(1); + expect(onerror).toHaveBeenCalledWith(thrown); + expect(res.statusCode).toBe(500); + const payload = JSON.parse(await body()) as { error: { code: number }; id: unknown }; + expect(payload.error.code).toBe(-32_603); + expect(payload.id).toBe(1); + }); + + it('still answers 500 when the onerror callback itself throws', async () => { + const node = toNodeHandler( + { + fetch: () => { + throw new Error('fetch boom'); + } + }, + { + onerror: () => { + throw new Error('reporter boom'); + } + } + ); + + const parsed = modernToolsCall('echo', { text: 'late' }); + const { req, res, body } = nodeRequestResponse(undefined); + Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); + await node(req, res, parsed); + + expect(res.statusCode).toBe(500); + const payload = JSON.parse(await body()) as { error: { code: number }; id: unknown }; + expect(payload.error.code).toBe(-32_603); + expect(payload.id).toBe(1); + }); +}); + +/* ------------------------------------------------------------------------ * + * Node face fixtures (duck-typed, no real sockets) + * ------------------------------------------------------------------------ */ + +interface FakeNodeResponse extends NodeServerResponseLike { + statusCode: number; + headers: Record | undefined; +} + +function nodeRequestResponse(body: unknown): { + req: Readable & { + method: string; + url: string; + headers: Record; + auth?: { token: string; clientId: string; scopes: string[] }; + }; + res: FakeNodeResponse; + body: () => Promise; +} { + const payload = body === undefined ? [] : [JSON.stringify(body)]; + const req = Object.assign(Readable.from(payload), { + method: 'POST', + url: '/mcp', + headers: { + host: 'localhost:3000', + 'content-type': 'application/json', + accept: 'application/json, text/event-stream', + ...bodyDerivedStandardHeaders(body) + } as Record + }); + + const chunks: string[] = []; + let resolveFinished: () => void; + const finished = new Promise(resolve => { + resolveFinished = resolve; + }); + const res: FakeNodeResponse = { + statusCode: 0, + headers: undefined, + writeHead(statusCode: number, headers?: Record) { + this.statusCode = statusCode; + this.headers = headers; + return this; + }, + write(chunk: string | Uint8Array) { + chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); + return true; + }, + end(chunk?: string | Uint8Array) { + if (chunk !== undefined) { + this.write(chunk); + } + resolveFinished(); + return this; + }, + on() { + return this; + } + }; + + return { + req, + res, + body: async () => { + await finished; + return chunks.join(''); + } + }; +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index fad5736d6a..ab24706d07 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -14,9 +14,7 @@ export type { McpHandlerRequestOptions, McpHttpHandler, McpRequestContext, - McpServerFactory, - NodeIncomingMessageLike, - NodeServerResponseLike + McpServerFactory } from './server/createMcpHandler.js'; export { createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './server/createMcpHandler.js'; export type { diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index faa140f0d6..4e4b859383 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -200,41 +200,18 @@ export interface CreateMcpHandlerOptions { } /** - * Minimal duck-typed shape of a Node.js `IncomingMessage` accepted by - * {@linkcode McpHttpHandler.node}. Kept structural so the handler stays free of - * `node:` imports and bundles for non-Node runtimes. - */ -export interface NodeIncomingMessageLike extends AsyncIterable { - method?: string; - url?: string; - headers: Record; - /** Validated authentication info attached by upstream middleware (pass-through). */ - auth?: AuthInfo; -} - -/** Minimal duck-typed shape of a Node.js `ServerResponse` accepted by {@linkcode McpHttpHandler.node}. */ -export interface NodeServerResponseLike { - writeHead(statusCode: number, headers?: Record): unknown; - write(chunk: string | Uint8Array): unknown; - end(chunk?: string | Uint8Array): unknown; - on(event: string, listener: (...args: unknown[]) => void): unknown; -} - -/** - * The handler returned by {@linkcode createMcpHandler}. Both faces are - * arrow-assigned bound properties: they can be detached and passed around - * (`const { fetch } = handler`) without losing their binding. + * The handler returned by {@linkcode createMcpHandler}: a web-standard + * `{ fetch, close, notify }` object — the shape Workers/Bun/Deno expect from + * `export default`. `fetch` is an arrow-assigned bound property: it can be + * detached and passed around (`const { fetch } = handler`) without losing its + * binding. + * + * Node frameworks (Express, Fastify, plain `node:http`) wrap the handler once + * with `toNodeHandler(handler)` from `@modelcontextprotocol/node`. */ export interface McpHttpHandler { /** Web-standard face: serve one HTTP request and resolve with the response. */ fetch: (request: Request, options?: McpHandlerRequestOptions) => Promise; - /** - * Node face: serve one Node.js request/response pair. The third argument is - * an optional pre-parsed body (`req.body` from `express.json()`); a function - * third argument (Express's `next` when the handler is mounted as - * middleware) is ignored. - */ - node: (req: NodeIncomingMessageLike, res: NodeServerResponseLike, parsedBody?: unknown) => Promise; /** * Tears down the modern leg: aborts in-flight modern exchanges and closes * their per-request instances. Legacy serving is unaffected — the @@ -561,10 +538,11 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown): P * modern-only strict endpoint. * * Mounting: `handler.fetch` is the web-standard face (Cloudflare Workers, - * Deno, Bun, Hono's `c.req.raw`); `handler.node(req, res, req.body)` is the - * Node face for Express/Fastify/plain `node:http`. When mounting bare on a - * fetch-native runtime, put Origin/Host validation in front of the handler — - * the entry itself is deliberately validation-free: + * Deno, Bun, Hono's `c.req.raw`); for Express/Fastify/plain `node:http`, wrap + * the handler once with `toNodeHandler(handler)` from + * `@modelcontextprotocol/node`. When mounting bare on a fetch-native runtime, + * put Origin/Host validation in front of the handler — the entry itself is + * deliberately validation-free: * * ```ts * import { hostHeaderValidationResponse, originValidationResponse, localhostAllowedHostnames, localhostAllowedOrigins } from '@modelcontextprotocol/server'; @@ -590,7 +568,7 @@ export async function isLegacyRequest(request: Request, parsedBody?: unknown): P * the era decision and `PerRequestHTTPServerTransport` for single-exchange * serving. * - * The entry performs no token verification: `authInfo` given to the faces is + * The entry performs no token verification: `authInfo` given to `fetch` is * passed through to handlers and the factory as-is and is never derived from * request headers. */ @@ -881,79 +859,8 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } }; - const nodeFace = async (req: NodeIncomingMessageLike, res: NodeServerResponseLike, parsedBody?: unknown): Promise => { - // Express passes (req, res, next) when the handler is mounted as a - // middleware function; a function third argument is `next`, not a body. - if (typeof parsedBody === 'function') { - parsedBody = undefined; - } - - let finished = false; - const abort = new AbortController(); - res.on('close', () => { - if (!finished) { - abort.abort(); - } - }); - - let response: Response; - try { - const request = await nodeRequestToFetchRequest(req, parsedBody, abort.signal); - response = await fetchFace(request, { - ...(req.auth !== undefined && { authInfo: req.auth }), - ...(parsedBody !== undefined && { parsedBody }) - }); - } catch (error) { - reportError(toError(error)); - response = internalServerErrorResponse(echoableRequestId(parsedBody)); - } - - const headers: Record = {}; - for (const [name, value] of response.headers) { - headers[name] = value; - } - res.writeHead(response.status, headers); - if (response.body === null) { - finished = true; - res.end(); - return; - } - const reader = response.body.getReader(); - // Honor write backpressure: when write() reports a full buffer (Node's - // `false` return), wait for the response to drain before pulling the - // next chunk. A single listener resolves whichever wait is pending; a - // closed response also releases the wait so a vanished client cannot - // park the loop forever. - let drainResolve: (() => void) | undefined; - const releaseDrainWait = () => { - drainResolve?.(); - drainResolve = undefined; - }; - res.on('drain', releaseDrainWait); - res.on('close', releaseDrainWait); - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) { - break; - } - if (value !== undefined && res.write(value) === false) { - await new Promise(resolve => { - drainResolve = resolve; - }); - } - } - } catch { - // The client went away while streaming; the abort signal already - // cancelled the exchange. - } - finished = true; - res.end(); - }; - return { fetch: fetchFace, - node: nodeFace, notify, bus, close: async () => { @@ -965,75 +872,3 @@ export function createMcpHandler(factory: McpServerFactory, options: CreateMcpHa } }; } - -/* ------------------------------------------------------------------------ * - * Node request conversion (duck-typed; no node: imports) - * ------------------------------------------------------------------------ */ - -function singleHeaderValue(value: string | string[] | undefined): string | undefined { - return Array.isArray(value) ? value[0] : value; -} - -async function nodeRequestToFetchRequest(req: NodeIncomingMessageLike, parsedBody: unknown, signal: AbortSignal): Promise { - const method = (req.method ?? 'GET').toUpperCase(); - const host = singleHeaderValue(req.headers['host']) ?? 'localhost'; - const url = `http://${host}${req.url ?? '/'}`; - - const headers = new Headers(); - for (const [name, value] of Object.entries(req.headers)) { - // HTTP/2 pseudo-headers (`:method`, `:path`, `:authority`, …) are - // connection metadata, not header fields — `Headers` rejects their - // names, so they are skipped rather than copied. - if (value === undefined || name.startsWith(':')) { - continue; - } - if (Array.isArray(value)) { - for (const item of value) { - headers.append(name, item); - } - } else { - headers.set(name, value); - } - } - - // The body is carried as text: MCP request bodies are JSON, and a string - // body keeps the constructed Request portable across runtime lib versions. - let body: string | undefined; - if (method !== 'GET' && method !== 'HEAD') { - if (parsedBody === undefined) { - const decoder = new TextDecoder(); - let collected = ''; - for await (const chunk of req) { - collected += typeof chunk === 'string' ? chunk : decoder.decode(chunk as Uint8Array, { stream: true }); - } - collected += decoder.decode(); - if (collected.length > 0) { - body = collected; - } - } else { - // The caller already consumed and parsed the Node stream (the - // documented `handler.node(req, res, req.body)` mounting behind - // `express.json()`), so the bytes cannot be re-read. Re-serialize - // the parsed value so consumers of the forwarded Request — anything - // on the legacy leg reading `request.json()`/`text()` instead of - // the pass-through parsedBody — still receive the body, and replace - // the entity headers that described the original raw bytes. - const serialized: string | undefined = JSON.stringify(parsedBody); - headers.delete('content-encoding'); - headers.delete('transfer-encoding'); - if (serialized === undefined) { - headers.delete('content-length'); - } else { - body = serialized; - headers.set('content-length', String(new TextEncoder().encode(serialized).byteLength)); - } - } - } - - return new Request(url, { - method, - headers, - signal, - ...(body !== undefined && { body }) - }); -} diff --git a/packages/server/test/server/createMcpHandler.test.ts b/packages/server/test/server/createMcpHandler.test.ts index ee5e8dcf9e..59e98b6a1d 100644 --- a/packages/server/test/server/createMcpHandler.test.ts +++ b/packages/server/test/server/createMcpHandler.test.ts @@ -7,13 +7,11 @@ * faces, the per-request era write + client-identity backfill, notification * routing, the response-mode knob, and close() teardown of the modern leg. */ -import { Readable } from 'node:stream'; - import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core'; import { describe, expect, it, vi } from 'vitest'; import * as z from 'zod/v4'; -import type { McpRequestContext, NodeServerResponseLike } from '../../src/server/createMcpHandler.js'; +import type { McpRequestContext } from '../../src/server/createMcpHandler.js'; import { createMcpHandler, isLegacyRequest } from '../../src/server/createMcpHandler.js'; import { McpServer } from '../../src/server/mcp.js'; import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport.js'; @@ -783,129 +781,10 @@ describe('createMcpHandler — handler faces', () => { expect(await response.text()).toContain('detached'); }); - it('serves through the duck-typed node face, reading the request stream when no parsed body is given', async () => { - const { factory } = testFactory(); - const handler = createMcpHandler(factory); - - const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'node face' })); - // Express mounts pass `next` as the third argument; a function is never a parsed body. - await handler.node(req, res, () => {}); - expect(res.statusCode).toBe(200); - expect(await body()).toContain('node face'); - }); - - it('prefers a pre-parsed body over the request stream on the node face', async () => { - const { factory } = testFactory(); - const handler = createMcpHandler(factory); - - const parsed = modernToolsCall('echo', { text: 'pre-parsed' }); - const { req, res, body } = nodeRequestResponse(undefined); - Object.assign(req.headers, bodyDerivedStandardHeaders(parsed)); - await handler.node(req, res, parsed); - expect(res.statusCode).toBe(200); - expect(await body()).toContain('pre-parsed'); - }); - - it('serves a pre-parsed legacy body through the node face on the default fallback (the documented express.json mounting)', async () => { - const { factory, state } = testFactory(); - const handler = createMcpHandler(factory); - - // The documented Express mounting: express.json() consumed the stream - // and hands the parsed object as the third argument; the raw headers - // still describe the original (already-consumed) bytes. - const legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'echo', arguments: { text: 'node legacy' } } }; - const { req, res, body } = nodeRequestResponse(undefined); - req.headers['content-length'] = '999'; - req.headers['transfer-encoding'] = 'chunked'; - await handler.node(req, res, legacyMessage); - - expect(res.statusCode).toBe(200); - expect(await body()).toContain('node legacy'); - expect(state.contexts).toHaveLength(1); - expect(state.contexts[0]?.era).toBe('legacy'); - }); - - it('forwards req.auth from upstream middleware as pass-through authInfo on the node face', async () => { - const { factory } = testFactory(); - const handler = createMcpHandler(factory); - - const { req, res, body } = nodeRequestResponse(modernToolsCall('whoami', {})); - req.auth = { token: 'verified', clientId: 'node-client', scopes: [] }; - await handler.node(req, res); - expect(res.statusCode).toBe(200); - expect(await body()).toContain('node-client'); - }); - - it('skips HTTP/2 pseudo-headers when copying node request headers', async () => { - const { factory } = testFactory(); - const handler = createMcpHandler(factory); - - const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'http2 served' })); - Object.assign(req.headers, { - ':method': 'POST', - ':path': '/mcp', - ':scheme': 'http', - ':authority': 'localhost:3000' - }); - await handler.node(req, res); - - expect(res.statusCode).toBe(200); - expect(await body()).toContain('http2 served'); - }); - - it('waits for drain before writing the next chunk when res.write reports backpressure', async () => { - const { factory } = testFactory(); - const handler = createMcpHandler(factory); - - const writes: string[] = []; - const listeners = new Map void>>(); - const res: NodeServerResponseLike & { statusCode: number } = { - statusCode: 0, - writeHead(statusCode: number) { - this.statusCode = statusCode; - return this; - }, - write(chunk: string | Uint8Array) { - writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); - // Always report a full buffer. - return false; - }, - end() { - return this; - }, - on(event: string, listener: (...args: unknown[]) => void) { - const existing = listeners.get(event) ?? []; - existing.push(listener); - listeners.set(event, existing); - return this; - } - }; - const emitDrain = () => { - for (const listener of listeners.get('drain') ?? []) { - listener(); - } - }; - - // The default (auto) response mode streams this exchange over SSE, so - // the loop sees at least two chunks (the progress frame and the result). - const { req } = nodeRequestResponse(modernToolsCall('progress-then-echo', { text: 'paced' })); - const served = handler.node(req, res); - - await vi.waitFor(() => expect(writes.length).toBe(1)); - // With the buffer reported full and no drain yet, no further chunk is written. - await new Promise(resolve => setTimeout(resolve, 25)); - expect(writes).toHaveLength(1); - - // Draining releases the loop chunk by chunk until the stream completes. - const pump = setInterval(emitDrain, 5); - await served; - clearInterval(pump); - - const streamed = writes.join(''); - expect(writes.length).toBeGreaterThan(1); - expect(streamed).toContain('notifications/progress'); - expect(streamed).toContain('paced'); - }); + // The Node `(req, res, parsedBody?)` adaptation moved to + // `toNodeHandler(handler)` in `@modelcontextprotocol/node`; its conversion + // semantics (stream read, pre-parsed body, req.auth pass-through, HTTP/2 + // pseudo-headers, write backpressure) are pinned at unit level there. }); describe('createMcpHandler — close()', () => { @@ -932,76 +811,6 @@ describe('createMcpHandler — close()', () => { }); }); -/* ------------------------------------------------------------------------ * - * Node face fixtures (duck-typed, no real sockets) - * ------------------------------------------------------------------------ */ - -interface FakeNodeResponse extends NodeServerResponseLike { - statusCode: number; - headers: Record | undefined; -} - -function nodeRequestResponse(body: unknown): { - req: Readable & { - method: string; - url: string; - headers: Record; - auth?: { token: string; clientId: string; scopes: string[] }; - }; - res: FakeNodeResponse; - body: () => Promise; -} { - const payload = body === undefined ? [] : [JSON.stringify(body)]; - const req = Object.assign(Readable.from(payload), { - method: 'POST', - url: '/mcp', - headers: { - host: 'localhost:3000', - 'content-type': 'application/json', - accept: 'application/json, text/event-stream', - ...bodyDerivedStandardHeaders(body) - } as Record - }); - - const chunks: string[] = []; - let resolveFinished: () => void; - const finished = new Promise(resolve => { - resolveFinished = resolve; - }); - const res: FakeNodeResponse = { - statusCode: 0, - headers: undefined, - writeHead(statusCode: number, headers?: Record) { - this.statusCode = statusCode; - this.headers = headers; - return this; - }, - write(chunk: string | Uint8Array) { - chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); - return true; - }, - end(chunk?: string | Uint8Array) { - if (chunk !== undefined) { - this.write(chunk); - } - resolveFinished(); - return this; - }, - on() { - return this; - } - }; - - return { - req, - res, - body: async () => { - await finished; - return chunks.join(''); - } - }; -} - // Type-level pin: a zero-argument factory stays assignable to McpServerFactory unchanged. const zeroArgFactory = () => new McpServer({ name: 'zero-arg', version: '1.0.0' }); void createMcpHandler(zeroArgFactory); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 28443dbaaf..6c112ff6f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -368,6 +368,9 @@ importers: '@modelcontextprotocol/express': specifier: workspace:* version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -484,6 +487,9 @@ importers: '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -553,6 +559,9 @@ importers: '@modelcontextprotocol/express': specifier: workspace:* version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -584,6 +593,9 @@ importers: '@modelcontextprotocol/express': specifier: workspace:* version: link:../../packages/middleware/express + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -853,6 +865,9 @@ importers: '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server @@ -895,6 +910,9 @@ importers: '@modelcontextprotocol/client': specifier: workspace:* version: link:../../packages/client + '@modelcontextprotocol/node': + specifier: workspace:* + version: link:../../packages/middleware/node '@modelcontextprotocol/server': specifier: workspace:* version: link:../../packages/server diff --git a/test/conformance/src/everythingServer.ts b/test/conformance/src/everythingServer.ts index 85a0934443..4196e817d2 100644 --- a/test/conformance/src/everythingServer.ts +++ b/test/conformance/src/everythingServer.ts @@ -10,7 +10,7 @@ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { localhostHostValidation } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node'; +import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; import type { CallToolResult, EventId, @@ -1326,6 +1326,7 @@ function createMcpServer() { const modernHandler = createMcpHandler(() => createMcpServer(), { onerror: error => console.error('Modern-era MCP handler error:', error) }); +const modernNodeHandler = toNodeHandler(modernHandler); /** Normalize a possibly-repeated HTTP header to its first value. */ function headerValue(value: string | string[] | undefined): string | undefined { @@ -1367,7 +1368,7 @@ app.post('/mcp', async (req: Request, res: Response) => { body: req.body }); if (inbound.kind !== 'legacy') { - await modernHandler.node(req, res, req.body); + await modernNodeHandler(req, res, req.body); return; } diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index 6ce066ed1e..83e3c112cc 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -14,6 +14,7 @@ import { PROTOCOL_VERSION_META_KEY, SUBSCRIPTION_ID_META_KEY } from '@modelcontextprotocol/core'; +import { toNodeHandler } from '@modelcontextprotocol/node'; import type { CreateMcpHandlerOptions, McpHttpHandler, McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, McpServer } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; @@ -43,7 +44,7 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { async function startEndpoint(options?: CreateMcpHandlerOptions): Promise<{ baseUrl: URL; handler: McpHttpHandler }> { const handler = createMcpHandler(factory, options); - const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const httpServer: HttpServer = createServer(toNodeHandler(handler)); const baseUrl = await listenOnRandomPort(httpServer); cleanups.push(async () => { await handler.close(); @@ -160,7 +161,7 @@ describe('createMcpHandler over HTTP — subscriptions/listen honored filter', ( () => new McpServer({ name: 'caps-gated', version: '1' }, { capabilities: { tools: { listChanged: true } } }), { keepAliveMs: 0 } ); - const httpServer: HttpServer = createServer((req, res) => void handler.node(req, res)); + const httpServer: HttpServer = createServer(toNodeHandler(handler)); const baseUrl = await listenOnRandomPort(httpServer); cleanups.push(async () => { await handler.close();