From b65afa9ea308c88259effe409bec10fbeed4c904 Mon Sep 17 00:00:00 2001 From: Rom Date: Thu, 14 May 2026 20:20:13 +0300 Subject: [PATCH 1/2] fix(node): reuse getRequestListener instead of creating one per request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NodeStreamableHTTPServerTransport.handleRequest() creates a new getRequestListener from @hono/node-server on every call, even though the constructor already creates one (this._requestListener). The constructor's version was unusable because it relied on a WeakMap keyed by the Web Standard Request object — but that object is created inside getRequestListener, so the WeakMap key can never be set before the callback fires. Fix: replace the WeakMap with AsyncLocalStorage to pass per-request context (authInfo, parsedBody) through to the shared listener callback. This is concurrent-safe and appropriate for this Node.js-specific middleware package. Impact: eliminates one getRequestListener allocation + closure per HTTP request. In production MCP servers handling sustained traffic, this reduces GC pressure from per-request overhead. Relates to #2090 --- .../middleware/node/src/streamableHttp.ts | 44 +++++++------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/middleware/node/src/streamableHttp.ts b/packages/middleware/node/src/streamableHttp.ts index 68a0c224f..d06942dcf 100644 --- a/packages/middleware/node/src/streamableHttp.ts +++ b/packages/middleware/node/src/streamableHttp.ts @@ -7,6 +7,7 @@ * For web-standard environments (Cloudflare Workers, Deno, Bun), use {@linkcode WebStandardStreamableHTTPServerTransport} directly. */ +import { AsyncLocalStorage } from 'node:async_hooks'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { getRequestListener } from '@hono/node-server'; @@ -67,20 +68,23 @@ export type StreamableHTTPServerTransportOptions = WebStandardStreamableHTTPServ export class NodeStreamableHTTPServerTransport implements Transport { private _webStandardTransport: WebStandardStreamableHTTPServerTransport; private _requestListener: ReturnType; - // Store auth and parsedBody per request for passing through to handleRequest - private _requestContext: WeakMap = new WeakMap(); + // Pass per-request context (auth, parsed body) through to the shared request listener. + // AsyncLocalStorage is used because getRequestListener creates the Web Standard Request + // internally — we have no reference to it before the callback fires, so a WeakMap keyed + // by Request cannot work. AsyncLocalStorage is concurrent-safe and appropriate here since + // this module is Node.js-specific. + private _requestContext = new AsyncLocalStorage<{ authInfo?: AuthInfo; parsedBody?: unknown }>(); constructor(options: StreamableHTTPServerTransportOptions = {}) { this._webStandardTransport = new WebStandardStreamableHTTPServerTransport(options); - // Create a request listener that wraps the web standard transport - // getRequestListener converts Node.js HTTP to Web Standard and properly handles SSE streaming + // Create a single request listener at construction time, reused for every request. + // getRequestListener converts Node.js HTTP to Web Standard and properly handles SSE streaming. // overrideGlobalObjects: false prevents Hono from overwriting global Response, which would - // break frameworks like Next.js whose response classes extend the native Response + // break frameworks like Next.js whose response classes extend the native Response. this._requestListener = getRequestListener( async (webRequest: Request) => { - // Get context if available (set during handleRequest) - const context = this._requestContext.get(webRequest); + const context = this._requestContext.getStore(); return this._webStandardTransport.handleRequest(webRequest, { authInfo: context?.authInfo, parsedBody: context?.parsedBody @@ -163,26 +167,12 @@ export class NodeStreamableHTTPServerTransport implements Transport { * @param parsedBody - Optional pre-parsed body from body-parser middleware */ async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise { - // Store context for this request to pass through auth and parsedBody - // We need to intercept the request creation to attach this context - const authInfo = req.auth; - - // Create a custom handler that includes our context - // overrideGlobalObjects: false prevents Hono from overwriting global Response, which would - // break frameworks like Next.js whose response classes extend the native Response - const handler = getRequestListener( - async (webRequest: Request) => { - return this._webStandardTransport.handleRequest(webRequest, { - authInfo, - parsedBody - }); - }, - { overrideGlobalObjects: false } - ); - - // Delegate to the request listener which handles all the Node.js <-> Web Standard conversion - // including proper SSE streaming support - await handler(req, res); + // Run the shared request listener within an AsyncLocalStorage context so the + // callback can retrieve authInfo and parsedBody without creating a new + // getRequestListener per request. + await this._requestContext.run({ authInfo: req.auth, parsedBody }, () => { + return this._requestListener(req, res); + }); } /** From a303cc739d6625f2531fa1917c6b471e8eafbe4d Mon Sep 17 00:00:00 2001 From: Rom Date: Thu, 14 May 2026 20:35:24 +0300 Subject: [PATCH 2/2] chore: add changeset for getRequestListener fix --- .changeset/fix-reuse-request-listener.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-reuse-request-listener.md diff --git a/.changeset/fix-reuse-request-listener.md b/.changeset/fix-reuse-request-listener.md new file mode 100644 index 000000000..d3e07c9ae --- /dev/null +++ b/.changeset/fix-reuse-request-listener.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/node': patch +--- + +Reuse the constructor's `getRequestListener` in `NodeStreamableHTTPServerTransport.handleRequest()` instead of creating a new one per request. Uses `AsyncLocalStorage` to pass per-request context (authInfo, parsedBody) through to the shared listener callback. This eliminates one `getRequestListener` allocation per HTTP request, reducing GC pressure under sustained load.