diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb53bc55d8..0a11d98208a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 6e0d27250ce..1b448c49e4a 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/cli/mcp.md b/docs/cli/mcp.md index 2256616a4e0..77a6d64b8cb 100644 --- a/docs/cli/mcp.md +++ b/docs/cli/mcp.md @@ -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 diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md index a7b10a8c4d6..72bc541df76 100644 --- a/docs/gateway/cli-backends.md +++ b/docs/gateway/cli-backends.md @@ -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 diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index bace1d62c56..0880b163454 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -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 diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 072b2b22b1c..8a32d790c94 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -566,6 +566,7 @@ describe("active-memory plugin", () => { }, }, }, + cleanupBundleMcpOnRunEnd: true, }); }); diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index a7afcf84c29..3a3b548c75e 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -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) { diff --git a/src/agents/pi-bundle-mcp-materialize.ts b/src/agents/pi-bundle-mcp-materialize.ts index a222ea27663..4c33286486a 100644 --- a/src/agents/pi-bundle-mcp-materialize.ts +++ b/src/agents/pi-bundle-mcp-materialize.ts @@ -66,8 +66,16 @@ export async function materializeBundleMcpToolsForRun(params: { reservedToolNames?: Iterable; disposeRuntime?: () => Promise; }): Promise { + 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?.(); }, }; diff --git a/src/agents/pi-bundle-mcp-runtime.test.ts b/src/agents/pi-bundle-mcp-runtime.test.ts index 2f2eac1a6bb..dafaedc1062 100644 --- a/src/agents/pi-bundle-mcp-runtime.test.ts +++ b/src/agents/pi-bundle-mcp-runtime.test.ts @@ -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([]); + }); }); diff --git a/src/agents/pi-bundle-mcp-runtime.ts b/src/agents/pi-bundle-mcp-runtime.ts index 801a4039185..0b610e9eaf1 100644 --- a/src/agents/pi-bundle-mcp-runtime.ts +++ b/src/agents/pi-bundle-mcp-runtime.ts @@ -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 | 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(); const sessionIdBySessionKey = new Map(); + const idleTtlMsBySessionId = new Map(); 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 | undefined; + let idleSweepInFlight: Promise | undefined; + + const forgetSessionKeysForSessionId = (sessionId: string) => { + for (const [sessionKey, mappedSessionId] of sessionIdBySessionKey.entries()) { + if (mappedSessionId === sessionId) { + sessionIdBySessionKey.delete(sessionKey); + } + } + }; + + const sweepIdleRuntimes = async (): Promise => { + 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, }; diff --git a/src/agents/pi-bundle-mcp-types.ts b/src/agents/pi-bundle-mcp-types.ts index 83d962ea64f..951e27566b1 100644 --- a/src/agents/pi-bundle-mcp-types.ts +++ b/src/agents/pi-bundle-mcp-types.ts @@ -38,6 +38,8 @@ export type SessionMcpRuntime = { configFingerprint: string; createdAt: number; lastUsedAt: number; + activeLeases?: number; + acquireLease?: () => () => void; getCatalog: () => Promise; markUsed: () => void; callTool: (serverName: string, toolName: string, input: unknown) => Promise; @@ -55,5 +57,6 @@ export type SessionMcpRuntimeManager = { resolveSessionId: (sessionKey: string) => string | undefined; disposeSession: (sessionId: string) => Promise; disposeAll: () => Promise; + sweepIdleRuntimes: () => Promise; listSessionIds: () => string[]; }; diff --git a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts index ed5647dcf9c..5c11eec1412 100644 --- a/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts +++ b/src/agents/pi-embedded-runner/run/attempt.subscription-cleanup.ts @@ -27,6 +27,7 @@ export async function cleanupEmbeddedAttemptResources(params: { releaseWsSession: (sessionId: string, options?: { allowPool?: boolean }) => void; allowWsSessionPool?: boolean; sessionId: string; + bundleMcpRuntime?: { dispose(): Promise | void }; bundleLspRuntime?: { dispose(): Promise | void }; sessionLock: { release(): Promise | void }; }): Promise { @@ -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 { diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 6acbf5749e4..15005d1604e 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -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( 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, }); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index 9020e0e4556..8d7abc87115 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -479,6 +479,8 @@ async function probeTarget(params: { reasoningLevel: "off", verboseLevel: "off", streamParams: { maxTokens }, + disableTools: true, + cleanupBundleMcpOnRunEnd: true, }); return buildResult("ok"); } catch (err) { diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 5e66ea99c2c..2926220475c 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -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.", diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d632ab41bc4..02d61ddeaa8 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -1313,6 +1313,8 @@ export const FIELD_HELP: Record = { 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": diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 694e2dffa01..b27b439e2ac 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -620,6 +620,7 @@ export const FIELD_LABELS: Record = { "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", diff --git a/src/config/types.mcp.ts b/src/config/types.mcp.ts index 2de3bd4ab3b..0ca78673f44 100644 --- a/src/config/types.mcp.ts +++ b/src/config/types.mcp.ts @@ -23,4 +23,10 @@ export type McpServerConfig = { export type McpConfig = { /** Named MCP server definitions managed by OpenClaw. */ servers?: Record; + /** + * Idle TTL for session-scoped bundled MCP runtimes, in milliseconds. + * + * Defaults to 10 minutes. Set to 0 to disable idle eviction. + */ + sessionIdleTtlMs?: number; }; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 2cb1d00fd17..013d919d122 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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(); diff --git a/src/hooks/llm-slug-generator.test.ts b/src/hooks/llm-slug-generator.test.ts index 94c8d81f499..4467d21e7b5 100644 --- a/src/hooks/llm-slug-generator.test.ts +++ b/src/hooks/llm-slug-generator.test.ts @@ -34,6 +34,7 @@ describe("generateSlugViaLLM", () => { expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual( expect.objectContaining({ timeoutMs: 15_000, + cleanupBundleMcpOnRunEnd: true, }), ); }); diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index fe600d39a4d..f32d71562d3 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -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