Skip to content

Commit cac2c72

Browse files
committed
feat(mcp): add shared runtime scope and decouple preflight abort
1 parent 5693fcd commit cac2c72

12 files changed

Lines changed: 505 additions & 74 deletions
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
24f11880cec619997ff93d303c32431bf4fb2bfefb56c9f0ece35ff91b329a80 config-baseline.json
2-
2923c1120c0369aeca6646cd67f7264590c6a1f4e5bc3157a04d7661324c6868 config-baseline.core.json
1+
a79127397ba3c5705b46a2184d1187fe1640d7d30e4dd6a3cec6bf0828d708ed config-baseline.json
2+
122c6c020f581ec66d008176c7d928a9bb51fb5bf0ca29da023949b77a74a637 config-baseline.core.json
33
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.json
44
d2e2114f1cd43dc894fe1a4836677b42a2a5af825537d6c4a932da832d58a590 config-baseline.plugin.json

docs/cli/mcp.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -396,7 +396,8 @@ Those saved definitions are for runtimes that OpenClaw launches or configures la
396396
- servers that advertise resources or prompts also expose utility tools for listing/reading resources and listing/fetching prompts; those generated utility names (`resources_list`, `resources_read`, `prompts_list`, `prompts_get`) use the same include/exclude filter
397397
- dynamic MCP tool-list changes invalidate the cached catalog for that session; the next discovery/use refreshes from the server
398398
- repeated MCP tool request/protocol failures pause that server briefly so one broken server does not consume the whole turn
399-
- session-scoped bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end
399+
- bundled MCP runtimes are reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10 minutes; set `0` to disable) and one-shot embedded runs clean them up at run end
400+
- `mcp.runtimeScope: "shared"` lets trusted single-tenant deployments reuse one bundled MCP runtime across sessions with the same workspace and MCP config; the default `"session"` keeps per-session MCP client and child-process isolation
400401

401402
</Accordion>
402403
</AccordionGroup>

docs/gateway/cli-backends.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -432,11 +432,15 @@ When bundle MCP is enabled, OpenClaw:
432432
If no MCP servers are enabled, OpenClaw still injects a strict config when a
433433
backend opts into bundle MCP so background runs stay isolated.
434434

435-
Session-scoped bundled MCP runtimes are cached for reuse within a session, then
436-
reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10
437-
minutes; set `0` to disable). One-shot embedded runs such as auth probes,
438-
slug generation, and active-memory recall request cleanup at run end so stdio
439-
children and Streamable HTTP/SSE streams do not outlive the run.
435+
Bundled MCP runtimes are cached for reuse within their configured
436+
`mcp.runtimeScope`, then reaped after `mcp.sessionIdleTtlMs` milliseconds of
437+
idle time (default 10 minutes; set `0` to disable). The default `"session"`
438+
scope preserves per-session MCP client and child-process isolation. The
439+
optional `"shared"` scope lets trusted single-tenant deployments reuse one MCP
440+
runtime across sessions with the same workspace and MCP config fingerprint.
441+
One-shot embedded runs such as auth probes, slug generation, and active-memory
442+
recall request cleanup at run end so stdio children and Streamable HTTP/SSE
443+
streams do not outlive the run.
440444

441445
## Reseed history cap
442446

docs/gateway/configuration-reference.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ target server during config edits.
9696
```json5
9797
{
9898
mcp: {
99+
// Optional. Default: "session". Use "shared" only for trusted single-tenant deployments.
100+
runtimeScope: "session", // session | shared
99101
// Optional. Default: 600000 ms (10 minutes). Set 0 to disable idle eviction.
100102
sessionIdleTtlMs: 600000,
101103
servers: {
@@ -170,9 +172,16 @@ target server during config edits.
170172
block before passing native `mcp_servers` config to Codex. Omit the block to
171173
keep the server projected for every Codex app-server agent with Codex's
172174
default MCP approval behavior.
173-
- `mcp.sessionIdleTtlMs`: idle TTL for session-scoped bundled MCP runtimes.
174-
One-shot embedded runs request run-end cleanup; this TTL is the backstop for
175-
long-lived sessions and future callers.
175+
- `mcp.runtimeScope`: bundled MCP runtime ownership scope. The default
176+
`"session"` keeps one MCP runtime per session. `"shared"` reuses one runtime
177+
across sessions with the same workspace and MCP config fingerprint, which
178+
avoids duplicate stdio children and repeated `tools/list` catalog work.
179+
Enable `"shared"` only for trusted single-tenant deployments because MCP
180+
clients, child processes, server-side state, and failure state can cross
181+
attached sessions.
182+
- `mcp.sessionIdleTtlMs`: idle TTL for bundled MCP runtimes. One-shot embedded
183+
runs request run-end cleanup; this TTL is the backstop for long-lived sessions
184+
and future callers.
176185
- Changes under `mcp.*` hot-apply by disposing cached session MCP runtimes.
177186
The next tool discovery/use recreates them from the new config, so removed
178187
`mcp.servers` entries are reaped immediately instead of waiting for idle TTL.

src/agents/agent-bundle-mcp-runtime.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1376,6 +1376,168 @@ process.on("SIGINT", shutdown);`,
13761376
expect(manager.listSessionIds()).not.toContain("session-a");
13771377
});
13781378

