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
- 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.
- 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.

View File

@@ -1,4 +1,4 @@
3b8ff208a31b04ea61391182444bd744357577872eac279136bbc284c3dc064a config-baseline.json
4dfeadeb814fb205f5a17d797cbbe3c07685009821fe8dbf8771ea428ed5b4dd config-baseline.core.json
13b68287fec00108ca66032120909a0eac797ed541e026357e175e3fce5bacdd config-baseline.json
77ee66fb3b2cde94b393712bc03a132b096cf601c193bde1fe42902eecb0b66b config-baseline.core.json
d72032762ab46b99480b57deb81130a0ab5b1401189cfbaf4f7fef4a063a7f6c config-baseline.channel.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`
tool profiles; `minimal` still hides them, and `tools.deny: ["bundle-mcp"]`
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

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
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
- **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
[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
```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,
reasoningLevel: "off",
silentExpected: true,
cleanupBundleMcpOnRunEnd: true,
abortSignal: params.abortSignal,
});
if (params.abortSignal?.aborted) {

View File

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

View File

@@ -26,13 +26,19 @@ function makeRuntime(
tools: Array<{ toolName: string; description: string }>,
serverName = "bundleProbe",
): SessionMcpRuntime {
const createdAt = Date.now();
let lastUsedAt = createdAt;
return {
sessionId: "session-colliding-tools",
workspaceDir: "/tmp",
configFingerprint: "fingerprint",
createdAt: 0,
lastUsedAt: 0,
markUsed: () => {},
createdAt,
get lastUsedAt() {
return lastUsedAt;
},
markUsed: () => {
lastUsedAt = Date.now();
},
getCatalog: async () => ({
version: 1,
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 () => {
const created: SessionMcpRuntime[] = [];
const disposed: string[] = [];
@@ -361,4 +388,94 @@ describe("session MCP runtime", () => {
retireSessionMcpRuntimeForSessionKey({ sessionKey: "agent:test:missing", reason: "test" }),
).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 SESSION_MCP_RUNTIME_MANAGER_KEY = Symbol.for("openclaw.sessionMcpRuntimeManager");
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 = {
compile: (schema: JsonSchemaType) => ValidateFunction;
@@ -168,6 +170,14 @@ function createDisposedError(sessionId: string): Error {
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: {
sessionId: string;
sessionKey?: string;
@@ -181,6 +191,7 @@ export function createSessionMcpRuntime(params: {
});
const createdAt = Date.now();
let lastUsedAt = createdAt;
let activeLeases = 0;
let disposed = false;
let catalog: McpToolCatalog | null = null;
let catalogInFlight: Promise<McpToolCatalog> | undefined;
@@ -318,6 +329,21 @@ export function createSessionMcpRuntime(params: {
get 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,
markUsed() {
lastUsedAt = Date.now();
@@ -349,11 +375,18 @@ export function createSessionMcpRuntime(params: {
}
function createSessionMcpRuntimeManager(
opts: { createRuntime?: CreateSessionMcpRuntime } = {},
opts: {
createRuntime?: CreateSessionMcpRuntime;
now?: () => number;
enableIdleSweepTimer?: boolean;
idleSweepIntervalMs?: number;
} = {},
): SessionMcpRuntimeManager {
const runtimesBySessionId = new Map<string, SessionMcpRuntime>();
const sessionIdBySessionKey = new Map<string, string>();
const idleTtlMsBySessionId = new Map<string, number>();
const createRuntime = opts.createRuntime ?? createSessionMcpRuntime;
const now = opts.now ?? Date.now;
const createInFlight = new Map<
string,
{
@@ -362,9 +395,79 @@ function createSessionMcpRuntimeManager(
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 {
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) {
sessionIdBySessionKey.set(params.sessionKey, params.sessionId);
}
@@ -383,6 +486,7 @@ function createSessionMcpRuntimeManager(
await existing.dispose();
} else {
existing.markUsed();
idleTtlMsBySessionId.set(params.sessionId, idleTtlMs);
return existing;
}
}
@@ -397,6 +501,7 @@ function createSessionMcpRuntimeManager(
createInFlight.delete(params.sessionId);
const staleRuntime = await inFlight.promise.catch(() => undefined);
runtimesBySessionId.delete(params.sessionId);
idleTtlMsBySessionId.delete(params.sessionId);
await staleRuntime?.dispose();
}
const created = Promise.resolve(
@@ -410,6 +515,7 @@ function createSessionMcpRuntimeManager(
).then((runtime) => {
runtime.markUsed();
runtimesBySessionId.set(params.sessionId, runtime);
idleTtlMsBySessionId.set(params.sessionId, idleTtlMs);
return runtime;
});
createInFlight.set(params.sessionId, {
@@ -437,27 +543,22 @@ function createSessionMcpRuntimeManager(
runtime = await inFlight.promise.catch(() => undefined);
}
runtimesBySessionId.delete(sessionId);
idleTtlMsBySessionId.delete(sessionId);
if (!runtime) {
for (const [sessionKey, mappedSessionId] of sessionIdBySessionKey.entries()) {
if (mappedSessionId === sessionId) {
sessionIdBySessionKey.delete(sessionKey);
}
}
forgetSessionKeysForSessionId(sessionId);
return;
}
for (const [sessionKey, mappedSessionId] of sessionIdBySessionKey.entries()) {
if (mappedSessionId === sessionId) {
sessionIdBySessionKey.delete(sessionKey);
}
}
forgetSessionKeysForSessionId(sessionId);
await runtime.dispose();
},
async disposeAll() {
clearIdleSweepTimer();
const inFlightRuntimes = Array.from(createInFlight.values());
createInFlight.clear();
const runtimes = Array.from(runtimesBySessionId.values());
runtimesBySessionId.clear();
sessionIdBySessionKey.clear();
idleTtlMsBySessionId.clear();
const lateRuntimes = await Promise.all(
inFlightRuntimes.map(async ({ promise }) => await promise.catch(() => undefined)),
);
@@ -469,6 +570,7 @@ function createSessionMcpRuntimeManager(
}
await Promise.allSettled(Array.from(allRuntimes, (runtime) => runtime.dispose()));
},
sweepIdleRuntimes,
listSessionIds() {
return Array.from(runtimesBySessionId.keys());
},
@@ -539,4 +641,5 @@ export const __testing = {
getCachedSessionIds() {
return getSessionMcpRuntimeManager().listSessionIds();
},
resolveSessionMcpRuntimeIdleTtlMs,
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -22503,6 +22503,13 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
description:
"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,
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.",
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": {
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.",

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.servers":
"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:
"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":

View File

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

View File

@@ -23,4 +23,10 @@ export type McpServerConfig = {
export type McpConfig = {
/** Named MCP server definitions managed by OpenClaw. */
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
.object({
servers: z.record(z.string(), McpServerSchema).optional(),
sessionIdleTtlMs: z.number().finite().min(0).optional(),
})
.strict()
.optional();

View File

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

View File

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