From f222d8b1c368a222ffc52b78bde4e0b0d00ac709 Mon Sep 17 00:00:00 2001 From: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:42:04 +0900 Subject: [PATCH 1/6] fix(cursor): preserve bare client tool names for Claude Code sessions When Claude Code connects via /v1/messages using a Cursor-backed model, bare tools (Bash, Read, Write, Edit, Grep, Glob, Task, etc.) were previously aliased with the ocx_client_ prefix. This caused catalog/prompt contradiction with Claude Code's system prompt, triggering fallback to Cursor-native execution frames. OpenCodex's redirect hint (Re-issue this operation NOW through one of the tools: ocx_client_*...) was then flagged by frontier Claude models as prompt-injection, causing refusal loops and halting execution. - Conditionally exempt Claude bare tool names from ocx_client_ aliasing when no Codex shell bridge (exec_command/shell_command) is present in the turn. - Pass the turn's catalog to cursorToolWireName across tool definitions, guidance notes, native exec hints, and request building. - Suppress neighboring-agent tool name warnings in tool guidance when running without a Codex shell bridge. - Add unit tests verifying Claude Code tool preservation and Codex shell bridge aliasing. --- src/adapters/cursor/live-transport.ts | 4 +- src/adapters/cursor/native-exec.ts | 2 +- src/adapters/cursor/request-builder.ts | 4 +- src/adapters/cursor/tool-definitions.ts | 4 +- src/adapters/cursor/tool-guidance.ts | 12 ++-- src/adapters/cursor/tool-naming.ts | 43 +++++++++++++-- .../cursor/cursor-tool-definitions.test.ts | 55 +++++++++++++++++++ 7 files changed, 108 insertions(+), 16 deletions(-) diff --git a/src/adapters/cursor/live-transport.ts b/src/adapters/cursor/live-transport.ts index a1c59f8d394..b68464d14c5 100644 --- a/src/adapters/cursor/live-transport.ts +++ b/src/adapters/cursor/live-transport.ts @@ -706,7 +706,7 @@ class LiveCursorTransport implements CursorTransport { const syntheticStructuredEditToolNames = new Set( (cursorVisibleTools ?? []) .filter(isCursorSyntheticStructuredEditTool) - .map(cursorToolWireName), + .map(tool => cursorToolWireName(tool, cursorVisibleTools)), ); const freeformToolNames = new Set( (cursorVisibleTools ?? []) @@ -723,7 +723,7 @@ class LiveCursorTransport implements CursorTransport { const toolSchemas = new Map(); const cursorToolNameMap = new Map(); for (const tool of cursorVisibleTools ?? []) { - const cursorWireName = cursorToolWireName(tool); + const cursorWireName = cursorToolWireName(tool, cursorVisibleTools); // Normalize against Responses/Codex field names, not the Cursor advertisement schema. // Advertising `cmd` while also storing that schema here left `cmd` unmapped and Codex // rejected shell_command with "missing field `command`" (#399). diff --git a/src/adapters/cursor/native-exec.ts b/src/adapters/cursor/native-exec.ts index f08e98a9313..aa195e31dbe 100644 --- a/src/adapters/cursor/native-exec.ts +++ b/src/adapters/cursor/native-exec.ts @@ -107,7 +107,7 @@ export function cursorNativeExecRedirectHint( // A request with no client tools but configured MCP tools still gets those named; a request that // advertises nothing at all keeps the default bridge wording. const names = [...new Set([ - ...clientTools.map(cursorToolWireName), + ...clientTools.map(tool => cursorToolWireName(tool, clientTools)), ...mcpToolDefs.map(def => `mcp_${def.providerIdentifier}_${def.name}`), ])]; if (names.length === 0) return undefined; diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index bce63a78e3a..3b28e7cb50f 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -171,8 +171,8 @@ export function applyCursorToolBudget( function catalogLimitNote(kept: readonly OcxTool[], omitted: readonly OcxTool[]): string | undefined { if (omitted.length === 0) return undefined; - const recoverable = kept.some(tool => tool.toolSearch || cursorToolWireName(tool) === "tool_search"); - const names = omitted.slice(0, 12).map(cursorToolWireName); + const recoverable = kept.some(tool => tool.toolSearch || cursorToolWireName(tool, kept) === "tool_search"); + const names = omitted.slice(0, 12).map(tool => cursorToolWireName(tool, kept)); const remainder = omitted.length - names.length; const omittedSummary = `${names.join(", ")}${remainder > 0 ? `, and ${remainder} more` : ""}`; return recoverable diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 12be95cdf53..7fb4300a6c1 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -4,7 +4,7 @@ import type { OcxRequestOptions, OcxTool } from "../../types"; import { McpToolDefinitionSchema, McpToolsSchema, type McpToolDefinition } from "./gen/agent_pb"; import { CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, cursorRequestAdvertisesApplyPatch, cursorToolAllowedByChoice, cursorToolWireName, OCX_RESPONSES_TOOL_PROVIDER } from "./tool-naming"; import { CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, cursorToolInputSchema } from "./tool-schemas"; -export { OCX_RESPONSES_TOOL_PROVIDER, CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL, CODEX_UNIFIED_EXEC_TOOL, CODEX_WAIT_TOOL, CODEX_APPLY_PATCH_TOOL, CODEX_TOOL_SEARCH_TOOL, CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, CURSOR_STRUCTURED_EDIT_TOOLS, CURSOR_EXEC_COMMAND_TOOL, CODEX_SHELL_BRIDGE_TOOL_NAMES, isCodexShellBridgeToolName, resolveShellBridgeAliasKey, cursorToolChoiceAliases, isBareCodexShellBridgeTool, isCursorExecutionPathTool, isCursorWaitTool, isCursorCodeModeExecTool, cursorRequestUsesCodeMode, cursorRequestHasShellAlias, cursorRequestAdvertisesApplyPatch, isCursorStructuredEditToolName, isCursorSyntheticStructuredEditTool, cursorToolWireName, normalizeCursorWireName, normalizeCursorTextToolMarkers, responsesToolNameFromCursorWire, cursorToolAllowedByChoice } from "./tool-naming"; +export { OCX_RESPONSES_TOOL_PROVIDER, CODEX_EXEC_COMMAND_TOOL, CODEX_SHELL_COMMAND_TOOL, CODEX_UNIFIED_EXEC_TOOL, CODEX_WAIT_TOOL, CODEX_APPLY_PATCH_TOOL, CODEX_TOOL_SEARCH_TOOL, CURSOR_EDIT_FILE_TOOL, CURSOR_MULTI_EDIT_TOOL, CURSOR_STRUCTURED_EDIT_TOOLS, CURSOR_EXEC_COMMAND_TOOL, CODEX_SHELL_BRIDGE_TOOL_NAMES, isCodexShellBridgeToolName, resolveShellBridgeAliasKey, cursorToolChoiceAliases, isBareCodexShellBridgeTool, isCursorExecutionPathTool, isCursorWaitTool, isCursorCodeModeExecTool, cursorRequestUsesCodeMode, cursorRequestHasShellAlias, cursorRequestAdvertisesApplyPatch, isCursorStructuredEditToolName, isCursorSyntheticStructuredEditTool, CLAUDE_CLIENT_BARE_TOOL_NAMES, isClaudeClientBareToolName, isCursorBareClientToolWireAliased, cursorToolWireName, normalizeCursorWireName, normalizeCursorTextToolMarkers, responsesToolNameFromCursorWire, cursorToolAllowedByChoice } from "./tool-naming"; export { CURSOR_EXEC_COMMAND_INPUT_SCHEMA, CURSOR_FREEFORM_INPUT_SCHEMA, CURSOR_EDIT_FILE_INPUT_SCHEMA, CURSOR_MULTI_EDIT_INPUT_SCHEMA, CODEX_SHELL_BRIDGE_ARG_NORMALIZE_SCHEMA, cursorToolInputSchema, cursorToolArgNormalizeSchema, shellBridgeRequiredCommandKeys, defaultShellBridgeArgNormalizeSchema, cursorShellBridgeDropError, nonEmptyShellBridgeCommandFromArgs, cursorShellBridgeArgsValid } from "./tool-schemas"; export { CURSOR_SHELL_ALIAS_SYSTEM_NOTE, CURSOR_GENERIC_TOOL_USE_USER_HINT, isGenericToolUseCountDemoPrompt, requestedCursorToolUseCount, shouldAppendCursorGenericToolUseHint, appendCursorGenericToolUseHint, shouldUseNativeExecOnlyForGenericToolUse, cursorToolsForActivePrompt, buildCursorToolGuidanceSystemNote } from "./tool-guidance"; @@ -83,7 +83,7 @@ export function buildCursorToolDefinitions( ): McpToolDefinition[] { if (!tools?.length) return []; return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools)).map(tool => { - const wireName = cursorToolWireName(tool); + const wireName = cursorToolWireName(tool, tools); return create(McpToolDefinitionSchema, { name: wireName, toolName: wireName, diff --git a/src/adapters/cursor/tool-guidance.ts b/src/adapters/cursor/tool-guidance.ts index 16daf244b89..90ad58f46a0 100644 --- a/src/adapters/cursor/tool-guidance.ts +++ b/src/adapters/cursor/tool-guidance.ts @@ -1,6 +1,6 @@ import type { OcxRequestOptions, OcxTool } from "../../types"; import { CODE_MODE_HOST_CONTRACT_SENTENCE, CODE_MODE_RESULT_ECHO_SENTENCE } from "../exec-tool-result-normalize"; -import { CODEX_SHELL_BRIDGE_TOOL_NAMES, CODEX_TOOL_SEARCH_TOOL, CODEX_UNIFIED_EXEC_TOOL, clientSemanticToolNameFromCursorWire, cursorRequestAdvertisesApplyPatch, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolAllowedByChoice, cursorToolWireName, isCodexShellBridgeToolName, isCursorExecutionPathTool, isCursorStructuredEditToolName } from "./tool-naming"; +import { CODEX_SHELL_BRIDGE_TOOL_NAMES, CODEX_TOOL_SEARCH_TOOL, CODEX_UNIFIED_EXEC_TOOL, clientSemanticToolNameFromCursorWire, cursorRequestAdvertisesApplyPatch, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolAllowedByChoice, cursorToolWireName, isBareCodexShellBridgeTool, isCodexShellBridgeToolName, isCursorExecutionPathTool, isCursorStructuredEditToolName } from "./tool-naming"; export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE = 'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell.'; @@ -129,7 +129,11 @@ function advertisedCoversNeighbor(wireNames: readonly string[], neighbor: (typeo return NEIGHBOR_AGENT_TOOL_ALIASES[neighbor].some(alias => advertised.has(alias.toLowerCase())); } -function unavailableNeighborAgentToolNames(wireNames: readonly string[]): string[] { +function unavailableNeighborAgentToolNames( + wireNames: readonly string[], + catalog?: readonly Pick[], +): string[] { + if (catalog && !catalog.some(isBareCodexShellBridgeTool)) return []; return NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertisedCoversNeighbor(wireNames, name)); } @@ -149,7 +153,7 @@ export function buildCursorToolGuidanceSystemNote( const wireNames = [...new Set( tools .filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools)) - .map(tool => cursorToolWireName(tool)), + .map(tool => cursorToolWireName(tool, tools)), )]; if (wireNames.length === 0) return undefined; @@ -170,7 +174,7 @@ export function buildCursorToolGuidanceSystemNote( ?.filter(tool => !tool.namespace && isCursorStructuredEditToolName(tool.name)) .map(tool => tool.name) ?? []; const discoveryTools = discoveryToolLabel(wireNames); - const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames); + const unavailableNeighborNames = unavailableNeighborAgentToolNames(wireNames, tools); // Host-shell-neutral: the Codex client executes bridge commands, and may differ from // the OpenCodex proxy OS (LAN/SSH remote-proxy). Always cover PowerShell 5.1 pitfalls. const hostShellNote = hasBareExec diff --git a/src/adapters/cursor/tool-naming.ts b/src/adapters/cursor/tool-naming.ts index a36fb3a4f2c..d4ac1135f13 100644 --- a/src/adapters/cursor/tool-naming.ts +++ b/src/adapters/cursor/tool-naming.ts @@ -178,16 +178,49 @@ const CURSOR_PROXY_OWNED_BARE_TOOL_NAMES = new Set([ CODEX_TOOL_SEARCH_TOOL, ]); +export const CLAUDE_CLIENT_BARE_TOOL_NAMES = new Set([ + "Bash", + "Read", + "Write", + "Edit", + "Grep", + "Glob", + "Task", + "GetDynamicTools", + "EnterMode", + "ExitMode", + "Todo", + "AddTodo", + "UpdateTodo", + "ViewTodo", + "WebSearch", + "FetchUrl", + "KillProcess", + "ListProcesses", + "ViewProcessOutput", +]); + +export function isClaudeClientBareToolName(name: string): boolean { + return CLAUDE_CLIENT_BARE_TOOL_NAMES.has(name) || /^[A-Z][a-zA-Z0-9_]*$/.test(name); +} + /** Avoid collisions with Cursor's private bare-tool namespace. */ -function isCursorBareClientToolWireAliased( +export function isCursorBareClientToolWireAliased( tool: Pick, + catalog?: readonly Pick[], ): boolean { - return !tool.namespace - && !CURSOR_PROXY_OWNED_BARE_TOOL_NAMES.has(tool.name); + if (tool.namespace || CURSOR_PROXY_OWNED_BARE_TOOL_NAMES.has(tool.name)) return false; + if (isClaudeClientBareToolName(tool.name)) { + return catalog ? catalogHasBareCodexShellBridge(catalog) : false; + } + return true; } -export function cursorToolWireName(tool: Pick): string { - if (isCursorBareClientToolWireAliased(tool)) { +export function cursorToolWireName( + tool: Pick, + catalog?: readonly Pick[], +): string { + if (isCursorBareClientToolWireAliased(tool, catalog)) { return `${CURSOR_CLIENT_TOOL_WIRE_PREFIX}${tool.name}`; } return namespacedToolName(tool.namespace, tool.name); diff --git a/tests/providers/cursor/cursor-tool-definitions.test.ts b/tests/providers/cursor/cursor-tool-definitions.test.ts index fb990bc716d..bba1621d991 100644 --- a/tests/providers/cursor/cursor-tool-definitions.test.ts +++ b/tests/providers/cursor/cursor-tool-definitions.test.ts @@ -824,6 +824,61 @@ describe("Cursor code mode tool guidance", () => { expect(note).not.toContain("V8 isolate"); expect(note).not.toContain("Host contract for the nested helpers"); }); + + test("preserves Claude Code client bare tool wire names when no Codex shell bridge is advertised", () => { + const claudeTools: OcxTool[] = [ + { name: "Bash", description: "Run a bash command", parameters: { type: "object", properties: { command: { type: "string" } } } }, + { name: "Read", description: "Read a file", parameters: {} }, + { name: "Edit", description: "Edit a file", parameters: {} }, + { name: "Write", description: "Write a file", parameters: {} }, + { name: "Grep", description: "Search file contents", parameters: {} }, + { name: "Glob", description: "Find files by glob", parameters: {} }, + { name: "Task", description: "Spawn an agent task", parameters: {} }, + { name: "GetDynamicTools", description: "Get dynamic tools", parameters: {} }, + ]; + + for (const tool of claudeTools) { + expect(cursorToolWireName(tool, claudeTools)).toBe(tool.name); + } + + const defs = buildCursorToolDefinitions(claudeTools); + expect(defs.map(tool => tool.toolName)).toEqual([ + "Bash", + "Read", + "Edit", + "Write", + "Grep", + "Glob", + "Task", + "GetDynamicTools", + ]); + + // When no shell bridge is advertised, guidance should not warn about neighboring-agent tool names or ocx_client_* + const note = buildCursorToolGuidanceSystemNote(claudeTools); + if (note) { + expect(note).not.toContain("neighboring-agent tool names"); + expect(note).not.toContain("ocx_client_"); + } + }); + + test("aliases client tools when Codex shell bridge is present in catalog", () => { + const toolsWithBridge: OcxTool[] = [ + { name: "exec_command", description: "Codex shell", parameters: {} }, + { name: "Glob", description: "Client tool", parameters: {} }, + { name: "Read", description: "Client tool", parameters: {} }, + ]; + + expect(cursorToolWireName({ name: "Glob" }, toolsWithBridge)).toBe("ocx_client_Glob"); + expect(cursorToolWireName({ name: "Read" }, toolsWithBridge)).toBe("ocx_client_Read"); + expect(cursorToolWireName({ name: "exec_command" }, toolsWithBridge)).toBe("exec_command"); + + const defs = buildCursorToolDefinitions(toolsWithBridge); + expect(defs.map(tool => tool.toolName)).toEqual([ + "exec_command", + "ocx_client_Glob", + "ocx_client_Read", + ]); + }); }); test("tool-definitions preserves leaf identities and naming stays the dependency root", async () => { From 281bafa529cbdb501518b4ba72317a9c62395f05 Mon Sep 17 00:00:00 2001 From: yeongjunyoo <47925973+yeongjunyoo@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:20:04 +0900 Subject: [PATCH 2/6] fix(cursor): address CodeRabbit review feedback on tool naming and replay - Pass request visible catalog through conversationTurns to toolCallStep so replayed calls use consistent wire names. - Remove overly broad regex from isClaudeClientBareToolName, strictly matching known Claude tools. - Restrict unavailable neighboring-agent warning suppression in tool guidance to catalogs containing Claude bare tools. - Add regression tests for catalog-aware replay, MCP-only neighboring-agent warnings, and response name mapping. --- src/adapters/cursor/protobuf-request.ts | 17 +++-- src/adapters/cursor/tool-guidance.ts | 8 ++- src/adapters/cursor/tool-naming.ts | 6 +- tests/providers/cursor/cursor-blob.test.ts | 63 +++++++++++++++++++ .../cursor/cursor-tool-definitions.test.ts | 31 +++++++-- 5 files changed, 110 insertions(+), 15 deletions(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 2cb3dca33e7..71eacb6e6ca 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -1299,12 +1299,13 @@ function toolCallStep( requestScope: CursorBlobRequestScopeToken, result?: OcxToolResultMessage, codeMode = false, + catalog?: readonly OcxTool[], ): Uint8Array { const args: Record = {}; for (const [key, value] of Object.entries(part.arguments ?? {})) args[key] = argBytes(value); // Replay the same provider-isolated identity advertised in this request. Returned calls are // restored to the client name, so transcript parts carry the client name again on the next turn. - const toolName = cursorToolWireName(part); + const toolName = cursorToolWireName(part, catalog); const decodedResult = result ? decodeResultParts(result) : undefined; const serialize = (maxImages: number): Uint8Array => toBinary(ConversationStepSchema, create(ConversationStepSchema, { message: { @@ -1357,8 +1358,12 @@ function toolResultPart(message: OcxToolResultMessage, codeMode: boolean, decode }); } -function assistantStep(part: OcxAssistantContentPart, requestScope: CursorBlobRequestScopeToken): Uint8Array | undefined { - if (part.type === "toolCall") return toolCallStep(part, requestScope); +function assistantStep( + part: OcxAssistantContentPart, + requestScope: CursorBlobRequestScopeToken, + catalog?: readonly OcxTool[], +): Uint8Array | undefined { + if (part.type === "toolCall") return toolCallStep(part, requestScope, undefined, false, catalog); if (part.type === "thinking") { return storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, { message: { @@ -1409,7 +1414,7 @@ function conversationTurns( const flush = () => { if (!current) return; for (const part of pendingToolCalls.values()) { - current.steps.push(toolCallStep(part, requestScope, missingToolResultFor(part), codeMode)); + current.steps.push(toolCallStep(part, requestScope, missingToolResultFor(part), codeMode, request.tools)); } turns.push(storeCursorBlob(toBinary(ConversationTurnStructureSchema, create(ConversationTurnStructureSchema, { turn: { @@ -1451,7 +1456,7 @@ function conversationTurns( pendingToolCalls.set(part.id, part); continue; } - const step = assistantStep(part, requestScope); + const step = assistantStep(part, requestScope, request.tools); if (step) current.steps.push(step); } continue; @@ -1478,7 +1483,7 @@ function conversationTurns( } const priorCall = pendingToolCalls.get(message.toolCallId); if (priorCall) { - current.steps.push(toolCallStep(priorCall, requestScope, message, codeMode)); + current.steps.push(toolCallStep(priorCall, requestScope, message, codeMode, request.tools)); pendingToolCalls.delete(message.toolCallId); } else { current.steps.push(storeCursorBlob(toBinary(ConversationStepSchema, create(ConversationStepSchema, { diff --git a/src/adapters/cursor/tool-guidance.ts b/src/adapters/cursor/tool-guidance.ts index 90ad58f46a0..f929d5c93c0 100644 --- a/src/adapters/cursor/tool-guidance.ts +++ b/src/adapters/cursor/tool-guidance.ts @@ -1,6 +1,6 @@ import type { OcxRequestOptions, OcxTool } from "../../types"; import { CODE_MODE_HOST_CONTRACT_SENTENCE, CODE_MODE_RESULT_ECHO_SENTENCE } from "../exec-tool-result-normalize"; -import { CODEX_SHELL_BRIDGE_TOOL_NAMES, CODEX_TOOL_SEARCH_TOOL, CODEX_UNIFIED_EXEC_TOOL, clientSemanticToolNameFromCursorWire, cursorRequestAdvertisesApplyPatch, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolAllowedByChoice, cursorToolWireName, isBareCodexShellBridgeTool, isCodexShellBridgeToolName, isCursorExecutionPathTool, isCursorStructuredEditToolName } from "./tool-naming"; +import { CODEX_SHELL_BRIDGE_TOOL_NAMES, CODEX_TOOL_SEARCH_TOOL, CODEX_UNIFIED_EXEC_TOOL, clientSemanticToolNameFromCursorWire, cursorRequestAdvertisesApplyPatch, cursorRequestHasExecutionPath, cursorRequestHasShellAlias, cursorRequestUsesCodeMode, cursorToolAllowedByChoice, cursorToolWireName, isBareCodexShellBridgeTool, isClaudeClientBareToolName, isCodexShellBridgeToolName, isCursorExecutionPathTool, isCursorStructuredEditToolName } from "./tool-naming"; export const CURSOR_SHELL_ALIAS_SYSTEM_NOTE = 'Shell commands use the Codex shell bridge tool shown in this turn\'s catalog (`shell_command` or `exec_command`) with JSON arguments like {"cmd":"..."}. The long `mcp_opencodex-responses_*` display name is the same tool. Prefer it over Cursor-native Shell.'; @@ -133,7 +133,11 @@ function unavailableNeighborAgentToolNames( wireNames: readonly string[], catalog?: readonly Pick[], ): string[] { - if (catalog && !catalog.some(isBareCodexShellBridgeTool)) return []; + if ( + catalog + && !catalog.some(isBareCodexShellBridgeTool) + && catalog.some(tool => !tool.namespace && isClaudeClientBareToolName(tool.name)) + ) return []; return NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertisedCoversNeighbor(wireNames, name)); } diff --git a/src/adapters/cursor/tool-naming.ts b/src/adapters/cursor/tool-naming.ts index d4ac1135f13..4f5f3f3d499 100644 --- a/src/adapters/cursor/tool-naming.ts +++ b/src/adapters/cursor/tool-naming.ts @@ -67,11 +67,11 @@ function cursorToolChoiceMatches( if (catalogHasBareCodexShellBridge(catalog)) { return isBareCodexShellBridgeTool(tool); } - return tool.name === choiceName || cursorToolWireName(tool) === choiceName; + return tool.name === choiceName || cursorToolWireName(tool, catalog) === choiceName; } if (tool.name === choiceName) return true; if (cursorToolChoiceAliases(tool).includes(choiceName)) return true; - return cursorToolWireName(tool) === choiceName + return cursorToolWireName(tool, catalog) === choiceName && !catalog.some(candidate => candidate.name === choiceName); } @@ -201,7 +201,7 @@ export const CLAUDE_CLIENT_BARE_TOOL_NAMES = new Set([ ]); export function isClaudeClientBareToolName(name: string): boolean { - return CLAUDE_CLIENT_BARE_TOOL_NAMES.has(name) || /^[A-Z][a-zA-Z0-9_]*$/.test(name); + return CLAUDE_CLIENT_BARE_TOOL_NAMES.has(name); } /** Avoid collisions with Cursor's private bare-tool namespace. */ diff --git a/tests/providers/cursor/cursor-blob.test.ts b/tests/providers/cursor/cursor-blob.test.ts index 1f3c57ed479..3412c5c1090 100644 --- a/tests/providers/cursor/cursor-blob.test.ts +++ b/tests/providers/cursor/cursor-blob.test.ts @@ -840,6 +840,69 @@ describe("Cursor blob handshake", () => { expect(value?.userMessage?.text).toBe(CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT); }); + test("replays tool calls with catalog-aware wire names", () => { + resetCursorCallIdProvenanceForTests(); + const local = "call_catalog_replay"; + // 1. When catalog has exec_command and Read, replayed call should use ocx_client_Read + const withBridgeBytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-bridge-replay", + system: ["You are helpful."], + tools: [{ name: "exec_command", parameters: {} }, { name: "Read", parameters: {} }], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: Read\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: local, name: "Read", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: local, toolName: "Read", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msgWithBridge = fromBinary(AgentClientMessageSchema, withBridgeBytes); + const runWithBridge = msgWithBridge.message.case === "runRequest" ? msgWithBridge.message.value : undefined; + const turnIdsWithBridge = runWithBridge?.conversationState?.turns ?? []; + const turnWithBridge = fromBinary(ConversationTurnStructureSchema, blobData(turnIdsWithBridge[0]!)); + const stepWithBridge = fromBinary(ConversationStepSchema, blobData(turnWithBridge.turn.value?.steps[0]!)); + if (stepWithBridge.message.case === "toolCall" && stepWithBridge.message.value.tool.case === "mcpToolCall") { + expect(stepWithBridge.message.value.tool.value.args?.toolName).toBe("ocx_client_Read"); + } else { + throw new Error("Expected mcpToolCall"); + } + + // 2. When catalog has only Claude client tools (no shell bridge), replayed call preserves bare Read + resetCursorCallIdProvenanceForTests(); + const noBridgeBytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-nobridge-replay", + system: ["You are helpful."], + tools: [{ name: "Read", parameters: {} }], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: Read\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: local, name: "Read", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: local, toolName: "Read", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msgNoBridge = fromBinary(AgentClientMessageSchema, noBridgeBytes); + const runNoBridge = msgNoBridge.message.case === "runRequest" ? msgNoBridge.message.value : undefined; + const turnIdsNoBridge = runNoBridge?.conversationState?.turns ?? []; + const turnNoBridge = fromBinary(ConversationTurnStructureSchema, blobData(turnIdsNoBridge[0]!)); + const stepNoBridge = fromBinary(ConversationStepSchema, blobData(turnNoBridge.turn.value?.steps[0]!)); + if (stepNoBridge.message.case === "toolCall" && stepNoBridge.message.value.tool.case === "mcpToolCall") { + expect(stepNoBridge.message.value.tool.value.args?.toolName).toBe("Read"); + } else { + throw new Error("Expected mcpToolCall"); + } + }); + test("native protobuf replay leaves an opaque escape lookalike byte-identical", () => { resetCursorCallIdProvenanceForTests(); const opaque = "ocxc1e_b2N4YzFf"; diff --git a/tests/providers/cursor/cursor-tool-definitions.test.ts b/tests/providers/cursor/cursor-tool-definitions.test.ts index bba1621d991..4a512c77fcc 100644 --- a/tests/providers/cursor/cursor-tool-definitions.test.ts +++ b/tests/providers/cursor/cursor-tool-definitions.test.ts @@ -19,6 +19,7 @@ import { cursorToolWireName, isGenericToolUseCountDemoPrompt, nonEmptyShellBridgeCommandFromArgs, + responsesToolNameFromCursorWire, } from "../../../src/adapters/cursor/tool-definitions"; import type { OcxTool } from "../../../src/types"; @@ -855,10 +856,9 @@ describe("Cursor code mode tool guidance", () => { // When no shell bridge is advertised, guidance should not warn about neighboring-agent tool names or ocx_client_* const note = buildCursorToolGuidanceSystemNote(claudeTools); - if (note) { - expect(note).not.toContain("neighboring-agent tool names"); - expect(note).not.toContain("ocx_client_"); - } + expect(note).toBeDefined(); + expect(note).not.toContain("neighboring-agent tool names"); + expect(note).not.toContain("ocx_client_"); }); test("aliases client tools when Codex shell bridge is present in catalog", () => { @@ -878,6 +878,29 @@ describe("Cursor code mode tool guidance", () => { "ocx_client_Glob", "ocx_client_Read", ]); + + const note = buildCursorToolGuidanceSystemNote(toolsWithBridge); + expect(note).toBeDefined(); + if (!note) throw new Error("Expected Cursor tool guidance note"); + expect(note).toContain("`ocx_client_Glob`"); + expect(note).toContain("`ocx_client_Read`"); + expect(note).toContain("neighboring-agent tool names"); + + // Both bare and prefixed response names map back to Read + const nameMap = new Map([ + [cursorToolWireName({ name: "Read" }, toolsWithBridge), "Read"], + ]); + expect(responsesToolNameFromCursorWire("ocx_client_Read", nameMap)).toBe("Read"); + expect(responsesToolNameFromCursorWire("Read", nameMap)).toBe("Read"); + }); + + test("preserves neighboring-agent tool warning for MCP-only catalogs without Claude bare tools", () => { + const mcpCatalog: OcxTool[] = [ + { name: "read_file", namespace: "mcp__fs", description: "Read file", parameters: {} }, + ]; + const note = buildCursorToolGuidanceSystemNote(mcpCatalog); + expect(note).toBeDefined(); + expect(note).toContain("neighboring-agent tool names"); }); }); From d4bda6eff59ec9783295e7328ce2aadbaa11a548 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 21:27:32 +0900 Subject: [PATCH 3/6] fix(cursor): import OcxTool for catalog-aware replay signatures --- src/adapters/cursor/protobuf-request.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 71eacb6e6ca..732a66e27b2 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -1,7 +1,7 @@ import { create, fromBinary, toBinary, toJson } from "@bufbuild/protobuf"; import { fromJson, type JsonValue } from "@bufbuild/protobuf"; import { ValueSchema } from "@bufbuild/protobuf/wkt"; -import type { OcxAssistantContentPart, OcxMessage, OcxToolResultMessage } from "../../types"; +import type { OcxAssistantContentPart, OcxMessage, OcxTool, OcxToolResultMessage } from "../../types"; import { namespacedToolName } from "../../types"; import type { CursorRunRequest } from "./types"; import { cursorStructuredOutputInstructions } from "./structured-output"; From cc1c9628911797ab623ad40651d387d03f4a9899 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 21:29:21 +0900 Subject: [PATCH 4/6] test(cursor): move Claude client replay coverage under size cap --- scripts/test-layout/layout.json | 2 +- tests/fixtures/test-layout-expected.json | 2 +- .../cursor-blob-claude-client-tools.test.ts | 104 ++++++++++++++++++ tests/providers/cursor/cursor-blob.test.ts | 63 ----------- 4 files changed, 106 insertions(+), 65 deletions(-) create mode 100644 tests/providers/cursor/cursor-blob-claude-client-tools.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index f331f7c3c5c..2122334fa29 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -761,7 +761,7 @@ "cursor-adapter.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", "cursor-blob-integrity.test.ts": "providers/cursor", - "cursor-blob.test.ts": "providers/cursor", + "cursor-blob-claude-client-tools.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", "cursor-call-id.test.ts": "providers/cursor", "cursor-cancel-provenance.test.ts": "providers/cursor", "cursor-catalog.test.ts": "providers/cursor", diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 71e304011d0..1cc7d6304e4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -469,7 +469,7 @@ "cost-scoring.test.ts": "usage", "crash-guard.test.ts": "service", "credential-redirect-guard.test.ts": "lib", "crusoe-provider.test.ts": "providers", "cursor-adapter.test.ts": "providers/cursor", "cursor-arg-normalize.test.ts": "providers/cursor", - "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", + "cursor-blob-integrity.test.ts": "providers/cursor", "cursor-blob-claude-client-tools.test.ts": "providers/cursor", "cursor-blob.test.ts": "providers/cursor", "cursor-call-id.test.ts": "providers/cursor", "cursor-cancel-provenance.test.ts": "providers/cursor", "cursor-catalog.test.ts": "providers/cursor", "cursor-claude-id.test.ts": "providers/cursor", "cursor-continuation-invariants.test.ts": "providers/cursor", diff --git a/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts b/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts new file mode 100644 index 00000000000..57e6218d618 --- /dev/null +++ b/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { create, fromBinary } from "@bufbuild/protobuf"; +import { + handleCursorNativeKv, + resetCursorBlobStateForTests, + setCursorBlobLimitsForTests, +} from "../../../src/adapters/cursor/native-exec"; +import { resetAppOwnedMemoryForTests } from "../../../src/lib/app-owned-memory"; +import { encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { resetCursorCallIdProvenanceForTests } from "../../../src/adapters/cursor/call-id"; +import { + AgentClientMessageSchema, + ConversationStepSchema, + ConversationTurnStructureSchema, + GetBlobArgsSchema, + KvServerMessageSchema, +} from "../../../src/adapters/cursor/gen/agent_pb"; + +beforeEach(() => { + resetCursorBlobStateForTests(); + resetAppOwnedMemoryForTests(); +}); +afterEach(() => { + setCursorBlobLimitsForTests(); + resetAppOwnedMemoryForTests(); +}); + +function blobData(blobId: Uint8Array): Uint8Array { + const reply = fromBinary(AgentClientMessageSchema, handleCursorNativeKv(create(KvServerMessageSchema, { + id: 1, + message: { case: "getBlobArgs", value: create(GetBlobArgsSchema, { blobId }) }, + }))); + expect(reply.message.case).toBe("kvClientMessage"); + const kv = reply.message.value; + expect(kv.message.case).toBe("getBlobResult"); + return kv.message.value.blobData; +} + + +describe("Cursor blob handshake", () => { + test("replays tool calls with catalog-aware wire names", () => { + resetCursorCallIdProvenanceForTests(); + const local = "call_catalog_replay"; + // 1. When catalog has exec_command and Read, replayed call should use ocx_client_Read + const withBridgeBytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-bridge-replay", + system: ["You are helpful."], + tools: [{ name: "exec_command", parameters: {} }, { name: "Read", parameters: {} }], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: Read\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: local, name: "Read", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: local, toolName: "Read", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msgWithBridge = fromBinary(AgentClientMessageSchema, withBridgeBytes); + const runWithBridge = msgWithBridge.message.case === "runRequest" ? msgWithBridge.message.value : undefined; + const turnIdsWithBridge = runWithBridge?.conversationState?.turns ?? []; + const turnWithBridge = fromBinary(ConversationTurnStructureSchema, blobData(turnIdsWithBridge[0]!)); + const stepWithBridge = fromBinary(ConversationStepSchema, blobData(turnWithBridge.turn.value?.steps[0]!)); + if (stepWithBridge.message.case === "toolCall" && stepWithBridge.message.value.tool.case === "mcpToolCall") { + expect(stepWithBridge.message.value.tool.value.args?.toolName).toBe("ocx_client_Read"); + } else { + throw new Error("Expected mcpToolCall"); + } + + // 2. When catalog has only Claude client tools (no shell bridge), replayed call preserves bare Read + resetCursorCallIdProvenanceForTests(); + const noBridgeBytes = encodeCursorRunRequest({ + modelId: "composer-2.5", + conversationId: "c-nobridge-replay", + system: ["You are helpful."], + tools: [{ name: "Read", parameters: {} }], + messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: Read\nis_error: false\noutput:\ncontents" }], + rawMessages: [ + { role: "user", content: "read a file", timestamp: 1 }, + { + role: "assistant", + model: "cursor/auto", + timestamp: 2, + content: [{ type: "toolCall", id: local, name: "Read", arguments: { path: "a.txt" } }], + }, + { role: "toolResult", toolCallId: local, toolName: "Read", content: "contents", isError: false, timestamp: 3 }, + ], + }); + const msgNoBridge = fromBinary(AgentClientMessageSchema, noBridgeBytes); + const runNoBridge = msgNoBridge.message.case === "runRequest" ? msgNoBridge.message.value : undefined; + const turnIdsNoBridge = runNoBridge?.conversationState?.turns ?? []; + const turnNoBridge = fromBinary(ConversationTurnStructureSchema, blobData(turnIdsNoBridge[0]!)); + const stepNoBridge = fromBinary(ConversationStepSchema, blobData(turnNoBridge.turn.value?.steps[0]!)); + if (stepNoBridge.message.case === "toolCall" && stepNoBridge.message.value.tool.case === "mcpToolCall") { + expect(stepNoBridge.message.value.tool.value.args?.toolName).toBe("Read"); + } else { + throw new Error("Expected mcpToolCall"); + } + }); + +}); diff --git a/tests/providers/cursor/cursor-blob.test.ts b/tests/providers/cursor/cursor-blob.test.ts index 3412c5c1090..1f3c57ed479 100644 --- a/tests/providers/cursor/cursor-blob.test.ts +++ b/tests/providers/cursor/cursor-blob.test.ts @@ -840,69 +840,6 @@ describe("Cursor blob handshake", () => { expect(value?.userMessage?.text).toBe(CURSOR_EXTERNAL_TOOL_CONTINUATION_TEXT); }); - test("replays tool calls with catalog-aware wire names", () => { - resetCursorCallIdProvenanceForTests(); - const local = "call_catalog_replay"; - // 1. When catalog has exec_command and Read, replayed call should use ocx_client_Read - const withBridgeBytes = encodeCursorRunRequest({ - modelId: "composer-2.5", - conversationId: "c-bridge-replay", - system: ["You are helpful."], - tools: [{ name: "exec_command", parameters: {} }, { name: "Read", parameters: {} }], - messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: Read\nis_error: false\noutput:\ncontents" }], - rawMessages: [ - { role: "user", content: "read a file", timestamp: 1 }, - { - role: "assistant", - model: "cursor/auto", - timestamp: 2, - content: [{ type: "toolCall", id: local, name: "Read", arguments: { path: "a.txt" } }], - }, - { role: "toolResult", toolCallId: local, toolName: "Read", content: "contents", isError: false, timestamp: 3 }, - ], - }); - const msgWithBridge = fromBinary(AgentClientMessageSchema, withBridgeBytes); - const runWithBridge = msgWithBridge.message.case === "runRequest" ? msgWithBridge.message.value : undefined; - const turnIdsWithBridge = runWithBridge?.conversationState?.turns ?? []; - const turnWithBridge = fromBinary(ConversationTurnStructureSchema, blobData(turnIdsWithBridge[0]!)); - const stepWithBridge = fromBinary(ConversationStepSchema, blobData(turnWithBridge.turn.value?.steps[0]!)); - if (stepWithBridge.message.case === "toolCall" && stepWithBridge.message.value.tool.case === "mcpToolCall") { - expect(stepWithBridge.message.value.tool.value.args?.toolName).toBe("ocx_client_Read"); - } else { - throw new Error("Expected mcpToolCall"); - } - - // 2. When catalog has only Claude client tools (no shell bridge), replayed call preserves bare Read - resetCursorCallIdProvenanceForTests(); - const noBridgeBytes = encodeCursorRunRequest({ - modelId: "composer-2.5", - conversationId: "c-nobridge-replay", - system: ["You are helpful."], - tools: [{ name: "Read", parameters: {} }], - messages: [{ role: "tool", content: "[tool_result]\ncall_id: call_1\nname: Read\nis_error: false\noutput:\ncontents" }], - rawMessages: [ - { role: "user", content: "read a file", timestamp: 1 }, - { - role: "assistant", - model: "cursor/auto", - timestamp: 2, - content: [{ type: "toolCall", id: local, name: "Read", arguments: { path: "a.txt" } }], - }, - { role: "toolResult", toolCallId: local, toolName: "Read", content: "contents", isError: false, timestamp: 3 }, - ], - }); - const msgNoBridge = fromBinary(AgentClientMessageSchema, noBridgeBytes); - const runNoBridge = msgNoBridge.message.case === "runRequest" ? msgNoBridge.message.value : undefined; - const turnIdsNoBridge = runNoBridge?.conversationState?.turns ?? []; - const turnNoBridge = fromBinary(ConversationTurnStructureSchema, blobData(turnIdsNoBridge[0]!)); - const stepNoBridge = fromBinary(ConversationStepSchema, blobData(turnNoBridge.turn.value?.steps[0]!)); - if (stepNoBridge.message.case === "toolCall" && stepNoBridge.message.value.tool.case === "mcpToolCall") { - expect(stepNoBridge.message.value.tool.value.args?.toolName).toBe("Read"); - } else { - throw new Error("Expected mcpToolCall"); - } - }); - test("native protobuf replay leaves an opaque escape lookalike byte-identical", () => { resetCursorCallIdProvenanceForTests(); const opaque = "ocxc1e_b2N4YzFf"; From 6d83a52b5060d03c53c8f922a53accaa4d07dd8b Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 21:32:16 +0900 Subject: [PATCH 5/6] fix(cursor): budget Claude client aliases with catalog context --- .../src/content/docs/guides/claude-code.md | 13 +++++ src/adapters/cursor/request-builder.ts | 10 ++-- src/adapters/cursor/tool-definitions.ts | 13 +++-- structure/providers/cursor.md | 20 +++++++ .../cursor-blob-claude-client-tools.test.ts | 53 +++++++++++++++++++ 5 files changed, 99 insertions(+), 10 deletions(-) diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 5b688af5fbf..4f7400c758a 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -794,6 +794,19 @@ depend on them for long unattended runs. +## Client tools on Cursor routes + +When Claude Code uses a Cursor-backed model, OpenCodex preserves recognized client tool names +such as `Bash`, `Read`, `Write`, `Edit`, `Grep`, `Glob`, and `Task` in the Cursor catalog, tool +guidance, and replayed calls. This keeps the names consistent with Claude Code's instructions. +Denied Cursor-native execution frames direct the model to the client tools available that turn. + +A mixed catalog containing a bare Codex `exec_command` or `shell_command` bridge uses wire +aliases such as `ocx_client_Read` to avoid Cursor tool collisions; calls returned to Claude Code +still use its original tool names. Namespaced MCP tools keep their namespaced identities. +OpenCodex includes these aliases in Cursor's transport byte budget, so a tool whose definition +would exceed the limit is omitted for that turn. + ## Reasoning effort Claude Code's `/effort` setting is preserved across the adapter: diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 3b28e7cb50f..61d66d2ebcf 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -102,8 +102,8 @@ export function applyCursorToolBudget( const tryKeep = (tool: OcxTool): boolean => { if (keptSet.has(tool) || kept.length >= CURSOR_TOOL_COUNT_LIMIT) return keptSet.has(tool); // Repeated protobuf message fields serialize as concatenated tag/length/value entries, - // so each one-entry wrapper size is the exact additive contribution to McpTools. - const candidateBytes = cursorMcpToolEncodedSize(tool, toolChoice); + // so each wrapper uses the same catalog-dependent names and choice as registration. + const candidateBytes = cursorMcpToolEncodedSize(tool, toolChoice, eligible); if (keptBytes + candidateBytes > CURSOR_TOOL_BYTES_LIMIT) return false; kept.push(tool); keptSet.add(tool); @@ -129,7 +129,7 @@ export function applyCursorToolBudget( if (!occupant || isCursorExecutionPathTool(occupant)) continue; kept.splice(i, 1); keptSet.delete(occupant); - keptBytes -= cursorMcpToolEncodedSize(occupant, toolChoice); + keptBytes -= cursorMcpToolEncodedSize(occupant, toolChoice, eligible); if (kept.length < CURSOR_TOOL_COUNT_LIMIT && keptBytes + needBytes <= CURSOR_TOOL_BYTES_LIMIT) { return; } @@ -141,7 +141,7 @@ export function applyCursorToolBudget( // earlier same-priority pins; evict wait/patch/filler rather than ship wait-only. for (const tool of eligible) { if (!isCursorExecutionPathTool(tool) || keptSet.has(tool)) continue; - const need = cursorMcpToolEncodedSize(tool, toolChoice); + const need = cursorMcpToolEncodedSize(tool, toolChoice, eligible); if (need > CURSOR_TOOL_BYTES_LIMIT) continue; evictNonExecutionPath(need); tryKeep(tool); @@ -157,7 +157,7 @@ export function applyCursorToolBudget( keptSet.delete(tool); const index = kept.indexOf(tool); if (index >= 0) kept.splice(index, 1); - keptBytes -= cursorMcpToolEncodedSize(tool, toolChoice); + keptBytes -= cursorMcpToolEncodedSize(tool, toolChoice, eligible); } } diff --git a/src/adapters/cursor/tool-definitions.ts b/src/adapters/cursor/tool-definitions.ts index 7fb4300a6c1..0942d9a08a8 100644 --- a/src/adapters/cursor/tool-definitions.ts +++ b/src/adapters/cursor/tool-definitions.ts @@ -80,10 +80,11 @@ export function encodeCursorInputSchema(schema: unknown): Uint8Array { export function buildCursorToolDefinitions( tools: readonly OcxTool[] | undefined, toolChoice?: OcxRequestOptions["toolChoice"], + catalog: readonly OcxTool[] = tools ?? [], ): McpToolDefinition[] { if (!tools?.length) return []; - return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, tools)).map(tool => { - const wireName = cursorToolWireName(tool, tools); + return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog)).map(tool => { + const wireName = cursorToolWireName(tool, catalog); return create(McpToolDefinitionSchema, { name: wireName, toolName: wireName, @@ -98,15 +99,17 @@ export function buildCursorToolDefinitions( export function cursorMcpToolsEncodedSize( tools: readonly OcxTool[] | undefined, toolChoice?: OcxRequestOptions["toolChoice"], + catalog: readonly OcxTool[] = tools ?? [], ): number { - const definitions = buildCursorToolDefinitions(tools, toolChoice); + const definitions = buildCursorToolDefinitions(tools, toolChoice, catalog); return toBinary(McpToolsSchema, create(McpToolsSchema, { mcpTools: definitions })).byteLength; } -/** Exact additive contribution of one repeated McpToolDefinition entry. */ +/** Exact additive contribution of one entry, using the registration catalog for wire naming and choice. */ export function cursorMcpToolEncodedSize( tool: OcxTool, toolChoice?: OcxRequestOptions["toolChoice"], + catalog: readonly OcxTool[] = [tool], ): number { - return cursorMcpToolsEncodedSize([tool], toolChoice); + return cursorMcpToolsEncodedSize([tool], toolChoice, catalog); } diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 319adb626f0..67ed534d2d5 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -52,6 +52,26 @@ In external Cursor turns using code mode or shell aliases, the bounded leading-c > Decision record: [ADR-0048](../decisions/ADR-0048-cursor-native-exec.md) +## Client tool wire names and transport budget + +`src/adapters/cursor/tool-naming.ts` derives client wire names from the turn's tool catalog. +Recognized bare Claude client tools such as `Bash`, `Read`, `Write`, `Edit`, `Grep`, `Glob`, and +`Task` keep their names when the catalog has no bare Codex `exec_command` or `shell_command` +bridge. When either bare bridge is present, those tools receive the `ocx_client_` prefix to avoid +Cursor-private tool collisions. Other non-proxy-owned bare tools always receive that prefix; +proxy-owned execution/edit tools and namespaced MCP tools retain their existing identities. +Registration, guidance, denied-native-exec redirects, live call mapping, and protobuf history +replay use this catalog-dependent rule. Return mapping restores the client's semantic tool name. + +`src/adapters/cursor/tool-definitions.ts` measures actual repeated protobuf entries using the +same catalog context for tool-choice matching and wire naming as registration. +`src/adapters/cursor/request-builder.ts` passes the eligible catalog to every admission and +eviction size calculation, so both serialized name fields include alias bytes. The transport +budget remains 330 tools and 120,000 bytes. If a bridge is omitted, surviving Claude tools can +use shorter bare names; the eligible-catalog measurement remains conservative in that case. +`tests/providers/cursor/cursor-blob-claude-client-tools.test.ts` covers replay naming and mixed +catalogs exactly at, and immediately above, the byte limit. + ## Structured final output Cursor's Connect wire has no native Responses `text.format` schema field. The adapter preserves diff --git a/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts b/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts index 57e6218d618..f4a83c9de73 100644 --- a/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts +++ b/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts @@ -7,6 +7,9 @@ import { } from "../../../src/adapters/cursor/native-exec"; import { resetAppOwnedMemoryForTests } from "../../../src/lib/app-owned-memory"; import { encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; +import { applyCursorToolBudget, CURSOR_TOOL_BYTES_LIMIT } from "../../../src/adapters/cursor/request-builder"; +import { cursorMcpToolEncodedSize, cursorMcpToolsEncodedSize } from "../../../src/adapters/cursor/tool-definitions"; +import type { OcxTool } from "../../../src/types"; import { resetCursorCallIdProvenanceForTests } from "../../../src/adapters/cursor/call-id"; import { AgentClientMessageSchema, @@ -102,3 +105,53 @@ describe("Cursor blob handshake", () => { }); }); + +describe("Cursor Claude client tool catalog budget", () => { + test("individual sizes use catalog wire names and aliased tool choices", () => { + const bridge: OcxTool = { name: "exec_command", parameters: {} }; + const read: OcxTool = { name: "Read", parameters: {} }; + const tools = [bridge, read]; + expect(tools.reduce((sum, tool) => sum + cursorMcpToolEncodedSize(tool, "auto", tools), 0)) + .toBe(cursorMcpToolsEncodedSize(tools, "auto")); + const choice = { name: "ocx_client_Read" }; + expect(cursorMcpToolEncodedSize(read, choice, tools)).toBeGreaterThan(0); + expect(cursorMcpToolEncodedSize(read, choice, tools)).toBe(cursorMcpToolsEncodedSize(tools, choice)); + expect(cursorMcpToolEncodedSize(bridge, choice, tools)).toBe(0); + }); + + test("omits a Claude tool when its mixed-catalog wire alias exceeds the byte limit", () => { + const bridge: OcxTool = { name: "exec_command", parameters: {} }; + const read: OcxTool = { name: "Read", description: "x".repeat(118_450), parameters: {} }; + const tools = [bridge, read]; + expect(cursorMcpToolsEncodedSize([bridge]) + cursorMcpToolsEncodedSize([read])) + .toBe(CURSOR_TOOL_BYTES_LIMIT); + expect(cursorMcpToolsEncodedSize(tools)).toBe(CURSOR_TOOL_BYTES_LIMIT + 22); + + const budget = applyCursorToolBudget(tools, "auto"); + expect(cursorMcpToolsEncodedSize(budget.tools)).toBeLessThanOrEqual(CURSOR_TOOL_BYTES_LIMIT); + expect(budget.tools).toEqual([bridge]); + expect(budget.omitted).toEqual([read]); + expect(applyCursorToolBudget([read], "auto").tools).toEqual([read]); + }); + + test.each(["exec_command", "shell_command"])( + "retains a mixed catalog exactly at the byte limit with %s", + (name) => { + const bridge: OcxTool = { name, parameters: {} }; + const read: OcxTool = { name: "Read", description: "x".repeat(118_450), parameters: {} }; + const overhead = cursorMcpToolsEncodedSize([bridge, read]) - CURSOR_TOOL_BYTES_LIMIT; + read.description = read.description!.slice(overhead); + expect(cursorMcpToolsEncodedSize([bridge, read])).toBe(CURSOR_TOOL_BYTES_LIMIT); + const budget = applyCursorToolBudget([bridge, read], "auto"); + expect(budget.tools).toEqual([bridge, read]); + expect(budget.omitted).toEqual([]); + expect(cursorMcpToolsEncodedSize(budget.tools)).toBe(CURSOR_TOOL_BYTES_LIMIT); + + const tooLarge = { ...read, description: read.description + "x" }; + const overflow = applyCursorToolBudget([bridge, tooLarge], "auto"); + expect(overflow.tools).toEqual([bridge]); + expect(overflow.omitted).toEqual([tooLarge]); + expect(cursorMcpToolsEncodedSize(overflow.tools)).toBeLessThanOrEqual(CURSOR_TOOL_BYTES_LIMIT); + }, + ); +}); From 91d692509782eaa16a109fdaebbcf6b145526c88 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 21:39:24 +0900 Subject: [PATCH 6/6] fix(cursor): resolve client tool choices before catalog filtering --- src/adapters/cursor/request-builder.ts | 19 +++++- structure/providers/cursor.md | 4 ++ .../cursor-blob-claude-client-tools.test.ts | 61 ++++++++++++++++++- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 61d66d2ebcf..2b693f53f72 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -509,8 +509,21 @@ export function createCursorRequest( ): CursorRunRequest { const messages = cursorRequestMessagesFromRaw(parsed.context.messages); const activeText = [...messages].reverse().find(message => message.role === "user" || message.role === "developer")?.content ?? ""; - const visibleTools = cursorToolsForActivePrompt(parsed.context.tools, activeText, parsed.options.toolChoice); - const budget = applyCursorToolBudget(visibleTools, parsed.options.toolChoice); + const catalog = parsed.context.tools ?? []; + const originalChoice = parsed.options.toolChoice; + // Resolve accepted bare wire aliases before filtering can remove their shell-bridge context. + // Keep the original selection so a semantic name cannot widen to a namespaced sibling. + const selectedTools = catalog.filter(tool => cursorToolAllowedByChoice(tool, originalChoice, catalog)); + const semanticChoiceName = (name: string): string => selectedTools.find(tool => + !tool.namespace && cursorToolWireName(tool, catalog) === name + && cursorToolAllowedByChoice(tool, { name }, catalog))?.name ?? name; + const toolChoice = originalChoice && typeof originalChoice === "object" + ? "name" in originalChoice + ? { ...originalChoice, name: semanticChoiceName(originalChoice.name) } + : { ...originalChoice, allowedTools: originalChoice.allowedTools.map(semanticChoiceName) } + : originalChoice; + const visibleTools = cursorToolsForActivePrompt(selectedTools, activeText, toolChoice); + const budget = applyCursorToolBudget(visibleTools, toolChoice); const limitNote = catalogLimitNote(budget.tools, budget.omitted); const model = normalizeCursorModelId( parsed.modelId, @@ -536,7 +549,7 @@ export function createCursorRequest( ...(budget.tools.length === 0 && !cursorClientThreadOwner(parsed) ? { suppressDefaultCursorToolCatalog: true } : {}), - ...(parsed.options.toolChoice ? { toolChoice: parsed.options.toolChoice } : {}), + ...(toolChoice ? { toolChoice } : {}), ...(parsed.options.parallelToolCalls !== undefined ? { parallelToolCalls: parsed.options.parallelToolCalls } : {}), }; const resolved = resolveCursorCheckpoint(parsed, request, options); diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 67ed534d2d5..766f3231129 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -62,6 +62,10 @@ Cursor-private tool collisions. Other non-proxy-owned bare tools always receive proxy-owned execution/edit tools and namespaced MCP tools retain their existing identities. Registration, guidance, denied-native-exec redirects, live call mapping, and protobuf history replay use this catalog-dependent rule. Return mapping restores the client's semantic tool name. +Request construction resolves accepted bare wire aliases in forced and allowed-tool choices to +semantic names against the original catalog, retaining only the originally selected identities +before prompt and budget filtering so a removed bridge cannot invalidate registration or widen +the choice to a namespaced sibling. `src/adapters/cursor/tool-definitions.ts` measures actual repeated protobuf entries using the same catalog context for tool-choice matching and wire naming as registration. diff --git a/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts b/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts index f4a83c9de73..875440421c3 100644 --- a/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts +++ b/tests/providers/cursor/cursor-blob-claude-client-tools.test.ts @@ -7,8 +7,9 @@ import { } from "../../../src/adapters/cursor/native-exec"; import { resetAppOwnedMemoryForTests } from "../../../src/lib/app-owned-memory"; import { encodeCursorRunRequest } from "../../../src/adapters/cursor/protobuf-request"; -import { applyCursorToolBudget, CURSOR_TOOL_BYTES_LIMIT } from "../../../src/adapters/cursor/request-builder"; -import { cursorMcpToolEncodedSize, cursorMcpToolsEncodedSize } from "../../../src/adapters/cursor/tool-definitions"; +import { applyCursorToolBudget, createCursorRequest, CURSOR_TOOL_BYTES_LIMIT } from "../../../src/adapters/cursor/request-builder"; +import { buildCursorToolDefinitions, cursorMcpToolEncodedSize, cursorMcpToolsEncodedSize } from "../../../src/adapters/cursor/tool-definitions"; +import { parseRequest } from "../../../src/responses/parser"; import type { OcxTool } from "../../../src/types"; import { resetCursorCallIdProvenanceForTests } from "../../../src/adapters/cursor/call-id"; import { @@ -107,6 +108,62 @@ describe("Cursor blob handshake", () => { }); describe("Cursor Claude client tool catalog budget", () => { + test.each([ + { bridge: true, name: "Read", expected: ["Read"] }, + { bridge: true, name: "ocx_client_Read", expected: ["Read"] }, + { bridge: false, name: "Read", expected: ["Read"] }, + { bridge: false, name: "ocx_client_Read", expected: [] }, + ])("registers forced $name with bridge=$bridge after choice filtering", ({ bridge, name, expected }) => { + const parsed = parseRequest({ + model: "cursor/auto", input: "read a file", + tools: [...(bridge ? ["exec_command"] : []), "Read", "Write"].map(name => ({ type: "function", name, parameters: {} })), + tool_choice: { type: "function", name }, + }); + const request = createCursorRequest(parsed); + expect(buildCursorToolDefinitions(request.tools, request.toolChoice).map(tool => tool.toolName)).toEqual(expected); + }); + + test.each([ + { allowed: ["ocx_client_Read"], expected: ["Read"] }, + { allowed: ["exec_command", "ocx_client_Read"], expected: ["exec_command", "ocx_client_Read"] }, + ])("registers allowed aliases $allowed after choice filtering", ({ allowed, expected }) => { + const parsed = parseRequest({ + model: "cursor/auto", input: "read a file", + tools: ["exec_command", "Read", "Write"].map(name => ({ type: "function", name, parameters: {} })), + tool_choice: { type: "allowed_tools", mode: "required", tools: allowed.map(name => ({ type: "function", name })) }, + }); + const request = createCursorRequest(parsed); + expect(buildCursorToolDefinitions(request.tools, request.toolChoice).map(tool => tool.toolName)).toEqual(expected); + }); + + test.each(["function", "allowed_tools"])("keeps an aliased %s choice scoped to its original bare identity", type => { + const parsed = parseRequest({ + model: "cursor/auto", input: "read a file", + tools: [ + { type: "function", name: "exec_command", parameters: {} }, + { type: "function", name: "Read", parameters: {} }, + { type: "namespace", name: "mcp__remote", tools: [{ type: "function", name: "Read", parameters: {} }] }, + ], + tool_choice: type === "function" + ? { type, name: "ocx_client_Read" } + : { type, mode: "required", tools: [{ type: "function", name: "ocx_client_Read" }] }, + }); + const request = createCursorRequest(parsed); + expect(request.tools?.map(tool => [tool.namespace, tool.name])).toEqual([[undefined, "Read"]]); + expect(buildCursorToolDefinitions(request.tools, request.toolChoice).map(tool => tool.toolName)).toEqual(["Read"]); + }); + + test("preserves both allowed semantic names when a literal name shadows a generated alias", () => { + const parsed = parseRequest({ + model: "cursor/auto", input: "read a file", + tools: ["exec_command", "Read", "ocx_client_Read"].map(name => ({ type: "function", name, parameters: {} })), + tool_choice: { type: "allowed_tools", mode: "required", tools: ["Read", "ocx_client_Read"].map(name => ({ type: "function", name })) }, + }); + const request = createCursorRequest(parsed); + expect(buildCursorToolDefinitions(request.tools, request.toolChoice).map(tool => tool.toolName)) + .toEqual(["Read", "ocx_client_ocx_client_Read"]); + }); + test("individual sizes use catalog wire names and aliased tool choices", () => { const bridge: OcxTool = { name: "exec_command", parameters: {} }; const read: OcxTool = { name: "Read", parameters: {} };