From b56032b67d6fb1c553fa354677c2235205897179 Mon Sep 17 00:00:00 2001 From: Kelly Chan Date: Sat, 17 Jan 2026 02:46:02 +0800 Subject: [PATCH] fix: lazy-load MCP SDK to avoid global state pollution in Next.js The @modelcontextprotocol/sdk has dependencies (via undici) that modify the global Response object at import time. This breaks Next.js App Routes which rely on instanceof Response checks, causing "No response is returned" errors. This fix: - Converts eager SDK imports to type-only imports - Adds a lazy loadSdk() function that dynamically imports SDK modules - Defers StreamableHTTPServerTransport creation to first request - Calls loadSdk() before SDK usage in both HTTP and SSE endpoints This ensures the SDK is only loaded after Next.js has fully initialized. Co-Authored-By: Claude Opus 4.5 --- src/handler/mcp-api-handler.ts | 62 ++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/src/handler/mcp-api-handler.ts b/src/handler/mcp-api-handler.ts index 5dd92e2..840dc7b 100644 --- a/src/handler/mcp-api-handler.ts +++ b/src/handler/mcp-api-handler.ts @@ -1,5 +1,10 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; +// IMPORTANT: These imports are lazy-loaded to avoid polluting global state at startup. +// The @modelcontextprotocol/sdk has dependencies (via undici) that modify global Response, +// which breaks Next.js App Routes. By using dynamic imports, we defer loading until +// the first request, after Next.js has fully initialized. +import type { McpServer as McpServerType } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { SSEServerTransport as SSEServerTransportType } from "@modelcontextprotocol/sdk/server/sse.js"; +import type { StreamableHTTPServerTransport as StreamableHTTPServerTransportType } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { type IncomingHttpHeaders, IncomingMessage, @@ -8,7 +13,6 @@ 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 { BodyType } from "./server-response-adapter"; import assert from "node:assert"; import type { @@ -19,10 +23,31 @@ import type { } from "../lib/log-helper"; import { createEvent } from "../lib/log-helper"; import { EventEmittingResponse } from "../lib/event-emitter.js"; -import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types"; import { getAuthContext } from "../auth/auth-context"; import { ServerOptions } from "."; +// Lazy-loaded SDK modules - populated on first use +let McpServer: typeof McpServerType; +let SSEServerTransport: typeof SSEServerTransportType; +let StreamableHTTPServerTransport: typeof StreamableHTTPServerTransportType; +let sdkLoaded = false; + +async function loadSdk(): Promise { + if (sdkLoaded) return; + + const [mcpModule, sseModule, streamableModule] = await Promise.all([ + import("@modelcontextprotocol/sdk/server/mcp.js"), + import("@modelcontextprotocol/sdk/server/sse.js"), + import("@modelcontextprotocol/sdk/server/streamableHttp.js"), + ]); + + McpServer = mcpModule.McpServer; + SSEServerTransport = sseModule.SSEServerTransport; + StreamableHTTPServerTransport = streamableModule.StreamableHTTPServerTransport; + sdkLoaded = true; +} + interface SerializedRequest { requestId: string; url: string; @@ -186,10 +211,10 @@ let redisPublisher: ReturnType; let redis: ReturnType; // WeakMap to track server metadata without preventing GC -const serverMetadata = new WeakMap(); // Periodic cleanup interval @@ -230,8 +255,8 @@ async function initializeRedis({ export function initializeMcpApiHandler( initializeServer: - | ((server: McpServer) => Promise) - | ((server: McpServer) => void), + | ((server: McpServerType) => Promise) + | ((server: McpServerType) => void), serverOptions: ServerOptions = {}, config: Config = { redisUrl: process.env.REDIS_URL || process.env.KV_URL, @@ -275,13 +300,12 @@ export function initializeMcpApiHandler( const logger = createLogger(verboseLogs); - let servers: McpServer[] = []; + let servers: McpServerType[] = []; + + // These are lazy-initialized on first request to avoid polluting global state at startup + let statelessServer: McpServerType; + let statelessTransport: StreamableHTTPServerTransportType; - let statelessServer: McpServer; - const statelessTransport = new StreamableHTTPServerTransport({ - sessionIdGenerator: sessionIdGenerator, - }); - // Start periodic cleanup if not already running if (!cleanupInterval) { cleanupInterval = setInterval(() => { @@ -358,12 +382,19 @@ export function initializeMcpApiHandler( } if (req.method === "POST") { + // Load SDK modules lazily on first request to avoid global state pollution at startup + await loadSdk(); + const eventRes = new EventEmittingResponse( createFakeIncomingMessage(), config.onEvent ); if (!statelessServer) { + // Create transport lazily on first use + statelessTransport = new StreamableHTTPServerTransport({ + sessionIdGenerator: sessionIdGenerator, + }); statelessServer = new McpServer(serverInfo, mcpServerOptions); await initializeServer(statelessServer); await statelessServer.connect(statelessTransport); @@ -454,6 +485,9 @@ export function initializeMcpApiHandler( return; } + // Load SDK modules lazily on first request to avoid global state pollution at startup + await loadSdk(); + const { redis, redisPublisher } = await initializeRedis({ redisUrl, logger,