fix: lazy-start gateway mcp loopback

This commit is contained in:
Peter Steinberger
2026-04-12 19:08:38 +01:00
parent 000fc7f233
commit 19d8069aea
6 changed files with 113 additions and 14 deletions

View File

@@ -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<typeof import("../docs-path.js").resolveOpenClawDocsPath>[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,

View File

@@ -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({

View File

@@ -23,6 +23,14 @@ export {
getActiveMcpLoopbackRuntime,
} from "./mcp-http.loopback-runtime.js";
type McpLoopbackServer = {
port: number;
close: () => Promise<void>;
};
let activeMcpLoopbackServer: McpLoopbackServer | undefined;
let activeMcpLoopbackServerPromise: Promise<McpLoopbackServer> | null = null;
export async function startMcpLoopbackServer(port = 0): Promise<{
port: number;
close: () => Promise<void>;
@@ -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<void>((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<McpLoopbackServer> {
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<void> {
const server =
activeMcpLoopbackServer ??
(activeMcpLoopbackServerPromise ? await activeMcpLoopbackServerPromise : undefined);
if (!server) {
return;
}
activeMcpLoopbackServer = undefined;
await server.close();
}

View File

@@ -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");
});
});

View File

@@ -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<typeof setTimeout> | null) => void;
loadConfig: () => OpenClawConfig;
}) {
let mcpServer: { port: number; close: () => Promise<void> } | 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<void>) | null = null;
if (!params.minimalTestGateway) {
const machineDisplayName = await getMachineDisplayName();
@@ -127,7 +118,6 @@ export async function startGatewayEarlyRuntime(params: {
});
return {
mcpServer,
bonjourStop,
skillsChangeUnsub,
maintenance,

View File

@@ -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) {