From d434604040c3520fa353bbdae6aab2ff8f995175 Mon Sep 17 00:00:00 2001 From: Brett Schneider Date: Wed, 4 Mar 2026 14:37:28 -0600 Subject: [PATCH] fix: restore globalThis.Response after lazy-loading MCP SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The @modelcontextprotocol/sdk has transitive dependencies (via undici) that replace globalThis.Response when imported. This breaks Next.js App Router route handlers which validate responses with instanceof Response, causing "No response is returned from route handler" errors for ALL routes in the same process — not just the MCP endpoint. Lazy-loading alone (as proposed in #141) only delays the problem. Once the MCP endpoint is hit, the global Response is replaced and all subsequent requests to any route handler fail. This fix: - Converts eager SDK imports to type-only imports - Adds a loadSdk() function that dynamically imports SDK modules - Saves globalThis.Response before import, restores it after - Defers StreamableHTTPServerTransport creation to first request - Calls loadSdk() at the start of mcpApiHandler Fixes #140 --- .changeset/fix-global-response-pollution.md | 5 + src/handler/mcp-api-handler.ts | 109 +++++++++++++------- 2 files changed, 76 insertions(+), 38 deletions(-) create mode 100644 .changeset/fix-global-response-pollution.md diff --git a/.changeset/fix-global-response-pollution.md b/.changeset/fix-global-response-pollution.md new file mode 100644 index 0000000..3f14bff --- /dev/null +++ b/.changeset/fix-global-response-pollution.md @@ -0,0 +1,5 @@ +--- +"mcp-handler": patch +--- + +fix: restore globalThis.Response after lazy-loading MCP SDK to prevent Next.js route handler failures diff --git a/src/handler/mcp-api-handler.ts b/src/handler/mcp-api-handler.ts index 5dd92e2..9e8feeb 100644 --- a/src/handler/mcp-api-handler.ts +++ b/src/handler/mcp-api-handler.ts @@ -1,5 +1,5 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; import { type IncomingHttpHeaders, IncomingMessage, @@ -8,7 +8,7 @@ import { import { createClient } from "redis"; import { Socket } from "node:net"; import { Readable } from "node:stream"; -import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import type { BodyType } from "./server-response-adapter"; import assert from "node:assert"; import type { @@ -23,6 +23,36 @@ import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; import { getAuthContext } from "../auth/auth-context"; import { ServerOptions } from "."; +// Lazy-loaded SDK modules. The @modelcontextprotocol/sdk has transitive +// dependencies (via undici) that replace globalThis.Response at import +// time. Eagerly importing the SDK at the module level causes Next.js +// route handlers to fail the `instanceof Response` check with +// "No response is returned from route handler" errors. +// See: https://github.com/vercel/mcp-handler/issues/140 +let McpServerClass: typeof McpServer; +let SSEServerTransportClass: typeof SSEServerTransport; +let StreamableHTTPServerTransportClass: typeof StreamableHTTPServerTransport; + +async function loadSdk() { + if (McpServerClass) return; + + const OriginalResponse = globalThis.Response; + + const [mcpMod, sseMod, httpMod] = await Promise.all([ + import("@modelcontextprotocol/sdk/server/mcp.js"), + import("@modelcontextprotocol/sdk/server/sse.js"), + import("@modelcontextprotocol/sdk/server/streamableHttp.js"), + ]); + + // Restore the original Response so that other route handlers in the + // same process continue to pass Next.js's instanceof check. + globalThis.Response = OriginalResponse; + + McpServerClass = mcpMod.McpServer; + SSEServerTransportClass = sseMod.SSEServerTransport; + StreamableHTTPServerTransportClass = httpMod.StreamableHTTPServerTransport; +} + interface SerializedRequest { requestId: string; url: string; @@ -278,16 +308,14 @@ export function initializeMcpApiHandler( let servers: McpServer[] = []; let statelessServer: McpServer; - const statelessTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: sessionIdGenerator, - }); - + let statelessTransport: StreamableHTTPServerTransport; + // Start periodic cleanup if not already running if (!cleanupInterval) { cleanupInterval = setInterval(() => { const now = Date.now(); const staleThreshold = 5 * 60 * 1000; // 5 minutes - + servers = servers.filter(server => { const metadata = serverMetadata.get(server); if (!metadata) { @@ -302,7 +330,7 @@ export function initializeMcpApiHandler( } return false; } - + const age = now - metadata.createdAt.getTime(); if (age > staleThreshold) { logger.log(`Removing stale server (session ${metadata.sessionId}, age: ${age}ms)`); @@ -319,13 +347,15 @@ export function initializeMcpApiHandler( serverMetadata.delete(server); return false; } - + return true; }); }, 30 * 1000); // Run every 30 seconds } return async function mcpApiHandler(req: Request, res: ServerResponse) { + await loadSdk(); + const url = new URL(req.url || "", "https://example.com"); if (url.pathname === streamableHttpEndpoint) { if (req.method === "GET") { @@ -364,7 +394,10 @@ export function initializeMcpApiHandler( ); if (!statelessServer) { - statelessServer = new McpServer(serverInfo, mcpServerOptions); + statelessTransport = new StreamableHTTPServerTransportClass({ + sessionIdGenerator: sessionIdGenerator, + }); + statelessServer = new McpServerClass(serverInfo, mcpServerOptions); await initializeServer(statelessServer); await statelessServer.connect(statelessTransport); } @@ -460,7 +493,7 @@ export function initializeMcpApiHandler( }); logger.log("Got new SSE connection"); assert(sseMessageEndpoint, "sseMessageEndpoint is required"); - const transport = new SSEServerTransport(sseMessageEndpoint, res); + const transport = new SSEServerTransportClass(sseMessageEndpoint, res); const sessionId = transport.sessionId; const eventRes = new EventEmittingResponse( @@ -476,8 +509,8 @@ export function initializeMcpApiHandler( undefined, }); - const server = new McpServer(serverInfo, serverOptions); - + const server = new McpServerClass(serverInfo, serverOptions); + // Track cleanup state to prevent double cleanup let isCleanedUp = false; let interval: NodeJS.Timeout | null = null; @@ -485,14 +518,14 @@ export function initializeMcpApiHandler( let abortHandler: (() => void) | null = null; let handleMessage: ((message: string) => Promise) | null = null; let logs: { type: LogLevel; messages: string[]; }[] = []; - + // Comprehensive cleanup function const cleanup = async (reason: string) => { if (isCleanedUp) return; isCleanedUp = true; - + logger.log(`Cleaning up SSE connection: ${reason}`); - + // Clear timers if (timeout) { clearTimeout(timeout); @@ -502,13 +535,13 @@ export function initializeMcpApiHandler( clearInterval(interval); interval = null; } - + // Remove abort event listener if (abortHandler) { req.signal.removeEventListener("abort", abortHandler); abortHandler = null; } - + // Unsubscribe from Redis if (handleMessage) { try { @@ -518,7 +551,7 @@ export function initializeMcpApiHandler( logger.error("Error unsubscribing from Redis:", error); } } - + // Close server and transport try { if (server?.server) { @@ -528,30 +561,30 @@ export function initializeMcpApiHandler( await transport.close(); } } catch (error) { - logger.error("Error closing server/transport:", error); + logger.error("Error closing stale server:", error); } - + // Remove server from array and WeakMap servers = servers.filter((s) => s !== server); serverMetadata.delete(server); - + // End session event eventRes.endSession("SSE"); - + // Clear logs array to free memory logs = []; - + // End response if not already ended if (!res.headersSent) { res.statusCode = 200; res.end(); } }; - + try { await initializeServer(server); servers.push(server); - + // Store metadata in WeakMap serverMetadata.set(server, { sessionId, @@ -674,12 +707,12 @@ export function initializeMcpApiHandler( abortHandler = () => resolveTimeout("client hang up"); req.signal.addEventListener("abort", abortHandler); - + // Handle response close event res.on("close", () => { cleanup("response closed"); }); - + // Handle response error event res.on("error", (error) => { logger.error("Response error:", error); @@ -736,24 +769,24 @@ export function initializeMcpApiHandler( let timeout: NodeJS.Timeout | null = null; let hasResponded = false; let isCleanedUp = false; - + // Cleanup function to ensure all resources are freed const cleanup = async () => { if (isCleanedUp) return; isCleanedUp = true; - + if (timeout) { clearTimeout(timeout); timeout = null; } - + try { await redis.unsubscribe(`responses:${sessionId}:${requestId}`); } catch (error) { logger.error("Error unsubscribing from Redis response channel:", error); } }; - + // Safe response handler to prevent double res.end() const sendResponse = async (status: number, body: string) => { if (!hasResponded) { @@ -763,7 +796,7 @@ export function initializeMcpApiHandler( await cleanup(); } }; - + // Response handler const handleResponse = async (message: string) => { try { @@ -805,7 +838,7 @@ export function initializeMcpApiHandler( await cleanup(); } }); - + // Handle response error event res.on("error", async (error) => { logger.error("Response error in message handler:", error); @@ -880,9 +913,9 @@ function createFakeIncomingMessage( req.method = method; req.url = url; req.headers = headers; - req.rawHeaders = Object.entries(headers).flatMap(([key, value]) => - Array.isArray(value) - ? value.flatMap(v => [key, v]) + req.rawHeaders = Object.entries(headers).flatMap(([key, value]) => + Array.isArray(value) + ? value.flatMap(v => [key, v]) : [key, value ?? ""] );