Skip to content

Commit 10a2e8d

Browse files
committed
fix: restore globalThis.Response after lazy-loading MCP SDK
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
1 parent ce0f0ce commit 10a2e8d

2 files changed

Lines changed: 73 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"mcp-handler": patch
3+
---
4+
5+
fix: restore globalThis.Response after lazy-loading MCP SDK to prevent Next.js route handler failures

src/handler/mcp-api-handler.ts

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2-
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
1+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2+
import type { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
33
import {
44
type IncomingHttpHeaders,
55
IncomingMessage,
@@ -8,7 +8,7 @@ import {
88
import { createClient } from "redis";
99
import { Socket } from "node:net";
1010
import { Readable } from "node:stream";
11-
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
11+
import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
1212
import type { BodyType } from "./server-response-adapter";
1313
import assert from "node:assert";
1414
import type {
@@ -19,6 +19,36 @@ import { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types";
1919
import { getAuthContext } from "../auth/auth-context";
2020
import { ServerOptions } from ".";
2121

22+
// Lazy-loaded SDK modules. The @modelcontextprotocol/sdk has transitive
23+
// dependencies (via undici) that replace globalThis.Response at import
24+
// time. Eagerly importing the SDK at the module level causes Next.js
25+
// route handlers to fail the `instanceof Response` check with
26+
// "No response is returned from route handler" errors.
27+
// See: https://github.com/vercel/mcp-handler/issues/140
28+
let McpServerClass: typeof McpServer;
29+
let SSEServerTransportClass: typeof SSEServerTransport;
30+
let WebStandardStreamableHTTPServerTransportClass: typeof WebStandardStreamableHTTPServerTransport;
31+
32+
async function loadSdk() {
33+
if (McpServerClass) return;
34+
35+
const OriginalResponse = globalThis.Response;
36+
37+
const [mcpMod, sseMod, httpMod] = await Promise.all([
38+
import("@modelcontextprotocol/sdk/server/mcp.js"),
39+
import("@modelcontextprotocol/sdk/server/sse.js"),
40+
import("@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"),
41+
]);
42+
43+
// Restore the original Response so that other route handlers in the
44+
// same process continue to pass Next.js's instanceof check.
45+
globalThis.Response = OriginalResponse;
46+
47+
McpServerClass = mcpMod.McpServer;
48+
SSEServerTransportClass = sseMod.SSEServerTransport;
49+
WebStandardStreamableHTTPServerTransportClass = httpMod.WebStandardStreamableHTTPServerTransport;
50+
}
51+
2252
interface SerializedRequest {
2353
requestId: string;
2454
url: string;
@@ -275,13 +305,13 @@ export function initializeMcpApiHandler(
275305

276306
// Note: In SDK 1.26.0+, stateless transports cannot be reused across requests.
277307
// We create a fresh transport and server per POST request.
278-
308+
279309
// Start periodic cleanup if not already running
280310
if (!cleanupInterval) {
281311
cleanupInterval = setInterval(() => {
282312
const now = Date.now();
283313
const staleThreshold = 5 * 60 * 1000; // 5 minutes
284-
314+
285315
servers = servers.filter(server => {
286316
const metadata = serverMetadata.get(server);
287317
if (!metadata) {
@@ -296,7 +326,7 @@ export function initializeMcpApiHandler(
296326
}
297327
return false;
298328
}
299-
329+
300330
const age = now - metadata.createdAt.getTime();
301331
if (age > staleThreshold) {
302332
logger.log(`Removing stale server (session ${metadata.sessionId}, age: ${age}ms)`);
@@ -313,13 +343,15 @@ export function initializeMcpApiHandler(
313343
serverMetadata.delete(server);
314344
return false;
315345
}
316-
346+
317347
return true;
318348
});
319349
}, 30 * 1000); // Run every 30 seconds
320350
}
321351

322352
return async function mcpApiHandler(req: Request, res: ServerResponse) {
353+
await loadSdk();
354+
323355
const url = new URL(req.url || "", "https://example.com");
324356
if (url.pathname === streamableHttpEndpoint) {
325357
if (req.method === "GET") {
@@ -369,10 +401,10 @@ export function initializeMcpApiHandler(
369401
// In SDK 1.26.0+, stateless transports cannot be reused across requests.
370402
// Create a fresh transport and server per POST request and use the
371403
// WebStandard transport directly since we already have a Web Request.
372-
const transport = new WebStandardStreamableHTTPServerTransport({
404+
const transport = new WebStandardStreamableHTTPServerTransportClass({
373405
sessionIdGenerator: sessionIdGenerator,
374406
});
375-
const server = new McpServer(serverInfo, mcpServerOptions);
407+
const server = new McpServerClass(serverInfo, mcpServerOptions);
376408
await initializeServer(server);
377409
await server.connect(transport);
378410

@@ -472,7 +504,7 @@ export function initializeMcpApiHandler(
472504
});
473505
logger.log("Got new SSE connection");
474506
assert(sseMessageEndpoint, "sseMessageEndpoint is required");
475-
const transport = new SSEServerTransport(sseMessageEndpoint, res);
507+
const transport = new SSEServerTransportClass(sseMessageEndpoint, res);
476508
const sessionId = transport.sessionId;
477509

478510
const eventRes = new EventEmittingResponse(
@@ -488,23 +520,23 @@ export function initializeMcpApiHandler(
488520
undefined,
489521
});
490522

491-
const server = new McpServer(serverInfo, serverOptions);
492-
523+
const server = new McpServerClass(serverInfo, serverOptions);
524+
493525
// Track cleanup state to prevent double cleanup
494526
let isCleanedUp = false;
495527
let interval: NodeJS.Timeout | null = null;
496528
let timeout: NodeJS.Timeout | null = null;
497529
let abortHandler: (() => void) | null = null;
498530
let handleMessage: ((message: string) => Promise<void>) | null = null;
499531
let logs: { type: LogLevel; messages: string[]; }[] = [];
500-
532+
501533
// Comprehensive cleanup function
502534
const cleanup = async (reason: string) => {
503535
if (isCleanedUp) return;
504536
isCleanedUp = true;
505-
537+
506538
logger.log(`Cleaning up SSE connection: ${reason}`);
507-
539+
508540
// Clear timers
509541
if (timeout) {
510542
clearTimeout(timeout);
@@ -514,13 +546,13 @@ export function initializeMcpApiHandler(
514546
clearInterval(interval);
515547
interval = null;
516548
}
517-
549+
518550
// Remove abort event listener
519551
if (abortHandler) {
520552
req.signal.removeEventListener("abort", abortHandler);
521553
abortHandler = null;
522554
}
523-
555+
524556
// Unsubscribe from Redis
525557
if (handleMessage) {
526558
try {
@@ -530,7 +562,7 @@ export function initializeMcpApiHandler(
530562
logger.error("Error unsubscribing from Redis:", error);
531563
}
532564
}
533-
565+
534566
// Close server and transport
535567
try {
536568
if (server?.server) {
@@ -540,30 +572,30 @@ export function initializeMcpApiHandler(
540572
await transport.close();
541573
}
542574
} catch (error) {
543-
logger.error("Error closing server/transport:", error);
575+
logger.error("Error closing stale server:", error);
544576
}
545-
577+
546578
// Remove server from array and WeakMap
547579
servers = servers.filter((s) => s !== server);
548580
serverMetadata.delete(server);
549-
581+
550582
// End session event
551583
eventRes.endSession("SSE");
552-
584+
553585
// Clear logs array to free memory
554586
logs = [];
555-
587+
556588
// End response if not already ended
557589
if (!res.headersSent) {
558590
res.statusCode = 200;
559591
res.end();
560592
}
561593
};
562-
594+
563595
try {
564596
await initializeServer(server);
565597
servers.push(server);
566-
598+
567599
// Store metadata in WeakMap
568600
serverMetadata.set(server, {
569601
sessionId,
@@ -686,12 +718,12 @@ export function initializeMcpApiHandler(
686718

687719
abortHandler = () => resolveTimeout("client hang up");
688720
req.signal.addEventListener("abort", abortHandler);
689-
721+
690722
// Handle response close event
691723
res.on("close", () => {
692724
cleanup("response closed");
693725
});
694-
726+
695727
// Handle response error event
696728
res.on("error", (error) => {
697729
logger.error("Response error:", error);
@@ -748,24 +780,24 @@ export function initializeMcpApiHandler(
748780
let timeout: NodeJS.Timeout | null = null;
749781
let hasResponded = false;
750782
let isCleanedUp = false;
751-
783+
752784
// Cleanup function to ensure all resources are freed
753785
const cleanup = async () => {
754786
if (isCleanedUp) return;
755787
isCleanedUp = true;
756-
788+
757789
if (timeout) {
758790
clearTimeout(timeout);
759791
timeout = null;
760792
}
761-
793+
762794
try {
763795
await redis.unsubscribe(`responses:${sessionId}:${requestId}`);
764796
} catch (error) {
765797
logger.error("Error unsubscribing from Redis response channel:", error);
766798
}
767799
};
768-
800+
769801
// Safe response handler to prevent double res.end()
770802
const sendResponse = async (status: number, body: string) => {
771803
if (!hasResponded) {
@@ -775,7 +807,7 @@ export function initializeMcpApiHandler(
775807
await cleanup();
776808
}
777809
};
778-
810+
779811
// Response handler
780812
const handleResponse = async (message: string) => {
781813
try {
@@ -817,7 +849,7 @@ export function initializeMcpApiHandler(
817849
await cleanup();
818850
}
819851
});
820-
852+
821853
// Handle response error event
822854
res.on("error", async (error) => {
823855
logger.error("Response error in message handler:", error);
@@ -892,9 +924,9 @@ function createFakeIncomingMessage(
892924
req.method = method;
893925
req.url = url;
894926
req.headers = headers;
895-
req.rawHeaders = Object.entries(headers).flatMap(([key, value]) =>
896-
Array.isArray(value)
897-
? value.flatMap(v => [key, v])
927+
req.rawHeaders = Object.entries(headers).flatMap(([key, value]) =>
928+
Array.isArray(value)
929+
? value.flatMap(v => [key, v])
898930
: [key, value ?? ""]
899931
);
900932

0 commit comments

Comments
 (0)