Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-global-response-pollution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"mcp-handler": patch
---

fix: restore globalThis.Response after lazy-loading MCP SDK to prevent Next.js route handler failures
109 changes: 71 additions & 38 deletions src/handler/mcp-api-handler.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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)`);
Expand All @@ -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") {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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(
Expand All @@ -476,23 +509,23 @@ 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;
let timeout: NodeJS.Timeout | null = null;
let abortHandler: (() => void) | null = null;
let handleMessage: ((message: string) => Promise<void>) | 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);
Expand All @@ -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 {
Expand All @@ -518,7 +551,7 @@ export function initializeMcpApiHandler(
logger.error("Error unsubscribing from Redis:", error);
}
}

// Close server and transport
try {
if (server?.server) {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -763,7 +796,7 @@ export function initializeMcpApiHandler(
await cleanup();
}
};

// Response handler
const handleResponse = async (message: string) => {
try {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 ?? ""]
);

Expand Down