From 19d8069aea064c46a1ad98af0346f118995bb8ce Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Apr 2026 19:08:38 +0100 Subject: [PATCH] fix: lazy-start gateway mcp loopback --- src/agents/cli-runner/prepare.ts | 12 ++++++- src/gateway/mcp-http.test.ts | 15 ++++++++ src/gateway/mcp-http.ts | 42 +++++++++++++++++++++- src/gateway/server-startup-early.test.ts | 44 ++++++++++++++++++++++++ src/gateway/server-startup-early.ts | 10 ------ src/gateway/server.impl.ts | 4 +-- 6 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 src/gateway/server-startup-early.test.ts diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index ffdc6d4b0d0..e5d83004656 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -1,3 +1,4 @@ +import { ensureMcpLoopbackServer } from "../../gateway/mcp-http.js"; import { createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, @@ -36,6 +37,7 @@ const prepareDeps = { makeBootstrapWarn: makeBootstrapWarnImpl, resolveBootstrapContextForRun: resolveBootstrapContextForRunImpl, getActiveMcpLoopbackRuntime, + ensureMcpLoopbackServer, createMcpLoopbackServerConfig, resolveOpenClawDocsPath: async ( params: Parameters[0], @@ -114,9 +116,17 @@ export async function prepareCliRunContext( config: params.config, agentId: params.agentId, }); - const mcpLoopbackRuntime = backendResolved.bundleMcp + let mcpLoopbackRuntime = backendResolved.bundleMcp ? prepareDeps.getActiveMcpLoopbackRuntime() : undefined; + if (backendResolved.bundleMcp && !mcpLoopbackRuntime) { + try { + await prepareDeps.ensureMcpLoopbackServer(); + } catch (error) { + cliBackendLog.warn(`mcp loopback server failed to start: ${String(error)}`); + } + mcpLoopbackRuntime = prepareDeps.getActiveMcpLoopbackRuntime(); + } const preparedBackend = await prepareCliBundleMcpConfig({ enabled: backendResolved.bundleMcp, mode: backendResolved.bundleMcpMode, diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 173bac48d73..84c4131de31 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -32,7 +32,9 @@ vi.mock("./tool-resolution.js", () => ({ import { createMcpLoopbackServerConfig, + closeMcpLoopbackServer, getActiveMcpLoopbackRuntime, + ensureMcpLoopbackServer, startMcpLoopbackServer, } from "./mcp-http.js"; @@ -162,6 +164,19 @@ describe("mcp loopback server", () => { expect(getActiveMcpLoopbackRuntime()).toBeUndefined(); }); + it("starts the loopback server lazily and reuses the same singleton", async () => { + expect(getActiveMcpLoopbackRuntime()).toBeUndefined(); + + const first = await ensureMcpLoopbackServer(0); + const second = await ensureMcpLoopbackServer(0); + + expect(second).toBe(first); + expect(getActiveMcpLoopbackRuntime()?.port).toBe(first.port); + + await closeMcpLoopbackServer(); + expect(getActiveMcpLoopbackRuntime()).toBeUndefined(); + }); + it("returns 401 when the bearer token is missing", async () => { server = await startMcpLoopbackServer(0); const response = await sendRaw({ diff --git a/src/gateway/mcp-http.ts b/src/gateway/mcp-http.ts index 759660f3eb7..2b91e4e8921 100644 --- a/src/gateway/mcp-http.ts +++ b/src/gateway/mcp-http.ts @@ -23,6 +23,14 @@ export { getActiveMcpLoopbackRuntime, } from "./mcp-http.loopback-runtime.js"; +type McpLoopbackServer = { + port: number; + close: () => Promise; +}; + +let activeMcpLoopbackServer: McpLoopbackServer | undefined; +let activeMcpLoopbackServerPromise: Promise | null = null; + export async function startMcpLoopbackServer(port = 0): Promise<{ port: number; close: () => Promise; @@ -98,13 +106,16 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ setActiveMcpLoopbackRuntime({ port: address.port, token }); logDebug(`mcp loopback listening on 127.0.0.1:${address.port}`); - return { + const server: McpLoopbackServer = { port: address.port, close: () => new Promise((resolve, reject) => { httpServer.close((error) => { if (!error) { clearActiveMcpLoopbackRuntime(token); + if (activeMcpLoopbackServer === server) { + activeMcpLoopbackServer = undefined; + } } if (error) { reject(error); @@ -114,4 +125,33 @@ export async function startMcpLoopbackServer(port = 0): Promise<{ }); }), }; + return server; +} + +export async function ensureMcpLoopbackServer(port = 0): Promise { + if (activeMcpLoopbackServer) { + return activeMcpLoopbackServer; + } + if (!activeMcpLoopbackServerPromise) { + activeMcpLoopbackServerPromise = startMcpLoopbackServer(port) + .then((server) => { + activeMcpLoopbackServer = server; + return server; + }) + .finally(() => { + activeMcpLoopbackServerPromise = null; + }); + } + return activeMcpLoopbackServerPromise; +} + +export async function closeMcpLoopbackServer(): Promise { + const server = + activeMcpLoopbackServer ?? + (activeMcpLoopbackServerPromise ? await activeMcpLoopbackServerPromise : undefined); + if (!server) { + return; + } + activeMcpLoopbackServer = undefined; + await server.close(); } diff --git a/src/gateway/server-startup-early.test.ts b/src/gateway/server-startup-early.test.ts new file mode 100644 index 00000000000..d4e5473513f --- /dev/null +++ b/src/gateway/server-startup-early.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { startGatewayEarlyRuntime } from "./server-startup-early.js"; + +describe("startGatewayEarlyRuntime", () => { + it("does not eagerly start the MCP loopback server", async () => { + const earlyRuntime = await startGatewayEarlyRuntime({ + minimalTestGateway: true, + cfgAtStart: {} as never, + port: 18_789, + gatewayTls: { enabled: false }, + tailscaleMode: "off" as never, + log: { + info: () => {}, + warn: () => {}, + }, + logDiscovery: { + info: () => {}, + warn: () => {}, + }, + nodeRegistry: {} as never, + broadcast: () => {}, + nodeSendToAllSubscribed: () => {}, + getPresenceVersion: () => 0, + getHealthVersion: () => 0, + refreshGatewayHealthSnapshot: () => {}, + logHealth: () => {}, + dedupe: () => {}, + chatAbortControllers: new Map(), + chatRunState: new Map(), + chatRunBuffers: new Map(), + chatDeltaSentAt: new Map(), + chatDeltaLastBroadcastLen: new Map(), + removeChatRun: () => {}, + agentRunSeq: () => 0, + nodeSendToSession: () => {}, + skillsRefreshDelayMs: 30_000, + getSkillsRefreshTimer: () => null, + setSkillsRefreshTimer: () => {}, + loadConfig: () => ({}) as never, + }); + + expect(earlyRuntime).not.toHaveProperty("mcpServer"); + }); +}); diff --git a/src/gateway/server-startup-early.ts b/src/gateway/server-startup-early.ts index f5a7510b620..1d09585c859 100644 --- a/src/gateway/server-startup-early.ts +++ b/src/gateway/server-startup-early.ts @@ -8,7 +8,6 @@ import { setSkillsRemoteRegistry, } from "../infra/skills-remote.js"; import { startTaskRegistryMaintenance } from "../tasks/task-registry.maintenance.js"; -import { startMcpLoopbackServer } from "./mcp-http.js"; import { startGatewayDiscovery } from "./server-discovery-runtime.js"; import { startGatewayMaintenanceTimers } from "./server-maintenance.js"; @@ -54,14 +53,6 @@ export async function startGatewayEarlyRuntime(params: { setSkillsRefreshTimer: (timer: ReturnType | null) => void; loadConfig: () => OpenClawConfig; }) { - let mcpServer: { port: number; close: () => Promise } | undefined; - try { - mcpServer = await startMcpLoopbackServer(0); - params.log.info(`MCP loopback server listening on http://127.0.0.1:${mcpServer.port}/mcp`); - } catch (error) { - params.log.warn(`MCP loopback server failed to start: ${String(error)}`); - } - let bonjourStop: (() => Promise) | null = null; if (!params.minimalTestGateway) { const machineDisplayName = await getMachineDisplayName(); @@ -127,7 +118,6 @@ export async function startGatewayEarlyRuntime(params: { }); return { - mcpServer, bonjourStop, skillsChangeUnsub, maintenance, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index e890c7674cd..528e4577f10 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -39,6 +39,7 @@ import { import { runSetupWizard } from "../wizard/setup.js"; import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js"; import { resolveGatewayAuth } from "./auth.js"; +import { closeMcpLoopbackServer } from "./mcp-http.js"; import { createGatewayAuxHandlers } from "./server-aux-handlers.js"; import { createChannelManager } from "./server-channels.js"; import { createGatewayCloseHandler, runGatewayClosePrelude } from "./server-close.js"; @@ -502,7 +503,7 @@ export async function startGatewayServer( stopModelPricingRefresh: runtimeState.stopModelPricingRefresh, stopChannelHealthMonitor: () => runtimeState?.channelHealthMonitor?.stop(), clearSecretsRuntimeSnapshot, - closeMcpServer: async () => await runtimeState?.mcpServer?.close(), + closeMcpServer: async () => await closeMcpLoopbackServer(), }); const closeOnStartupFailure = async () => { await runClosePrelude(); @@ -574,7 +575,6 @@ export async function startGatewayServer( }, loadConfig, }); - runtimeState.mcpServer = earlyRuntime.mcpServer; runtimeState.bonjourStop = earlyRuntime.bonjourStop; runtimeState.skillsChangeUnsub = earlyRuntime.skillsChangeUnsub; if (earlyRuntime.maintenance) {