mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
fix: retire idle bundled MCP runtimes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -566,6 +566,7 @@ describe("active-memory plugin", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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?.();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -479,6 +479,8 @@ async function probeTarget(params: {
|
||||
reasoningLevel: "off",
|
||||
verboseLevel: "off",
|
||||
streamParams: { maxTokens },
|
||||
disableTools: true,
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
});
|
||||
return buildResult("ok");
|
||||
} catch (err) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("generateSlugViaLLM", () => {
|
||||
expect(runEmbeddedPiAgentMock.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
timeoutMs: 15_000,
|
||||
cleanupBundleMcpOnRunEnd: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user