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 ?? ""] );