diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index b3f044b4fd9..c2959677b22 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -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; } diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index d32338d8818..95358619a4b 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -105,6 +105,7 @@ import { ensureMcpLoopbackServer, startMcpLoopbackServer, } from "./mcp-http.js"; +import { McpLoopbackToolCache } from "./mcp-http.runtime.js"; let server: Awaited> | 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[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",