Skip to content

Commit 19d8069

Browse files
committed
fix: lazy-start gateway mcp loopback
1 parent 000fc7f commit 19d8069

6 files changed

Lines changed: 113 additions & 14 deletions

File tree

src/agents/cli-runner/prepare.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ensureMcpLoopbackServer } from "../../gateway/mcp-http.js";
12
import {
23
createMcpLoopbackServerConfig,
34
getActiveMcpLoopbackRuntime,
@@ -36,6 +37,7 @@ const prepareDeps = {
3637
makeBootstrapWarn: makeBootstrapWarnImpl,
3738
resolveBootstrapContextForRun: resolveBootstrapContextForRunImpl,
3839
getActiveMcpLoopbackRuntime,
40+
ensureMcpLoopbackServer,
3941
createMcpLoopbackServerConfig,
4042
resolveOpenClawDocsPath: async (
4143
params: Parameters<typeof import("../docs-path.js").resolveOpenClawDocsPath>[0],
@@ -114,9 +116,17 @@ export async function prepareCliRunContext(
114116
config: params.config,
115117
agentId: params.agentId,
116118
});
117-
const mcpLoopbackRuntime = backendResolved.bundleMcp
119+
let mcpLoopbackRuntime = backendResolved.bundleMcp
118120
? prepareDeps.getActiveMcpLoopbackRuntime()
119121
: undefined;
122+
if (backendResolved.bundleMcp && !mcpLoopbackRuntime) {
123+
try {
124+
await prepareDeps.ensureMcpLoopbackServer();
125+
} catch (error) {
126+
cliBackendLog.warn(`mcp loopback server failed to start: ${String(error)}`);
127+
}
128+
mcpLoopbackRuntime = prepareDeps.getActiveMcpLoopbackRuntime();
129+
}
120130
const preparedBackend = await prepareCliBundleMcpConfig({
121131
enabled: backendResolved.bundleMcp,
122132
mode: backendResolved.bundleMcpMode,

src/gateway/mcp-http.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ vi.mock("./tool-resolution.js", () => ({
3232

3333
import {
3434
createMcpLoopbackServerConfig,
35+
closeMcpLoopbackServer,
3536
getActiveMcpLoopbackRuntime,
37+
ensureMcpLoopbackServer,
3638
startMcpLoopbackServer,
3739
} from "./mcp-http.js";
3840

@@ -162,6 +164,19 @@ describe("mcp loopback server", () => {
162164
expect(getActiveMcpLoopbackRuntime()).toBeUndefined();
163165
});
164166

167+
it("starts the loopback server lazily and reuses the same singleton", async () => {
168+
expect(getActiveMcpLoopbackRuntime()).toBeUndefined();
169+
170+
const first = await ensureMcpLoopbackServer(0);
171+
const second = await ensureMcpLoopbackServer(0);
172+
173+
expect(second).toBe(first);
174+
expect(getActiveMcpLoopbackRuntime()?.port).toBe(first.port);
175+
176+
await closeMcpLoopbackServer();
177+
expect(getActiveMcpLoopbackRuntime()).toBeUndefined();
178+
});
179+
165180
it("returns 401 when the bearer token is missing", async () => {
166181
server = await startMcpLoopbackServer(0);
167182
const response = await sendRaw({

src/gateway/mcp-http.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ export {
2323
getActiveMcpLoopbackRuntime,
2424
} from "./mcp-http.loopback-runtime.js";
2525

26+
type McpLoopbackServer = {
27+
port: number;
28+
close: () => Promise<void>;
29+
};
30+
31+
let activeMcpLoopbackServer: McpLoopbackServer | undefined;
32+
let activeMcpLoopbackServerPromise: Promise<McpLoopbackServer> | null = null;
33+
2634
export async function startMcpLoopbackServer(port = 0): Promise<{
2735
port: number;
2836
close: () => Promise<void>;
@@ -98,13 +106,16 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
98106
setActiveMcpLoopbackRuntime({ port: address.port, token });
99107
logDebug(`mcp loopback listening on 127.0.0.1:${address.port}`);
100108

101-
return {
109+
const server: McpLoopbackServer = {
102110
port: address.port,
103111
close: () =>
104112
new Promise<void>((resolve, reject) => {
105113
httpServer.close((error) => {
106114
if (!error) {
107115
clearActiveMcpLoopbackRuntime(token);
116+
if (activeMcpLoopbackServer === server) {
117+
activeMcpLoopbackServer = undefined;
118+
}
108119
}
109120
if (error) {
110121
reject(error);
@@ -114,4 +125,33 @@ export async function startMcpLoopbackServer(port = 0): Promise<{
114125
});
115126
}),
116127
};
128+
return server;
129+
}
130+
131+
export async function ensureMcpLoopbackServer(port = 0): Promise<McpLoopbackServer> {
132+
if (activeMcpLoopbackServer) {
133+
return activeMcpLoopbackServer;
134+
}
135+
if (!activeMcpLoopbackServerPromise) {
136+
activeMcpLoopbackServerPromise = startMcpLoopbackServer(port)
137+
.then((server) => {
138+
activeMcpLoopbackServer = server;
139+
return server;
140+
})
141+
.finally(() => {
142+
activeMcpLoopbackServerPromise = null;
143+
});
144+
}
145+
return activeMcpLoopbackServerPromise;
146+
}
147+
148+
export async function closeMcpLoopbackServer(): Promise<void> {
149+
const server =
150+
activeMcpLoopbackServer ??
151+
(activeMcpLoopbackServerPromise ? await activeMcpLoopbackServerPromise : undefined);
152+
if (!server) {
153+
return;
154+
}
155+
activeMcpLoopbackServer = undefined;
156+
await server.close();
117157
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { describe, expect, it } from "vitest";
2+
import { startGatewayEarlyRuntime } from "./server-startup-early.js";
3+
4+
describe("startGatewayEarlyRuntime", () => {
5+
it("does not eagerly start the MCP loopback server", async () => {
6+
const earlyRuntime = await startGatewayEarlyRuntime({
7+
minimalTestGateway: true,
8+
cfgAtStart: {} as never,
9+
port: 18_789,
10+
gatewayTls: { enabled: false },
11+
tailscaleMode: "off" as never,
12+
log: {
13+
info: () => {},
14+
warn: () => {},
15+
},
16+
logDiscovery: {
17+
info: () => {},
18+
warn: () => {},
19+
},
20+
nodeRegistry: {} as never,
21+
broadcast: () => {},
22+
nodeSendToAllSubscribed: () => {},
23+
getPresenceVersion: () => 0,
24+
getHealthVersion: () => 0,
25+
refreshGatewayHealthSnapshot: () => {},
26+
logHealth: () => {},
27+
dedupe: () => {},
28+
chatAbortControllers: new Map(),
29+
chatRunState: new Map(),
30+
chatRunBuffers: new Map(),
31+
chatDeltaSentAt: new Map(),
32+
chatDeltaLastBroadcastLen: new Map(),
33+
removeChatRun: () => {},
34+
agentRunSeq: () => 0,
35+
nodeSendToSession: () => {},
36+
skillsRefreshDelayMs: 30_000,
37+
getSkillsRefreshTimer: () => null,
38+
setSkillsRefreshTimer: () => {},
39+
loadConfig: () => ({}) as never,
40+
});
41+
42+
expect(earlyRuntime).not.toHaveProperty("mcpServer");
43+
});
44+
});

src/gateway/server-startup-early.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import {
88
setSkillsRemoteRegistry,
99
} from "../infra/skills-remote.js";
1010
import { startTaskRegistryMaintenance } from "../tasks/task-registry.maintenance.js";
11-
import { startMcpLoopbackServer } from "./mcp-http.js";
1211
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
1312
import { startGatewayMaintenanceTimers } from "./server-maintenance.js";
1413

@@ -54,14 +53,6 @@ export async function startGatewayEarlyRuntime(params: {
5453
setSkillsRefreshTimer: (timer: ReturnType<typeof setTimeout> | null) => void;
5554
loadConfig: () => OpenClawConfig;
5655
}) {
57-
let mcpServer: { port: number; close: () => Promise<void> } | undefined;
58-
try {
59-
mcpServer = await startMcpLoopbackServer(0);
60-
params.log.info(`MCP loopback server listening on http://127.0.0.1:${mcpServer.port}/mcp`);
61-
} catch (error) {
62-
params.log.warn(`MCP loopback server failed to start: ${String(error)}`);
63-
}
64-
6556
let bonjourStop: (() => Promise<void>) | null = null;
6657
if (!params.minimalTestGateway) {
6758
const machineDisplayName = await getMachineDisplayName();
@@ -127,7 +118,6 @@ export async function startGatewayEarlyRuntime(params: {
127118
});
128119

129120
return {
130-
mcpServer,
131121
bonjourStop,
132122
skillsChangeUnsub,
133123
maintenance,

src/gateway/server.impl.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
import { runSetupWizard } from "../wizard/setup.js";
4040
import { createAuthRateLimiter, type AuthRateLimiter } from "./auth-rate-limit.js";
4141
import { resolveGatewayAuth } from "./auth.js";
42+
import { closeMcpLoopbackServer } from "./mcp-http.js";
4243
import { createGatewayAuxHandlers } from "./server-aux-handlers.js";
4344
import { createChannelManager } from "./server-channels.js";
4445
import { createGatewayCloseHandler, runGatewayClosePrelude } from "./server-close.js";
@@ -502,7 +503,7 @@ export async function startGatewayServer(
502503
stopModelPricingRefresh: runtimeState.stopModelPricingRefresh,
503504
stopChannelHealthMonitor: () => runtimeState?.channelHealthMonitor?.stop(),
504505
clearSecretsRuntimeSnapshot,
505-
closeMcpServer: async () => await runtimeState?.mcpServer?.close(),
506+
closeMcpServer: async () => await closeMcpLoopbackServer(),
506507
});
507508
const closeOnStartupFailure = async () => {
508509
await runClosePrelude();
@@ -574,7 +575,6 @@ export async function startGatewayServer(
574575
},
575576
loadConfig,
576577
});
577-
runtimeState.mcpServer = earlyRuntime.mcpServer;
578578
runtimeState.bonjourStop = earlyRuntime.bonjourStop;
579579
runtimeState.skillsChangeUnsub = earlyRuntime.skillsChangeUnsub;
580580
if (earlyRuntime.maintenance) {

0 commit comments

Comments
 (0)