fix: retire idle bundled MCP runtimes

This commit is contained in:
Peter Steinberger
2026-04-25 07:49:05 +01:00
parent 66e66f19c6
commit b34ece705f
21 changed files with 358 additions and 18 deletions

View File

@@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### Fixes
- MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add `mcp.sessionIdleTtlMs` idle eviction for leaked session runtimes. Fixes #71106 and #71110.
- CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt. - CI/release-checks: pass workflow inputs and matrix values through step environment variables instead of embedding them directly into `run:` shell commands, reducing template-injection surface in the cross-OS release-check workflow. (#66884) Thanks @alexlomt.
- fix(ci): harden release checks workflow inputs (#66884). Thanks @alexlomt - fix(ci): harden release checks workflow inputs (#66884). Thanks @alexlomt
- Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev. - Gateway/restart continuation: durably hand restart continuations to a session-delivery queue before deleting the restart sentinel, recover queued continuation work after crashy restarts, and fall back to a session-only wake when no channel route survives reboot. (#70780) Thanks @fuller-stack-dev.

View File

@@ -1,4 +1,4 @@
3b8ff208a31b04ea61391182444bd744357577872eac279136bbc284c3dc064a config-baseline.json 13b68287fec00108ca66032120909a0eac797ed541e026357e175e3fce5bacdd config-baseline.json
4dfeadeb814fb205f5a17d797cbbe3c07685009821fe8dbf8771ea428ed5b4dd config-baseline.core.json 77ee66fb3b2cde94b393712bc03a132b096cf601c193bde1fe42902eecb0b66b config-baseline.core.json
d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.json
0d5ba81f0030bd39b7ae285096276cc18b150836c2252fd2217329fc6154e80e config-baseline.plugin.json 0d5ba81f0030bd39b7ae285096276cc18b150836c2252fd2217329fc6154e80e config-baseline.plugin.json

View File

@@ -376,6 +376,9 @@ Important behavior:
- embedded Pi exposes configured MCP tools in normal `coding` and `messaging` - embedded Pi exposes configured MCP tools in normal `coding` and `messaging`
tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]` tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]`
disables them explicitly disables them explicitly
- 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
## Saved MCP server definitions ## Saved MCP server definitions

View File

@@ -349,6 +349,12 @@ When bundle MCP is enabled, OpenClaw:
If no MCP servers are enabled, OpenClaw still injects a strict config when a If no MCP servers are enabled, OpenClaw still injects a strict config when a
backend opts into bundle MCP so background runs stay isolated. backend opts into bundle MCP so background runs stay isolated.
Session-scoped bundled MCP runtimes are cached for reuse within a session, then
reaped after `mcp.sessionIdleTtlMs` milliseconds of idle time (default 10
minutes; set `0` to disable). One-shot embedded runs such as auth probes,
slug generation, and active-memory recall request cleanup at run end so stdio
children and Streamable HTTP/SSE streams do not outlive the run.
## Limitations ## Limitations
- **No direct OpenClaw tool calls.** OpenClaw does not inject tool calls into - **No direct OpenClaw tool calls.** OpenClaw does not inject tool calls into

View File

@@ -51,6 +51,44 @@ Tool policy, experimental toggles, provider-backed tool config, and custom
provider / base-URL setup moved to a dedicated page — see provider / base-URL setup moved to a dedicated page — see
[Configuration — tools and custom providers](/gateway/config-tools). [Configuration — tools and custom providers](/gateway/config-tools).
## MCP
OpenClaw-managed MCP server definitions live under `mcp.servers` and are
consumed by embedded Pi and other runtime adapters. The `openclaw mcp list`,
`show`, `set`, and `unset` commands manage this block without connecting to the
target server during config edits.
```json5
{
mcp: {
// Optional. Default: 600000 ms (10 minutes). Set 0 to disable idle eviction.
sessionIdleTtlMs: 600000,
servers: {
docs: {
command: "npx",
args: ["-y", "@modelcontextprotocol/server-fetch"],
},
remote: {
url: "https://example.com/mcp",
transport: "streamable-http", // streamable-http | sse
headers: {
Authorization: "Bearer ${MCP_REMOTE_TOKEN}",
},
},
},
},
}
```
- `mcp.servers`: named stdio or remote MCP server definitions for runtimes that
expose configured MCP tools.
- `mcp.sessionIdleTtlMs`: idle TTL for session-scoped bundled MCP runtimes.
One-shot embedded runs request run-end cleanup; this TTL is the backstop for
long-lived sessions and future callers.
See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
[CLI backends](/gateway/cli-backends#bundle-mcp-overlays) for runtime behavior.
## Skills ## Skills
```json5 ```json5

View File

@@ -566,6 +566,7 @@ describe("active-memory plugin", () => {
}, },
}, },
}, },
cleanupBundleMcpOnRunEnd: true,
}); });
}); });

