Skip to content

Commit 2ca9eed

Browse files
committed
fix(cache): sort MCP tools deterministically to stabilize prompt cache (#58037) (thanks @bcherny)
1 parent d01cb5e commit 2ca9eed

4 files changed

Lines changed: 52 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Docs: https://docs.openclaw.ai
101101
- Matrix/media: surface a dedicated `[matrix <kind> attachment too large]` marker for oversized inbound media instead of the generic unavailable marker, and classify size-limit failures with a typed Matrix error. (#60289) Thanks @efe-arv.
102102
- WhatsApp/watchdog: reset watchdog timeout after reconnect so quiet channels no longer enter a tight reconnect loop from stale message timestamps carried across connection runs. (#60007) Thanks @MonkeyLeeT.
103103
- Agents/fallback: persist selected fallback overrides before retry attempts start, prefer persisted overrides during live-session reconciliation, and keep provider-scoped auth-profile failover from snapping retries back to stale primary selections.
104+
- Agents/MCP: sort MCP tools deterministically by name so the tools block in API requests is stable across turns, preventing unnecessary prompt-cache busting from non-deterministic `listTools()` order. (#58037) Thanks @bcherny.
104105

105106
## 2026.4.2
106107

src/agents/pi-bundle-mcp-materialize.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ export async function materializeBundleMcpToolsForRun(params: {
101101
});
102102
}
103103

104+
// Sort tools deterministically by name so the tools block in API requests is
105+
// stable across turns. MCP's listTools() does not guarantee order, and any
106+
// change in the tools array busts the prompt cache at the tools block.
107+
tools.sort((a, b) => a.name.localeCompare(b.name));
108+
104109
return {
105110
tools,
106111
dispose: async () => {

src/agents/pi-bundle-mcp-test-harness.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export async function makeTempDir(prefix: string): Promise<string> {
2525
return dir;
2626
}
2727

28-
async function writeExecutable(filePath: string, content: string): Promise<void> {
28+
export async function writeExecutable(filePath: string, content: string): Promise<void> {
2929
await fs.mkdir(path.dirname(filePath), { recursive: true });
3030
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
3131
}

src/agents/pi-bundle-mcp-tools.materialize.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createRequire } from "node:module";
12
import path from "node:path";
23
import { afterEach, describe, expect, it } from "vitest";
34
import {
@@ -6,9 +7,14 @@ import {
67
startSseProbeServer,
78
writeBundleProbeMcpServer,
89
writeClaudeBundle,
10+
writeExecutable,
911
} from "./pi-bundle-mcp-test-harness.js";
1012
import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js";
1113

14+
const require = createRequire(import.meta.url);
15+
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
16+
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
17+
1218
afterEach(async () => {
1319
await cleanupBundleMcpHarness();
1420
});
@@ -154,4 +160,43 @@ describe("createBundleMcpToolRuntime", () => {
154160
await sseServer.close();
155161
}
156162
});
163+
164+
it("returns tools sorted alphabetically for stable prompt-cache keys", async () => {
165+
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
166+
const serverScriptPath = path.join(workspaceDir, "servers", "multi-tool.mjs");
167+
// Register tools in non-alphabetical order; runtime must sort them.
168+
await writeExecutable(
169+
serverScriptPath,
170+
`#!/usr/bin/env node
171+
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
172+
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
173+
const server = new McpServer({ name: "multi", version: "1.0.0" });
174+
server.tool("zeta", "z", async () => ({ content: [{ type: "text", text: "z" }] }));
175+
server.tool("alpha", "a", async () => ({ content: [{ type: "text", text: "a" }] }));
176+
server.tool("mu", "m", async () => ({ content: [{ type: "text", text: "m" }] }));
177+
await server.connect(new StdioServerTransport());
178+
`,
179+
);
180+
181+
const runtime = await createBundleMcpToolRuntime({
182+
workspaceDir,
183+
cfg: {
184+
mcp: {
185+
servers: {
186+
multi: { command: "node", args: [serverScriptPath] },
187+
},
188+
},
189+
},
190+
});
191+
192+
try {
193+
expect(runtime.tools.map((tool) => tool.name)).toEqual([
194+
"multi__alpha",
195+
"multi__mu",
196+
"multi__zeta",
197+
]);
198+
} finally {
199+
await runtime.dispose();
200+
}
201+
});
157202
});

0 commit comments

Comments
 (0)