Make the agent MCP server stateless to fix session leak

The agent-facing MCP server (/mcp/agents) ran in stateful session mode,
keeping a StreamableHTTPServerTransport plus its full Server and Zod tool
schemas alive per agent connection, keyed by session id. The map was only
pruned on the SDK's onclose/onsessionclosed callbacks, which fire only on a
clean DELETE. Agents that are killed or exit without one never get reaped, so
dead sessions accumulate for the life of the daemon (observed ~77MB across 132
stale transports in a heap snapshot).

Listing and calling tools needs no cross-request state, so each request now
builds a fresh server + transport and tears both down when the response closes.
Per-request cost is ~0.6-0.9ms and ~760KB of short-lived allocation; nothing is
retained between requests. GET and DELETE return 405, which the MCP client
tolerates (it never receives a session id).
This commit is contained in:
Mohamed Boudra
2026-06-17 09:18:29 +07:00
parent 7b4b27d877
commit 69741e0770
+33 -55
View File
@@ -6,7 +6,6 @@ import { randomUUID } from "node:crypto";
import { hostname as getHostname } from "node:os"; import { hostname as getHostname } from "node:os";
import path from "node:path"; import path from "node:path";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { z } from "zod"; import { z } from "zod";
import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js";
@@ -147,8 +146,6 @@ import {
type DaemonAuthConfig, type DaemonAuthConfig,
} from "./auth.js"; } from "./auth.js";
type AgentMcpTransportMap = Map<string, StreamableHTTPServerTransport>;
const MAX_MCP_DEBUG_BATCH_ITEMS = 10; const MAX_MCP_DEBUG_BATCH_ITEMS = 10;
const REDACTED_LOG_VALUE = "[redacted]"; const REDACTED_LOG_VALUE = "[redacted]";
const DOWNLOAD_OPEN_FLAGS = const DOWNLOAD_OPEN_FLAGS =
@@ -821,9 +818,8 @@ export async function createPaseoDaemon(
let agentMcpBaseUrl: string | null = null; let agentMcpBaseUrl: string | null = null;
if (mcpEnabled) { if (mcpEnabled) {
const agentMcpRoute = "/mcp/agents"; const agentMcpRoute = "/mcp/agents";
const agentMcpTransports: AgentMcpTransportMap = new Map();
const createAgentMcpTransport = async (callerAgentId?: string) => { const createAgentMcpSession = async (callerAgentId?: string) => {
const agentMcpServer = await createAgentMcpServer({ const agentMcpServer = await createAgentMcpServer({
agentManager, agentManager,
agentStorage, agentStorage,
@@ -895,34 +891,25 @@ export async function createPaseoDaemon(
logger, logger,
}); });
// Stateless mode: each HTTP request builds a fresh server + transport that is
// torn down when the response closes, so no per-session state is retained between
// requests. The agent control plane only lists and calls tools, neither of which
// needs cross-request state, so sessions would only pin memory for the life of the
// daemon (agents that exit without a clean DELETE never get reaped).
const transport = new StreamableHTTPServerTransport({ const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(), sessionIdGenerator: undefined,
onsessioninitialized: (sessionId) => {
agentMcpTransports.set(sessionId, transport);
logger.debug({ sessionId }, "Agent MCP session initialized");
},
onsessionclosed: (sessionId) => {
agentMcpTransports.delete(sessionId);
logger.debug({ sessionId }, "Agent MCP session closed");
},
// NOTE: We enforce a Vite-like host allowlist at the app/websocket layer. // NOTE: We enforce a Vite-like host allowlist at the app/websocket layer.
// StreamableHTTPServerTransport's built-in check requires exact Host header matches. // StreamableHTTPServerTransport's built-in check requires exact Host header matches.
enableDnsRebindingProtection: false, enableDnsRebindingProtection: false,
}); });
Object.assign(transport, { Object.assign(transport, {
onclose: () => {
if (transport.sessionId) {
agentMcpTransports.delete(transport.sessionId);
}
},
onerror: (err: Error) => { onerror: (err: Error) => {
logger.error({ err }, "Agent MCP transport error"); logger.error({ err }, "Agent MCP transport error");
}, },
}); });
await agentMcpServer.connect(transport); await agentMcpServer.connect(transport);
return transport; return { server: agentMcpServer, transport };
}; };
const runAgentMcpRequest = async ( const runAgentMcpRequest = async (
@@ -956,41 +943,32 @@ export async function createPaseoDaemon(
); );
} }
try { try {
const sessionId = req.header("mcp-session-id"); // Stateless: GET (standalone SSE) and DELETE (session termination) have no
let transport = sessionId ? agentMcpTransports.get(sessionId) : undefined; // meaning without sessions. The MCP client tolerates 405 on the GET stream
// and never issues a DELETE because it is never handed a session id.
if (!transport) { if (req.method !== "POST") {
if (req.method !== "POST") { res.status(405).json({
res.status(400).json({ jsonrpc: "2.0",
jsonrpc: "2.0", error: {
error: { code: -32000,
code: -32000, message: "Method not allowed",
message: "Missing or invalid MCP session", },
}, id: null,
id: null, });
}); return;
return;
}
if (!isInitializeRequest(req.body)) {
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Initialization request expected",
},
id: null,
});
return;
}
const callerAgentIdRaw = req.query.callerAgentId;
let callerAgentId: string | undefined;
if (typeof callerAgentIdRaw === "string") {
callerAgentId = callerAgentIdRaw;
} else if (Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string") {
callerAgentId = callerAgentIdRaw[0];
}
transport = await createAgentMcpTransport(callerAgentId);
} }
const callerAgentIdRaw = req.query.callerAgentId;
let callerAgentId: string | undefined;
if (typeof callerAgentIdRaw === "string") {
callerAgentId = callerAgentIdRaw;
} else if (Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string") {
callerAgentId = callerAgentIdRaw[0];
}
const { server, transport } = await createAgentMcpSession(callerAgentId);
res.on("close", () => {
void transport.close();
void server.close();
});
await transport.handleRequest( await transport.handleRequest(
req as unknown as IncomingMessage, req as unknown as IncomingMessage,