fix(gateway): cap mcp loopback tool cache

This commit is contained in:
Vincent Koc
2026-06-07 01:27:55 +02:00
parent 5f7cfd6451
commit 46e12e7aff
2 changed files with 49 additions and 3 deletions

View File

@@ -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;
}

View File

@@ -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",