Skip to content

Commit 284f01e

Browse files
feat(server): handleHttp() per-request entry, SessionCompat, dual-conformance
Adds handleHttp(mcp): (Request) => Promise<Response> for stateless/serverless deployment, driven by Protocol.dispatch(). SessionCompat is opt-in 2025-11 mcp-session-id lifecycle. toNodeHttpHandler adapts to Node http. SSE Last-Event-ID resumption is bound to the issuing session: the streamId is per-session (per-POST UUID or _GET_stream:<sessionId>), tracked in SessionCompat, and replayEvents rejects event IDs that don't belong to the requesting session. Requires EventStore.getStreamIdForEventId. Dual conformance: CI runs the same suite against both connect(transport) and handleHttp() entry points (40/40 each).
1 parent 435a2dc commit 284f01e

16 files changed

Lines changed: 1868 additions & 892 deletions

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
"test:conformance:server": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server",
4343
"test:conformance:server:all": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:all",
4444
"test:conformance:server:run": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:run",
45+
"test:conformance:server:handlehttp": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:handlehttp",
46+
"test:conformance:server:dual": "pnpm --filter @modelcontextprotocol/test-conformance run test:conformance:server:dual",
4547
"test:conformance:all": "pnpm run test:conformance:client:all && pnpm run test:conformance:server:all"
4648
},
4749
"devDependencies": {

packages/core/src/shared/protocol.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { SdkError, SdkErrorCode } from '../errors/sdkErrors.js';
1010
import type {
11+
JSONRPCNotification,
1112
JSONRPCRequest,
1213
MessageExtraInfo,
1314
Notification,
@@ -179,6 +180,14 @@ export abstract class Protocol<ContextT extends BaseContext> {
179180
return this._dispatcher.dispatch(request, env);
180181
}
181182

183+
/**
184+
* Dispatch one inbound notification to its registered handler. Transport-free
185+
* counterpart to {@linkcode Protocol.dispatch}; consumed by `handleHttp`.
186+
*/
187+
dispatchNotification(notification: JSONRPCNotification): Promise<void> {
188+
return this._dispatcher.dispatchNotification(notification);
189+
}
190+
182191
/**
183192
* Registers a handler to invoke when this protocol object receives a request with the given method.
184193
*

packages/middleware/node/src/streamableHttp.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,35 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
2121
*/
2222
export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServerTransportOptions;
2323

24+
/**
25+
* Converts a web-standard `(Request) => Response` handler into a Node.js
26+
* `(IncomingMessage, ServerResponse) => void` handler suitable for express,
27+
* `http.createServer`, etc.
28+
*
29+
* The third parameter (express's `next`) is accepted for middleware compatibility but
30+
* not invoked; errors are written to the response (`@hono/node-server` swallows handler
31+
* rejections internally). Auth info is read from `req.auth`; a pre-parsed body is read from `req.body`
32+
* (e.g. when `express.json()` ran before this handler).
33+
*
34+
* ```ts
35+
* import { handleHttp } from '@modelcontextprotocol/server';
36+
* import { toNodeHttpHandler } from '@modelcontextprotocol/node';
37+
*
38+
* app.all('/mcp', toNodeHttpHandler(handleHttp(mcp, { session })));
39+
* ```
40+
*/
41+
export function toNodeHttpHandler(
42+
handler: (req: Request, extra?: { authInfo?: AuthInfo; parsedBody?: unknown }) => Response | Promise<Response>
43+
): (req: IncomingMessage & { auth?: AuthInfo; body?: unknown }, res: ServerResponse, next?: (err?: unknown) => void) => Promise<void> {
44+
return async (req, res, _next) => {
45+
void _next;
46+
const parsedBody = req.body;
47+
const extra = req.auth !== undefined || parsedBody !== undefined ? { authInfo: req.auth, parsedBody } : undefined;
48+
const listener = getRequestListener(webReq => handler(webReq, extra), { overrideGlobalObjects: false });
49+
await listener(req, res);
50+
};
51+
}
52+
2453
/**
2554
* Server transport for Streamable HTTP: this implements the MCP Streamable HTTP transport specification.
2655
* It supports both SSE streaming and direct HTTP responses.

packages/middleware/node/test/streamableHttp.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,31 @@ import { listenOnRandomPort } from '@modelcontextprotocol/test-helpers';
1818
import * as z from 'zod/v4';
1919
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2020

21-
import { NodeStreamableHTTPServerTransport } from '../src/streamableHttp.js';
21+
import { NodeStreamableHTTPServerTransport, toNodeHttpHandler } from '../src/streamableHttp.js';
22+
23+
describe('toNodeHttpHandler', () => {
24+
it('does not treat express next() as parsedBody; reads req.body instead', async () => {
25+
let capturedExtra: { parsedBody?: unknown } | undefined;
26+
const handler = toNodeHttpHandler(async (_req, extra) => {
27+
capturedExtra = extra;
28+
return Response.json({ ok: true });
29+
});
30+
const port = await getFreePort();
31+
const server = createServer((req, res) => {
32+
(req as IncomingMessage & { body?: unknown }).body = { jsonrpc: '2.0', method: 'ping', id: 1 };
33+
// Express-style call: third arg is the next() function.
34+
void handler(req as IncomingMessage & { auth?: AuthInfo; body?: unknown }, res, () => {});
35+
});
36+
await new Promise<void>(r => server.listen(port, r));
37+
try {
38+
await fetch(`http://localhost:${port}/`, { method: 'POST' });
39+
expect(capturedExtra?.parsedBody).toEqual({ jsonrpc: '2.0', method: 'ping', id: 1 });
40+
expect(typeof capturedExtra?.parsedBody).not.toBe('function');
41+
} finally {
42+
await new Promise<void>(r => server.close(() => r()));
43+
}
44+
});
45+
});
2246

2347
async function getFreePort() {
2448
return new Promise(res => {

packages/server/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export { Server } from './server/server.js';
3131
// StdioServerTransport is exported from the './stdio' subpath — server stdio has only type-level Node
3232
// imports (erased at compile time), but matching the client's `./stdio` subpath gives consumers a
3333
// consistent shape across packages.
34+
export type { Dispatchable, HandleHttpOptions, HandleHttpRequestExtra } from './server/handleHttp.js';
35+
export { handleHttp } from './server/handleHttp.js';
36+
export type { SessionCompatOptions, SessionValidation } from './server/sessionCompat.js';
37+
export { SessionCompat } from './server/sessionCompat.js';
3438
export type {
3539
EventId,
3640
EventStore,
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import type { DispatchOutput, JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, RequestEnv } from '@modelcontextprotocol/core';
2+
3+
import type { ShttpHandlerOptions, ShttpRequestExtra } from './shttpHandler.js';
4+
import { shttpHandler } from './shttpHandler.js';
5+
6+
async function* unwrap(gen: AsyncIterable<DispatchOutput>): AsyncGenerator<JSONRPCMessage, void, void> {
7+
for await (const out of gen) yield out.message;
8+
}
9+
10+
/**
11+
* Minimal contract {@linkcode handleHttp} requires. Satisfied by `McpServer`,
12+
* `Server`, and any `Protocol` subclass.
13+
*/
14+
export interface Dispatchable {
15+
dispatch(request: JSONRPCRequest, env?: RequestEnv): AsyncIterable<DispatchOutput>;
16+
dispatchNotification(notification: JSONRPCNotification): Promise<void>;
17+
}
18+
19+
/**
20+
* Mounts an `McpServer` (or any `Protocol`) as a web-standard
21+
* `(Request) => Response` handler. Use this to drive a server from an HTTP framework
22+
* without instantiating a transport class:
23+
*
24+
* ```ts
25+
* import { McpServer, handleHttp, SessionCompat } from '@modelcontextprotocol/server';
26+
* import { toNodeHttpHandler } from '@modelcontextprotocol/node';
27+
*
28+
* const mcp = new McpServer({ name: 's', version: '1.0.0' });
29+
* mcp.tool('search', schema, handler);
30+
*
31+
* app.all('/mcp', toNodeHttpHandler(handleHttp(mcp, { session: new SessionCompat() })));
32+
* ```
33+
*
34+
* `mcp.connect(transport)` is not called; each HTTP request flows through
35+
* `mcp.dispatch()` directly. Supply a `SessionCompat` via `options.session`
36+
* to serve clients that send `Mcp-Session-Id` (the pre-2026-06 stateful flow).
37+
*/
38+
export function handleHttp(
39+
mcp: Dispatchable,
40+
options: ShttpHandlerOptions = {}
41+
): (req: Request, extra?: ShttpRequestExtra) => Promise<Response> {
42+
return shttpHandler(
43+
{
44+
onrequest: (req, env?: RequestEnv) => unwrap(mcp.dispatch(req, env)),
45+
onnotification: n => mcp.dispatchNotification(n)
46+
},
47+
options
48+
);
49+
}
50+
51+
export { type ShttpHandlerOptions as HandleHttpOptions, type ShttpRequestExtra as HandleHttpRequestExtra } from './shttpHandler.js';

packages/server/src/server/mcp.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@ import type {
66
CompleteRequestResourceTemplate,
77
CompleteResult,
88
CreateTaskResult,
9+
DispatchOutput,
910
GetPromptResult,
1011
Implementation,
12+
JSONRPCNotification,
13+
JSONRPCRequest,
1114
ListPromptsResult,
1215
ListResourcesResult,
1316
ListToolsResult,
1417
LoggingMessageNotification,
1518
Prompt,
1619
PromptReference,
1720
ReadResourceResult,
21+
RequestEnv,
1822
Resource,
1923
ResourceTemplateReference,
2024
Result,
@@ -111,6 +115,19 @@ export class McpServer {
111115
return await this.server.connect(transport);
112116
}
113117

118+
/**
119+
* Transport-free per-request entry; forwards to {@linkcode Server}`.dispatch`.
120+
* Exposed so `handleHttp(mcp, ...)` accepts an {@linkcode McpServer} directly.
121+
*/
122+
dispatch(request: JSONRPCRequest, env?: RequestEnv): AsyncGenerator<DispatchOutput, void, void> {
123+
return this.server.dispatch(request, env);
124+
}
125+
126+
/** Forwards to {@linkcode Server}`.dispatchNotification` for the `handleHttp` path. */
127+
dispatchNotification(notification: JSONRPCNotification): Promise<void> {
128+
return this.server.dispatchNotification(notification);
129+
}
130+
114131
/**
115132
* Closes the connection.
116133
*/

0 commit comments

Comments
 (0)