View File

@@ -1684,6 +1684,7 @@ async function runRecallSubagent(params: {
thinkLevel: params.config.thinking, thinkLevel: params.config.thinking,
reasoningLevel: "off", reasoningLevel: "off",
silentExpected: true, silentExpected: true,
cleanupBundleMcpOnRunEnd: true,
abortSignal: params.abortSignal, abortSignal: params.abortSignal,
}); });
if (params.abortSignal?.aborted) { if (params.abortSignal?.aborted) {

View File

@@ -66,8 +66,16 @@ export async function materializeBundleMcpToolsForRun(params: {
reservedToolNames?: Iterable<string>; reservedToolNames?: Iterable<string>;
disposeRuntime?: () => Promise<void>; disposeRuntime?: () => Promise<void>;
}): Promise<BundleMcpToolRuntime> { }): Promise<BundleMcpToolRuntime> {
let disposed = false;
const releaseLease = params.runtime.acquireLease?.();
params.runtime.markUsed(); params.runtime.markUsed();
const catalog = await params.runtime.getCatalog(); let catalog;
try {
catalog = await params.runtime.getCatalog();
} catch (error) {
releaseLease?.();
throw error;
}
const reservedNames = normalizeReservedToolNames(params.reservedToolNames); const reservedNames = normalizeReservedToolNames(params.reservedToolNames);
const tools: BundleMcpToolRuntime["tools"] = []; const tools: BundleMcpToolRuntime["tools"] = [];
const sortedCatalogTools = [...catalog.tools].toSorted((a, b) => { const sortedCatalogTools = [...catalog.tools].toSorted((a, b) => {
@@ -104,6 +112,7 @@ export async function materializeBundleMcpToolsForRun(params: {
description: tool.description || tool.fallbackDescription, description: tool.description || tool.fallbackDescription,
parameters: tool.inputSchema, parameters: tool.inputSchema,
execute: async (_toolCallId: string, input: unknown) => { execute: async (_toolCallId: string, input: unknown) => {
params.runtime.markUsed();
const result = await params.runtime.callTool(tool.serverName, tool.toolName, input); const result = await params.runtime.callTool(tool.serverName, tool.toolName, input);
return toAgentToolResult({ return toAgentToolResult({
serverName: tool.serverName, serverName: tool.serverName,
@@ -127,6 +136,11 @@ export async function materializeBundleMcpToolsForRun(params: {
return { return {
tools, tools,
dispose: async () => { dispose: async () => {
if (disposed) {
return;
}
disposed = true;
releaseLease?.();
await params.disposeRuntime?.(); await params.disposeRuntime?.();
}, },
}; };

View File

@@ -26,13 +26,19 @@ function makeRuntime(
tools: Array<{ toolName: string; description: string }>, tools: Array<{ toolName: string; description: string }>,
serverName = "bundleProbe", serverName = "bundleProbe",
): SessionMcpRuntime { ): SessionMcpRuntime {
const createdAt = Date.now();
let lastUsedAt = createdAt;
return { return {
sessionId: "session-colliding-tools", sessionId: "session-colliding-tools",
workspaceDir: "/tmp", workspaceDir: "/tmp",
configFingerprint: "fingerprint", configFingerprint: "fingerprint",
createdAt: 0, createdAt,
lastUsedAt: 0, get lastUsedAt() {
markUsed: () => {}, return lastUsedAt;
},
markUsed: () => {
lastUsedAt = Date.now();
},
getCatalog: async () => ({ getCatalog: async () => ({
version: 1, version: 1,
generatedAt: 0, generatedAt: 0,
@@ -135,6 +141,27 @@ describe("session MCP runtime", () => {
]); ]);
}); });
it("holds a runtime lease until the materialized tool runtime is disposed", async () => {
let activeLeases = 0;
const runtime = {
...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]),
acquireLease: () => {
activeLeases += 1;
return () => {
activeLeases -= 1;
};
},
};
const materialized = await materializeBundleMcpToolsForRun({ runtime });
expect(activeLeases).toBe(1);
await materialized.dispose();
await materialized.dispose();
expect(activeLeases).toBe(0);
});
it("reuses repeated materialization and recreates after explicit disposal", async () => { it("reuses repeated materialization and recreates after explicit disposal", async () => {
const created: SessionMcpRuntime[] = []; const created: SessionMcpRuntime[] = [];
const disposed: string[] = []; const disposed: string[] = [];
@@ -361,4 +388,94 @@ describe("session MCP runtime", () => {
retireSessionMcpRuntimeForSessionKey({ sessionKey: "agent:test:missing", reason: "test" }), retireSessionMcpRuntimeForSessionKey({ sessionKey: "agent:test:missing", reason: "test" }),
).resolves.toBe(false); ).resolves.toBe(false);
}); });
it("evicts idle runtimes after the configured TTL but skips active leases", async () => {
let now = 1_000;
const disposed: string[] = [];
const createRuntime: RuntimeFactory = (params) => {
let lastUsedAt = now;
let activeLeases = 0;
return {
...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]),
sessionId: params.sessionId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
configFingerprint: params.configFingerprint ?? "fingerprint",
get lastUsedAt() {
return lastUsedAt;
},
get activeLeases() {
return activeLeases;
},
markUsed: () => {
lastUsedAt = now;
},
acquireLease: () => {
activeLeases += 1;
return () => {
activeLeases -= 1;
lastUsedAt = now;
};
},
dispose: async () => {
disposed.push(params.sessionId);
},
};
};
const manager = __testing.createSessionMcpRuntimeManager({
createRuntime,
now: () => now,
enableIdleSweepTimer: false,
});
const runtime = await manager.getOrCreate({
sessionId: "session-idle",
sessionKey: "agent:test:session-idle",
workspaceDir: "/workspace",
cfg: { mcp: { servers: {}, sessionIdleTtlMs: 50 } },
});
const releaseLease = runtime.acquireLease?.();
now += 60;
await expect(manager.sweepIdleRuntimes()).resolves.toBe(0);
expect(manager.listSessionIds()).toEqual(["session-idle"]);
releaseLease?.();
now += 60;
await expect(manager.sweepIdleRuntimes()).resolves.toBe(1);
expect(disposed).toEqual(["session-idle"]);
expect(manager.listSessionIds()).toEqual([]);
expect(manager.resolveSessionId("agent:test:session-idle")).toBeUndefined();
});
it("keeps idle runtime eviction disabled when the TTL is zero", async () => {
let now = 1_000;
const disposed: string[] = [];
const manager = __testing.createSessionMcpRuntimeManager({
createRuntime: (params) => ({
...makeRuntime([{ toolName: "bundle_probe", description: "Bundle MCP probe" }]),
sessionId: params.sessionId,
sessionKey: params.sessionKey,
workspaceDir: params.workspaceDir,
configFingerprint: params.configFingerprint ?? "fingerprint",
dispose: async () => {
disposed.push(params.sessionId);
},
}),
now: () => now,
enableIdleSweepTimer: false,
});
await manager.getOrCreate({
sessionId: "session-no-ttl",
workspaceDir: "/workspace",
cfg: { mcp: { servers: {}, sessionIdleTtlMs: 0 } },
});
now += 60_000_000;
await expect(manager.sweepIdleRuntimes()).resolves.toBe(0);
expect(manager.listSessionIds()).toEqual(["session-no-ttl"]);
expect(disposed).toEqual([]);
});
}); });

View File

@@ -45,6 +45,8 @@ type CreateSessionMcpRuntime = (
const require = createRequire(import.meta.url); const require = createRequire(import.meta.url);
const SESSION_MCP_RUNTIME_MANAGER_KEY = Symbol.for("openclaw.sessionMcpRuntimeManager"); const SESSION_MCP_RUNTIME_MANAGER_KEY = Symbol.for("openclaw.sessionMcpRuntimeManager");
const DRAFT_2020_12_SCHEMA = "https://json-schema.org/draft/2020-12/schema"; const DRAFT_2020_12_SCHEMA = "https://json-schema.org/draft/2020-12/schema";
const DEFAULT_SESSION_MCP_RUNTIME_IDLE_TTL_MS = 10 * 60 * 1000;
const SESSION_MCP_RUNTIME_SWEEP_INTERVAL_MS = 60 * 1000;
type Ajv2020Like = { type Ajv2020Like = {
compile: (schema: JsonSchemaType) => ValidateFunction; compile: (schema: JsonSchemaType) => ValidateFunction;
@@ -168,6 +170,14 @@ function createDisposedError(sessionId: string): Error {
return new Error(`bundle-mcp runtime disposed for session ${sessionId}`); return new Error(`bundle-mcp runtime disposed for session ${sessionId}`);
} }
function resolveSessionMcpRuntimeIdleTtlMs(cfg?: OpenClawConfig): number {
const raw = cfg?.mcp?.sessionIdleTtlMs;
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) {
return Math.floor(raw);
}
return DEFAULT_SESSION_MCP_RUNTIME_IDLE_TTL_MS;
}
export function createSessionMcpRuntime(params: { export function createSessionMcpRuntime(params: {
sessionId: string; sessionId: string;
sessionKey?: string; sessionKey?: string;
@@ -181,6 +191,7 @@ export function createSessionMcpRuntime(params: {
}); });
const createdAt = Date.now(); const createdAt = Date.now();
let lastUsedAt = createdAt; let lastUsedAt = createdAt;
let activeLeases = 0;
let disposed = false; let disposed = false;
let catalog: McpToolCatalog | null = null; let catalog: McpToolCatalog | null = null;
let catalogInFlight: Promise<McpToolCatalog> | undefined; let catalogInFlight: Promise<McpToolCatalog> | undefined;
@@ -318,6 +329,21 @@ export function createSessionMcpRuntime(params: {
get lastUsedAt() { get lastUsedAt() {
return lastUsedAt; return lastUsedAt;
}, },
get activeLeases() {
return activeLeases;
},
acquireLease() {
activeLeases += 1;
let released = false;
return () => {
if (released) {
return;
}
released = true;
activeLeases = Math.max(0, activeLeases - 1);
lastUsedAt = Date.now();
};
},
getCatalog, getCatalog,
markUsed() { markUsed() {
lastUsedAt = Date.now(); lastUsedAt = Date.now();
@@ -349,11 +375,18 @@ export function createSessionMcpRuntime(params: {
} }
function createSessionMcpRuntimeManager( function createSessionMcpRuntimeManager(
opts: { createRuntime?: CreateSessionMcpRuntime } = {}, opts: {
createRuntime?: CreateSessionMcpRuntime;
now?: () => number;
enableIdleSweepTimer?: boolean;
idleSweepIntervalMs?: number;
} = {},
): SessionMcpRuntimeManager { ): SessionMcpRuntimeManager {
const runtimesBySessionId = new Map<string, SessionMcpRuntime>(); const runtimesBySessionId = new Map<string, SessionMcpRuntime>();
const sessionIdBySessionKey = new Map<string, string>(); const sessionIdBySessionKey = new Map<string, string>();
const idleTtlMsBySessionId = new Map<string, number>();
const createRuntime = opts.createRuntime ?? createSessionMcpRuntime; const createRuntime = opts.createRuntime ?? createSessionMcpRuntime;
const now = opts.now ?? Date.now;
const createInFlight = new Map< const createInFlight = new Map<
string, string,
{ {
@@ -362,9 +395,79 @@ function createSessionMcpRuntimeManager(
configFingerprint: string; configFingerprint: string;
} }
>(); >();
const idleSweepIntervalMs = opts.idleSweepIntervalMs ?? SESSION_MCP_RUNTIME_SWEEP_INTERVAL_MS;
let idleSweepTimer: ReturnType<typeof setInterval> | undefined;
let idleSweepInFlight: Promise<void> | undefined;
const forgetSessionKeysForSessionId = (sessionId: string) => {
for (const [sessionKey, mappedSessionId] of sessionIdBySessionKey.entries()) {
if (mappedSessionId === sessionId) {
sessionIdBySessionKey.delete(sessionKey);
}
}
};
const sweepIdleRuntimes = async (): Promise<number> => {
const nowMs = now();
const expired: SessionMcpRuntime[] = [];
for (const [sessionId, runtime] of runtimesBySessionId.entries()) {
const idleTtlMs =
idleTtlMsBySessionId.get(sessionId) ?? DEFAULT_SESSION_MCP_RUNTIME_IDLE_TTL_MS;
if (idleTtlMs <= 0 || (runtime.activeLeases ?? 0) > 0) {
continue;
}
if (nowMs - runtime.lastUsedAt < idleTtlMs) {
continue;
}
runtimesBySessionId.delete(sessionId);
idleTtlMsBySessionId.delete(sessionId);
forgetSessionKeysForSessionId(sessionId);
expired.push(runtime);
}
await Promise.allSettled(expired.map((runtime) => runtime.dispose()));
return expired.length;
};
const queueIdleSweep = () => {
if (idleSweepInFlight) {
return;
}
idleSweepInFlight = sweepIdleRuntimes()
.then(() => undefined)
.catch((error: unknown) => {
logWarn(`bundle-mcp: idle runtime sweep failed: ${String(error)}`);
})
.finally(() => {
idleSweepInFlight = undefined;
});
};
const ensureIdleSweepTimer = () => {
if (opts.enableIdleSweepTimer === false || idleSweepIntervalMs <= 0 || idleSweepTimer) {
return;
}
idleSweepTimer = setInterval(queueIdleSweep, idleSweepIntervalMs);
idleSweepTimer.unref?.();
};
const clearIdleSweepTimer = () => {
if (!idleSweepTimer) {
return;
}
clearInterval(idleSweepTimer);
idleSweepTimer = undefined;
};
return { return {
async getOrCreate(params) { async getOrCreate(params) {
const idleTtlMs = resolveSessionMcpRuntimeIdleTtlMs(params.cfg);
if (runtimesBySessionId.has(params.sessionId)) {
idleTtlMsBySessionId.set(params.sessionId, idleTtlMs);
}
await sweepIdleRuntimes();
if (idleTtlMs > 0) {
ensureIdleSweepTimer();
}
if (params.sessionKey) { if (params.sessionKey) {
sessionIdBySessionKey.set(params.sessionKey, params.sessionId); sessionIdBySessionKey.set(params.sessionKey, params.sessionId);
} }
@@ -383,6 +486,7 @@ function createSessionMcpRuntimeManager(
await existing.dispose(); await existing.dispose();
} else { } else {
existing.markUsed(); existing.markUsed();
idleTtlMsBySessionId.set(params.sessionId, idleTtlMs);
return existing; return existing;
} }
} }
@@ -397,6 +501,7 @@ function createSessionMcpRuntimeManager(
createInFlight.delete(params.sessionId); createInFlight.delete(params.sessionId);
const staleRuntime = await inFlight.promise.catch(() => undefined); const staleRuntime = await inFlight.promise.catch(() => undefined);
runtimesBySessionId.delete(params.sessionId); runtimesBySessionId.delete(params.sessionId);
idleTtlMsBySessionId.delete(params.sessionId);
await staleRuntime?.dispose(); await staleRuntime?.dispose();
} }
const created = Promise.resolve( const created = Promise.resolve(
@@ -410,6 +515,7 @@ function createSessionMcpRuntimeManager(
).then((runtime) => { ).then((runtime) => {
runtime.markUsed(); runtime.markUsed();
runtimesBySessionId.set(params.sessionId, runtime); runtimesBySessionId.set(params.sessionId, runtime);
idleTtlMsBySessionId.set(params.sessionId, idleTtlMs);
return runtime; return runtime;
}); });
createInFlight.set(params.sessionId, { createInFlight.set(params.sessionId, {
@@ -437,27 +543,22 @@ function createSessionMcpRuntimeManager(
runtime = await inFlight.promise.catch(() => undefined); runtime = await inFlight.promise.catch(() => undefined);
} }
runtimesBySessionId.delete(sessionId); runtimesBySessionId.delete(sessionId);
idleTtlMsBySessionId.delete(sessionId);
if (!runtime) { if (!runtime) {
for (const [sessionKey, mappedSessionId] of sessionIdBySessionKey.entries()) { forgetSessionKeysForSessionId(sessionId);
if (mappedSessionId === sessionId) {
sessionIdBySessionKey.delete(sessionKey);
}
}
return; return;
} }
for (const [sessionKey, mappedSessionId] of sessionIdBySessionKey.entries()) { forgetSessionKeysForSessionId(sessionId);
if (mappedSessionId === sessionId) {
sessionIdBySessionKey.delete(sessionKey);
}
}
await runtime.dispose(); await runtime.dispose();
}, },
async disposeAll() { async disposeAll() {
clearIdleSweepTimer();
const inFlightRuntimes = Array.from(createInFlight.values()); const inFlightRuntimes = Array.from(createInFlight.values());
createInFlight.clear(); createInFlight.clear();
const runtimes = Array.from(runtimesBySessionId.values()); const runtimes = Array.from(runtimesBySessionId.values());
runtimesBySessionId.clear(); runtimesBySessionId.clear();
sessionIdBySessionKey.clear(); sessionIdBySessionKey.clear();
idleTtlMsBySessionId.clear();
const lateRuntimes = await Promise.all( const lateRuntimes = await Promise.all(
inFlightRuntimes.map(async ({ promise }) => await promise.catch(() => undefined)), inFlightRuntimes.map(async ({ promise }) => await promise.catch(() => undefined)),
); );
@@ -469,6 +570,7 @@ function createSessionMcpRuntimeManager(
} }
await Promise.allSettled(Array.from(allRuntimes, (runtime) => runtime.dispose())); await Promise.allSettled(Array.from(allRuntimes, (runtime) => runtime.dispose()));
}, },
sweepIdleRuntimes,
listSessionIds() { listSessionIds() {
return Array.from(runtimesBySessionId.keys()); return Array.from(runtimesBySessionId.keys());
}, },
@@ -539,4 +641,5 @@ export const __testing = {
getCachedSessionIds() { getCachedSessionIds() {
return getSessionMcpRuntimeManager().listSessionIds(); return getSessionMcpRuntimeManager().listSessionIds();
}, },
resolveSessionMcpRuntimeIdleTtlMs,
}; };

View File

@@ -38,6 +38,8 @@ export type SessionMcpRuntime = {
configFingerprint: string; configFingerprint: string;
createdAt: number; createdAt: number;
lastUsedAt: number; lastUsedAt: number;
activeLeases?: number;
acquireLease?: () => () => void;
getCatalog: () => Promise<McpToolCatalog>; getCatalog: () => Promise<McpToolCatalog>;
markUsed: () => void; markUsed: () => void;
callTool: (serverName: string, toolName: string, input: unknown) => Promise<CallToolResult>; callTool: (serverName: string, toolName: string, input: unknown) => Promise<CallToolResult>;
@@ -55,5 +57,6 @@ export type SessionMcpRuntimeManager = {
resolveSessionId: (sessionKey: string) => string | undefined; resolveSessionId: (sessionKey: string) => string | undefined;
disposeSession: (sessionId: string) => Promise<void>; disposeSession: (sessionId: string) => Promise<void>;
disposeAll: () => Promise<void>; disposeAll: () => Promise<void>;
sweepIdleRuntimes: () => Promise<number>;
listSessionIds: () => string[]; listSessionIds: () => string[];
}; };

View File

@@ -27,6 +27,7 @@ export async function cleanupEmbeddedAttemptResources(params: {
releaseWsSession: (sessionId: string, options?: { allowPool?: boolean }) => void; releaseWsSession: (sessionId: string, options?: { allowPool?: boolean }) => void;
allowWsSessionPool?: boolean; allowWsSessionPool?: boolean;
sessionId: string; sessionId: string;
bundleMcpRuntime?: { dispose(): Promise<void> | void };
bundleLspRuntime?: { dispose(): Promise<void> | void }; bundleLspRuntime?: { dispose(): Promise<void> | void };
sessionLock: { release(): Promise<void> | void }; sessionLock: { release(): Promise<void> | void };
}): Promise<void> { }): Promise<void> {
@@ -55,6 +56,11 @@ export async function cleanupEmbeddedAttemptResources(params: {
} catch { } catch {
/* best-effort */ /* best-effort */
} }
try {
await params.bundleMcpRuntime?.dispose();
} catch {
/* best-effort */
}
try { try {
await params.bundleLspRuntime?.dispose(); await params.bundleLspRuntime?.dispose();
} catch { } catch {

View File

@@ -83,6 +83,7 @@ import { supportsModelTools } from "../../model-tool-support.js";
import { releaseWsSession } from "../../openai-ws-stream.js"; import { releaseWsSession } from "../../openai-ws-stream.js";
import { resolveOwnerDisplaySetting } from "../../owner-display.js"; import { resolveOwnerDisplaySetting } from "../../owner-display.js";
import { createBundleLspToolRuntime } from "../../pi-bundle-lsp-runtime.js"; import { createBundleLspToolRuntime } from "../../pi-bundle-lsp-runtime.js";
import { TOOL_NAME_SEPARATOR } from "../../pi-bundle-mcp-names.js";
import { import {
getOrCreateSessionMcpRuntime, getOrCreateSessionMcpRuntime,
materializeBundleMcpToolsForRun, materializeBundleMcpToolsForRun,
@@ -465,6 +466,20 @@ export function applyEmbeddedAttemptToolsAllow<T extends { name: string }>(
return tools.filter((tool) => allowSet.has(tool.name)); return tools.filter((tool) => allowSet.has(tool.name));
} }
function shouldCreateBundleMcpRuntimeForAttempt(params: {
toolsEnabled: boolean;
disableTools?: boolean;
toolsAllow?: string[];
}): boolean {
if (!params.toolsEnabled || params.disableTools === true) {
return false;
}
if (!params.toolsAllow || params.toolsAllow.length === 0) {
return true;
}
return params.toolsAllow.some((toolName) => toolName.includes(TOOL_NAME_SEPARATOR));
}
function collectAttemptExplicitToolAllowlistSources(params: { function collectAttemptExplicitToolAllowlistSources(params: {
config?: EmbeddedRunAttemptParams["config"]; config?: EmbeddedRunAttemptParams["config"];
sessionKey?: string; sessionKey?: string;
@@ -835,7 +850,12 @@ export async function runEmbeddedAttempt(
model: params.model, model: params.model,
}); });
const clientTools = toolsEnabled ? params.clientTools : undefined; const clientTools = toolsEnabled ? params.clientTools : undefined;
const bundleMcpSessionRuntime = toolsEnabled const bundleMcpEnabled = shouldCreateBundleMcpRuntimeForAttempt({
toolsEnabled,
disableTools: params.disableTools,
toolsAllow: params.toolsAllow,
});
const bundleMcpSessionRuntime = bundleMcpEnabled
? await getOrCreateSessionMcpRuntime({ ? await getOrCreateSessionMcpRuntime({
sessionId: params.sessionId, sessionId: params.sessionId,
sessionKey: params.sessionKey, sessionKey: params.sessionKey,
@@ -3099,6 +3119,7 @@ export async function runEmbeddedAttempt(
allowWsSessionPool: allowWsSessionPool:
!promptError && !aborted && !timedOut && !idleTimedOut && !timedOutDuringCompaction, !promptError && !aborted && !timedOut && !idleTimedOut && !timedOutDuringCompaction,
sessionId: params.sessionId, sessionId: params.sessionId,
bundleMcpRuntime,
bundleLspRuntime, bundleLspRuntime,
sessionLock, sessionLock,
}); });

View File

@@ -479,6 +479,8 @@ async function probeTarget(params: {
reasoningLevel: "off", reasoningLevel: "off",
verboseLevel: "off", verboseLevel: "off",
streamParams: { maxTokens }, streamParams: { maxTokens },
disableTools: true,
cleanupBundleMcpOnRunEnd: true,
}); });
return buildResult("ok"); return buildResult("ok");
} catch (err) { } catch (err) {

View File

@@ -22503,6 +22503,13 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description: description:
"Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.",
}, },
sessionIdleTtlMs: {
type: "number",
minimum: 0,
title: "MCP Runtime Idle TTL",
description:
"Idle TTL in milliseconds for session-scoped bundled MCP runtimes. Defaults to 10 minutes; set 0 to disable idle eviction.",
},
}, },
additionalProperties: false, additionalProperties: false,
title: "MCP", title: "MCP",
@@ -26343,6 +26350,11 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
help: "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", help: "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.",
tags: ["advanced"], tags: ["advanced"],
}, },
"mcp.sessionIdleTtlMs": {
label: "MCP Runtime Idle TTL",
help: "Idle TTL in milliseconds for session-scoped bundled MCP runtimes. Defaults to 10 minutes; set 0 to disable idle eviction.",
tags: ["storage"],
},
"ui.seamColor": { "ui.seamColor": {
label: "Accent Color", label: "Accent Color",
help: "Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.", help: "Primary accent color used by UI surfaces for emphasis, badges, and visual identity cues. Use high-contrast values that remain readable across light/dark themes.",

View File

@@ -1313,6 +1313,8 @@ export const FIELD_HELP: Record<string, string> = {
mcp: "Global MCP server definitions managed by OpenClaw. Embedded Pi and other runtime adapters can consume these servers without storing them inside Pi-owned project settings.", mcp: "Global MCP server definitions managed by OpenClaw. Embedded Pi and other runtime adapters can consume these servers without storing them inside Pi-owned project settings.",
"mcp.servers": "mcp.servers":
"Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.", "Named MCP server definitions. OpenClaw stores them in its own config and runtime adapters decide which transports are supported at execution time.",
"mcp.sessionIdleTtlMs":
"Idle TTL in milliseconds for session-scoped bundled MCP runtimes. Defaults to 10 minutes; set 0 to disable idle eviction.",
session: session:
"Global session routing, reset, delivery policy, and maintenance controls for conversation history behavior. Keep defaults unless you need stricter isolation, retention, or delivery constraints.", "Global session routing, reset, delivery policy, and maintenance controls for conversation history behavior. Keep defaults unless you need stricter isolation, retention, or delivery constraints.",
"session.scope": "session.scope":

View File

@@ -620,6 +620,7 @@ export const FIELD_LABELS: Record<string, string> = {
"commands.allowFrom": "Command Elevated Access Rules", "commands.allowFrom": "Command Elevated Access Rules",
mcp: "MCP", mcp: "MCP",
"mcp.servers": "MCP Servers", "mcp.servers": "MCP Servers",
"mcp.sessionIdleTtlMs": "MCP Runtime Idle TTL",
ui: "UI", ui: "UI",
"ui.seamColor": "Accent Color", "ui.seamColor": "Accent Color",
"ui.assistant": "Assistant Appearance", "ui.assistant": "Assistant Appearance",

View File

@@ -23,4 +23,10 @@ export type McpServerConfig = {
export type McpConfig = { export type McpConfig = {
/** Named MCP server definitions managed by OpenClaw. */ /** Named MCP server definitions managed by OpenClaw. */
servers?: Record<string, McpServerConfig>; servers?: Record<string, McpServerConfig>;
/**
* Idle TTL for session-scoped bundled MCP runtimes, in milliseconds.
*
* Defaults to 10 minutes. Set to 0 to disable idle eviction.
*/
sessionIdleTtlMs?: number;
}; };

View File

@@ -231,6 +231,7 @@ const McpServerSchema = z
const McpConfigSchema = z const McpConfigSchema = z
.object({ .object({
servers: z.record(z.string(), McpServerSchema).optional(), servers: z.record(z.string(), McpServerSchema).optional(),
sessionIdleTtlMs: z.number().finite().min(0).optional(),
}) })
.strict() .strict()
.optional(); .optional();

View File

@@ -34,6 +34,7 @@ describe("generateSlugViaLLM", () => {
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ expect.objectContaining({
timeoutMs: 15_000, timeoutMs: 15_000,
cleanupBundleMcpOnRunEnd: true,
}), }),
); );
}); });

View File

@@ -75,6 +75,7 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design",
model, model,
timeoutMs, timeoutMs,
runId: `slug-gen-${Date.now()}`, runId: `slug-gen-${Date.now()}`,
cleanupBundleMcpOnRunEnd: true,
}); });
// Extract text from payloads // Extract text from payloads