fix(cache): sort MCP tools deterministically to stabilize prompt cache (#58037)

Co-authored-by: George Zhang <georgezhangtj97@gmail.com>
This commit is contained in:
Boris Cherny
2026-04-03 17:19:53 -07:00
committed by GitHub
parent 881f7dc82f
commit bc16b9dccf
4 changed files with 52 additions and 1 deletions

View File

@@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
## 2026.4.2

View File

@@ -101,6 +101,11 @@ export async function materializeBundleMcpToolsForRun(params: {
});
}
// Sort tools deterministically by name so the tools block in API requests is
// stable across turns. MCP's listTools() does not guarantee order, and any
// change in the tools array busts the prompt cache at the tools block.
tools.sort((a, b) => a.name.localeCompare(b.name));
return {
tools,
dispose: async () => {

View File

@@ -25,7 +25,7 @@ export async function makeTempDir(prefix: string): Promise<string> {
return dir;
}
async function writeExecutable(filePath: string, content: string): Promise<void> {
export async function writeExecutable(filePath: string, content: string): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 });
}

View File

@@ -1,3 +1,4 @@
import { createRequire } from "node:module";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
@@ -6,9 +7,14 @@ import {
startSseProbeServer,
writeBundleProbeMcpServer,
writeClaudeBundle,
writeExecutable,
} from "./pi-bundle-mcp-test-harness.js";
import { createBundleMcpToolRuntime } from "./pi-bundle-mcp-tools.js";
const require = createRequire(import.meta.url);
const SDK_SERVER_MCP_PATH = require.resolve("@modelcontextprotocol/sdk/server/mcp.js");
const SDK_SERVER_STDIO_PATH = require.resolve("@modelcontextprotocol/sdk/server/stdio.js");
afterEach(async () => {
await cleanupBundleMcpHarness();
});
@@ -154,4 +160,43 @@ describe("createBundleMcpToolRuntime", () => {
await sseServer.close();
}
});
it("returns tools sorted alphabetically for stable prompt-cache keys", async () => {
const workspaceDir = await makeTempDir("openclaw-bundle-mcp-tools-");
const serverScriptPath = path.join(workspaceDir, "servers", "multi-tool.mjs");
// Register tools in non-alphabetical order; runtime must sort them.
await writeExecutable(
serverScriptPath,
`#!/usr/bin/env node
import { McpServer } from ${JSON.stringify(SDK_SERVER_MCP_PATH)};
import { StdioServerTransport } from ${JSON.stringify(SDK_SERVER_STDIO_PATH)};
const server = new McpServer({ name: "multi", version: "1.0.0" });
server.tool("zeta", "z", async () => ({ content: [{ type: "text", text: "z" }] }));
server.tool("alpha", "a", async () => ({ content: [{ type: "text", text: "a" }] }));
server.tool("mu", "m", async () => ({ content: [{ type: "text", text: "m" }] }));
await server.connect(new StdioServerTransport());
`,
);
const runtime = await createBundleMcpToolRuntime({
workspaceDir,
cfg: {
mcp: {
servers: {
multi: { command: "node", args: [serverScriptPath] },
},
},
},
});
try {
expect(runtime.tools.map((tool) => tool.name)).toEqual([
"multi__alpha",
"multi__mu",
"multi__zeta",
]);
} finally {
await runtime.dispose();
}
});
});