diff --git a/CHANGELOG.md b/CHANGELOG.md index 36e320e62c8..5b4a1dbba7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai - Matrix/media: surface a dedicated `[matrix 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 diff --git a/src/agents/pi-bundle-mcp-materialize.ts b/src/agents/pi-bundle-mcp-materialize.ts index 65c335c5ef9..7a1b95a38e9 100644 --- a/src/agents/pi-bundle-mcp-materialize.ts +++ b/src/agents/pi-bundle-mcp-materialize.ts @@ -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 () => { diff --git a/src/agents/pi-bundle-mcp-test-harness.ts b/src/agents/pi-bundle-mcp-test-harness.ts index ffb7a2a310a..a5b5de9274b 100644 --- a/src/agents/pi-bundle-mcp-test-harness.ts +++ b/src/agents/pi-bundle-mcp-test-harness.ts @@ -25,7 +25,7 @@ export async function makeTempDir(prefix: string): Promise { return dir; } -async function writeExecutable(filePath: string, content: string): Promise { +export async function writeExecutable(filePath: string, content: string): Promise { await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, content, { encoding: "utf-8", mode: 0o755 }); } diff --git a/src/agents/pi-bundle-mcp-tools.materialize.test.ts b/src/agents/pi-bundle-mcp-tools.materialize.test.ts index 45f5db4f170..00e4ae77f77 100644 --- a/src/agents/pi-bundle-mcp-tools.materialize.test.ts +++ b/src/agents/pi-bundle-mcp-tools.materialize.test.ts @@ -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(); + } + }); });