diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index bfbd3f807..e66d53c95 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -11,8 +11,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { getRequestListener } from '@hono/node-server'; import type { AuthInfo, JSONRPCMessage, MessageExtraInfo, RequestId, Transport } from '@modelcontextprotocol/core'; -import type { WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; +import type { Dispatchable, HandleHttpOptions, WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server'; +import { handleHttp, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'; /** * Configuration options for {@linkcode NodeStreamableHTTPServerTransport} @@ -50,6 +50,32 @@ export function toNodeHttpHandler( }; } +/** + * Mounts an `McpServer` (or any `Protocol` subclass) as a Node.js HTTP handler. + * Composes {@linkcode handleHttp} with {@linkcode toNodeHttpHandler} so each request flows through + * `mcp.dispatch()` directly; `mcp.connect()` and a transport instance are not used. + * + * Reads `req.auth` (set by e.g. `requireBearerAuth` from `@modelcontextprotocol/express`) + * and `req.body` (set by e.g. `express.json()`) when present. + * + * ```ts + * import http from 'node:http'; + * import { McpServer, SessionCompat } from '@modelcontextprotocol/server'; + * import { mcpNodeHandler } from '@modelcontextprotocol/node'; + * + * const mcp = new McpServer({ name: 's', version: '1.0.0' }); + * http.createServer(mcpNodeHandler(mcp, { session: new SessionCompat() })).listen(3000); + * ``` + * + * For frameworks that already work with web `(Request) => Response`, use {@linkcode handleHttp} directly. + */ +export function mcpNodeHandler( + mcp: Dispatchable, + options?: HandleHttpOptions +): (req: IncomingMessage & { auth?: AuthInfo; body?: unknown }, res: ServerResponse, next?: (err?: unknown) => void) => Promise { + return toNodeHttpHandler(handleHttp(mcp, options)); +} + /** * Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification. * It supports both SSE streaming and direct HTTP responses. diff --git a/packages/middleware/node/test/streamableHttp.test.ts b/packages/middleware/node/test/streamableHttp.test.ts index 37d66ac2f..ec7a7a9e1 100644 --- a/packages/middleware/node/test/streamableHttp.test.ts +++ b/packages/middleware/node/test/streamableHttp.test.ts @@ -13,12 +13,55 @@ import type { RequestId } from '@modelcontextprotocol/core'; import type { EventId, EventStore, StreamId } from '@modelcontextprotocol/server'; -import { McpServer } from '@modelcontextprotocol/server'; +import { McpServer, SessionCompat } from '@modelcontextprotocol/server'; import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers'; import * as z from 'zod/v4'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { NodeStreamableHTTPServerTransport, toNodeHttpHandler } from '../src/streamableHttp.js'; +import { mcpNodeHandler, NodeStreamableHTTPServerTransport, toNodeHttpHandler } from '../src/streamableHttp.js'; + +describe('mcpNodeHandler', () => { + async function startNodeHandlerServer(options?: Parameters[1]) { + const mcp = new McpServer({ name: 'test-server', version: '1.0.0' }); + mcp.registerTool( + 'greet', + { description: 'A simple greeting tool', inputSchema: z.object({ who: z.string() }) }, + async ({ who }): Promise => ({ content: [{ type: 'text', text: `hi ${who}` }] }) + ); + const handler = mcpNodeHandler(mcp, options); + const server = createServer((req, res) => void handler(req, res)); + const baseUrl = await listenOnRandomPort(server); + return { server, baseUrl }; + } + + it('serves initialize via mcp.dispatch() (stateless, no transport class)', async () => { + const { server, baseUrl } = await startNodeHandlerServer({ enableJsonResponse: true }); + try { + const res = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + expect(res.status).toBe(200); + const body = (await res.json()) as JSONRPCResultResponse; + expect(body.result).toMatchObject({ serverInfo: { name: 'test-server' } }); + expect(res.headers.get('mcp-session-id')).toBeNull(); + } finally { + await new Promise(r => server.close(() => r())); + } + }); + + it('serves session lifecycle via SessionCompat', async () => { + const { server, baseUrl } = await startNodeHandlerServer({ session: new SessionCompat(), enableJsonResponse: true }); + try { + const initRes = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize); + const sid = initRes.headers.get('mcp-session-id'); + expect(sid).toBeTruthy(); + const listRes = await sendPostRequest(baseUrl, TEST_MESSAGES.toolsList, sid!); + expect(listRes.status).toBe(200); + const body = (await listRes.json()) as JSONRPCResultResponse; + expect((body.result as { tools: unknown[] }).tools).toHaveLength(1); + } finally { + await new Promise(r => server.close(() => r())); + } + }); +}); describe('toNodeHttpHandler', () => { it('does not treat express next() as parsedBody; reads req.body instead', async () => {