mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-30 20:53:34 +00:00
fix(gateway): cap mcp loopback tool cache
This commit is contained in:
@@ -14,6 +14,7 @@ import { resolveGatewayScopedTools } from "./tool-resolution.js";
|
||||
// context and caches the expensive schema projection for short bursts of tool
|
||||
// list/call traffic from the same MCP client.
|
||||
const TOOL_CACHE_TTL_MS = 30_000;
|
||||
const TOOL_CACHE_MAX_ENTRIES = 256;
|
||||
const NATIVE_TOOL_EXCLUDE = new Set(["read", "write", "edit", "apply_patch", "exec", "process"]);
|
||||
|
||||
type CachedScopedTools = {
|
||||
@@ -72,6 +73,11 @@ export class McpLoopbackToolCache {
|
||||
params.senderIsOwner === true ? "owner" : "non-owner",
|
||||
].join("\u0000");
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of this.#entries) {
|
||||
if (now - entry.time >= TOOL_CACHE_TTL_MS) {
|
||||
this.#entries.delete(key);
|
||||
}
|
||||
}
|
||||
const cached = this.#entries.get(cacheKey);
|
||||
// Config object identity is part of the cache contract so explicit gateway
|
||||
// reloads invalidate tool scope and schema without filesystem polling.
|
||||
@@ -88,10 +94,12 @@ export class McpLoopbackToolCache {
|
||||
time: now,
|
||||
};
|
||||
this.#entries.set(cacheKey, nextEntry);
|
||||
for (const [key, entry] of this.#entries) {
|
||||
if (now - entry.time >= TOOL_CACHE_TTL_MS) {
|
||||
this.#entries.delete(key);
|
||||
while (this.#entries.size > TOOL_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = this.#entries.keys().next().value;
|
||||
if (oldestKey === undefined) {
|
||||
break;
|
||||
}
|
||||
this.#entries.delete(oldestKey);
|
||||
}
|
||||
return nextEntry;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
ensureMcpLoopbackServer,
|
||||
startMcpLoopbackServer,
|
||||
} from "./mcp-http.js";
|
||||
import { McpLoopbackToolCache } from "./mcp-http.runtime.js";
|
||||
|
||||
let server: Awaited<ReturnType<typeof startMcpLoopbackServer>> | undefined;
|
||||
|
||||
@@ -576,6 +577,43 @@ describe("mcp loopback server", () => {
|
||||
expect(getScopedToolsCall(3).currentInboundAudio).toBe(true);
|
||||
});
|
||||
|
||||
it("caps loopback tool cache cardinality by evicting oldest contexts", () => {
|
||||
const cache = new McpLoopbackToolCache();
|
||||
const baseParams = {
|
||||
accountId: undefined,
|
||||
cfg: { session: { mainKey: "main" } } as never,
|
||||
currentChannelId: "telegram:chat123",
|
||||
currentInboundAudio: undefined,
|
||||
currentMessageId: undefined,
|
||||
currentThreadTs: "thread-1",
|
||||
inboundEventKind: "room_event",
|
||||
messageProvider: "telegram",
|
||||
senderIsOwner: true,
|
||||
sessionKey: "agent:main:telegram:group:chat123",
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
} satisfies Parameters<McpLoopbackToolCache["resolve"]>[0];
|
||||
|
||||
for (let index = 0; index < 257; index += 1) {
|
||||
cache.resolve({
|
||||
...baseParams,
|
||||
currentMessageId: `message-${index}`,
|
||||
});
|
||||
}
|
||||
expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(257);
|
||||
|
||||
cache.resolve({
|
||||
...baseParams,
|
||||
currentMessageId: "message-0",
|
||||
});
|
||||
expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(258);
|
||||
|
||||
cache.resolve({
|
||||
...baseParams,
|
||||
currentMessageId: "message-256",
|
||||
});
|
||||
expect(resolveGatewayScopedToolsMock).toHaveBeenCalledTimes(258);
|
||||
});
|
||||
|
||||
it("adds empty properties for object schemas that omit properties", async () => {
|
||||
resolveGatewayScopedToolsMock.mockReturnValue({
|
||||
agentId: "main",
|
||||
|
||||
Reference in New Issue
Block a user