1379+
it("keeps separate runtimes for different sessions by default", async () => {
1380+
const created: string[] = [];
1381+
const createRuntime: RuntimeFactory = (params) => {
1382+
created.push(params.sessionId);
1383+
return {
1384+
...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]),
1385+
sessionId: params.sessionId,
1386+
sessionKey: params.sessionKey,
1387+
workspaceDir: params.workspaceDir,
1388+
configFingerprint: params.configFingerprint ?? "fingerprint",
1389+
};
1390+
};
1391+
const manager = testing.createSessionMcpRuntimeManager({ createRuntime });
1392+
1393+
const runtimeA = await manager.getOrCreate({
1394+
sessionId: "session-a",
1395+
sessionKey: "agent:test:session-a",
1396+
workspaceDir: "/workspace",
1397+
cfg: { mcp: { servers: {} } },
1398+
});
1399+
const runtimeB = await manager.getOrCreate({
1400+
sessionId: "session-b",
1401+
sessionKey: "agent:test:session-b",
1402+
workspaceDir: "/workspace",
1403+
cfg: { mcp: { servers: {} } },
1404+
});
1405+
1406+
expect(runtimeA).not.toBe(runtimeB);
1407+
expect(created).toEqual(["session-a", "session-b"]);
1408+
expect(manager.listSessionIds().toSorted()).toEqual(["session-a", "session-b"]);
1409+
});
1410+
1411+
it("shares one runtime across sessions when runtimeScope is shared", async () => {
1412+
const created: string[] = [];
1413+
const disposed: string[] = [];
1414+
const createRuntime: RuntimeFactory = (params) => {
1415+
created.push(params.sessionId);
1416+
return {
1417+
...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]),
1418+
sessionId: params.sessionId,
1419+
sessionKey: params.sessionKey,
1420+
workspaceDir: params.workspaceDir,
1421+
configFingerprint: params.configFingerprint ?? "fingerprint",
1422+
dispose: async () => {
1423+
disposed.push(params.sessionId);
1424+
},
1425+
};
1426+
};
1427+
const manager = testing.createSessionMcpRuntimeManager({ createRuntime });
1428+
const cfg = { mcp: { runtimeScope: "shared" as const, servers: {} } };
1429+
1430+
const runtimeA = await manager.getOrCreate({
1431+
sessionId: "session-a",
1432+
sessionKey: "agent:test:session-a",
1433+
workspaceDir: "/workspace",
1434+
cfg,
1435+
});
1436+
const runtimeB = await manager.getOrCreate({
1437+
sessionId: "session-b",
1438+
sessionKey: "agent:test:session-b",
1439+
workspaceDir: "/workspace",
1440+
cfg,
1441+
});
1442+
1443+
expect(runtimeA).toBe(runtimeB);
1444+
expect(created).toHaveLength(1);
1445+
expect(created[0]).toMatch(/^__mcp_shared__:/);
1446+
expect(manager.peekSession({ sessionId: "session-a" })).toBe(runtimeA);
1447+
expect(manager.peekSession({ sessionKey: "agent:test:session-b" })).toBe(runtimeA);
1448+
expect(manager.listSessionIds().toSorted()).toEqual(["session-a", "session-b"]);
1449+
1450+
await manager.disposeSession("session-a");
1451+
1452+
expect(disposed).toEqual([]);
1453+
expect(manager.peekSession({ sessionId: "session-a" })).toBeUndefined();
1454+
expect(manager.peekSession({ sessionId: "session-b" })).toBe(runtimeA);
1455+
1456+
await manager.disposeSession("session-b");
1457+
1458+
expect(disposed).toEqual([runtimeA.sessionId]);
1459+
expect(manager.listSessionIds()).toEqual([]);
1460+
});
1461+
1462+
it("creates separate shared runtimes per workspace and MCP config fingerprint", async () => {
1463+
const created: Array<{ sessionId: string; workspaceDir: string; configFingerprint?: string }> =
1464+
[];
1465+
const createRuntime: RuntimeFactory = (params) => {
1466+
created.push({
1467+
sessionId: params.sessionId,
1468+
workspaceDir: params.workspaceDir,
1469+
configFingerprint: params.configFingerprint,
1470+
});
1471+
return {
1472+
...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]),
1473+
sessionId: params.sessionId,
1474+
workspaceDir: params.workspaceDir,
1475+
configFingerprint: params.configFingerprint ?? "fingerprint",
1476+
};
1477+
};
1478+
const manager = testing.createSessionMcpRuntimeManager({ createRuntime });
1479+
const cfgA = {
1480+
mcp: {
1481+
runtimeScope: "shared" as const,
1482+
servers: { probe: { command: "node", args: ["server-a.mjs"] } },
1483+
},
1484+
};
1485+
const cfgB = {
1486+
mcp: {
1487+
runtimeScope: "shared" as const,
1488+
servers: { probe: { command: "node", args: ["server-b.mjs"] } },
1489+
},
1490+
};
1491+
1492+
const runtimeA = await manager.getOrCreate({
1493+
sessionId: "session-a",
1494+
workspaceDir: "/workspace-a",
1495+
cfg: cfgA,
1496+
});
1497+
const runtimeSameFingerprint = await manager.getOrCreate({
1498+
sessionId: "session-b",
1499+
workspaceDir: "/workspace-a",
1500+
cfg: cfgA,
1501+
});
1502+
const runtimeDifferentWorkspace = await manager.getOrCreate({
1503+
sessionId: "session-c",
1504+
workspaceDir: "/workspace-b",
1505+
cfg: cfgA,
1506+
});
1507+
const runtimeDifferentConfig = await manager.getOrCreate({
1508+
sessionId: "session-d",
1509+
workspaceDir: "/workspace-a",
1510+
cfg: cfgB,
1511+
});
1512+
1513+
expect(runtimeSameFingerprint).toBe(runtimeA);
1514+
expect(runtimeDifferentWorkspace).not.toBe(runtimeA);
1515+
expect(runtimeDifferentConfig).not.toBe(runtimeA);
1516+
expect(created).toHaveLength(3);
1517+
expect(new Set(created.map((entry) => entry.sessionId)).size).toBe(3);
1518+
});
1519+
1520+
it("drops session attachments when shared runtime creation fails", async () => {
1521+
const manager = testing.createSessionMcpRuntimeManager({
1522+
createRuntime: () => {
1523+
throw new Error("mcp child failed to start");
1524+
},
1525+
});
1526+
1527+
await expect(
1528+
manager.getOrCreate({
1529+
sessionId: "session-a",
1530+
sessionKey: "agent:test:session-a",
1531+
workspaceDir: "/workspace",
1532+
cfg: { mcp: { runtimeScope: "shared", servers: {} } },
1533+
}),
1534+
).rejects.toThrow("mcp child failed to start");
1535+
1536+
expect(manager.listSessionIds()).toEqual([]);
1537+
expect(manager.peekSession({ sessionId: "session-a" })).toBeUndefined();
1538+
expect(manager.resolveSessionId("agent:test:session-a")).toBeUndefined();
1539+
});
1540+
13791541
it("peeks existing runtimes and populated catalogs without creating new runtimes", async () => {
13801542
let catalogReady = false;
13811543
const createRuntime: RuntimeFactory = (params) => {
@@ -1730,6 +1892,73 @@ process.on("SIGINT", shutdown);`,
17301892
expect(disposed).toStrictEqual([]);
17311893
});
17321894

1895+
it("does not evict a shared runtime while an attached session holds a lease", async () => {
1896+
let now = 1_000;
1897+
const disposed: string[] = [];
1898+
const createRuntime: RuntimeFactory = (params) => {
1899+
let lastUsedAt = now;
1900+
let activeLeases = 0;
1901+
return {
1902+
...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]),
1903+
sessionId: params.sessionId,
1904+
workspaceDir: params.workspaceDir,
1905+
configFingerprint: params.configFingerprint ?? "fingerprint",
1906+
get lastUsedAt() {
1907+
return lastUsedAt;
1908+
},
1909+
get activeLeases() {
1910+
return activeLeases;
1911+
},
1912+
markUsed: () => {
1913+
lastUsedAt = now;
1914+
},
1915+
acquireLease: () => {
1916+
activeLeases += 1;
1917+
return () => {
1918+
activeLeases -= 1;
1919+
};
1920+
},
1921+
dispose: async () => {
1922+
disposed.push(params.sessionId);
1923+
},
1924+
};
1925+
};
1926+
const manager = testing.createSessionMcpRuntimeManager({
1927+
createRuntime,
1928+
now: () => now,
1929+
enableIdleSweepTimer: false,
1930+
});
1931+
const cfg = {
1932+
mcp: {
1933+
runtimeScope: "shared" as const,
1934+
servers: {},
1935+
sessionIdleTtlMs: 50,
1936+
},
1937+
};
1938+
1939+
const runtime = await manager.getOrCreate({
1940+
sessionId: "session-a",
1941+
workspaceDir: "/workspace",
1942+
cfg,
1943+
});
1944+
await manager.getOrCreate({
1945+
sessionId: "session-b",
1946+
workspaceDir: "/workspace",
1947+
cfg,
1948+
});
1949+
const releaseLease = runtime.acquireLease?.();
1950+
1951+
now += 60;
1952+
await expect(manager.sweepIdleRuntimes()).resolves.toBe(0);
1953+
expect(manager.listSessionIds().toSorted()).toEqual(["session-a", "session-b"]);
1954+
1955+
releaseLease?.();
1956+
await expect(manager.sweepIdleRuntimes()).resolves.toBe(1);
1957+
1958+
expect(disposed).toEqual([runtime.sessionId]);
1959+
expect(manager.listSessionIds()).toEqual([]);
1960+
});
1961+
17331962
it("production createSessionMcpRuntime acquireLease release does not refresh lastUsedAt", () => {
17341963
const runtime = createSessionMcpRuntime({
17351964
sessionId: "session-lease-timestamp-check",

0 commit comments

Comments
 (0)