Skip to content
13 changes: 13 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,19 @@ depend on them for long unattended runs.

<!-- TODO(WP5 GUI): Add the sidecar settings-screen walkthrough after the GUI controls ship. -->

## 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:
Expand Down
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [])
Expand All @@ -723,7 +723,7 @@ class LiveCursorTransport implements CursorTransport {
const toolSchemas = new Map<string, unknown>();
const cursorToolNameMap = new Map<string, string>();
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).
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/cursor/native-exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 12 additions & 7 deletions src/adapters/cursor/protobuf-request.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -1299,12 +1299,13 @@ function toolCallStep(
requestScope: CursorBlobRequestScopeToken,
result?: OcxToolResultMessage,
codeMode = false,
catalog?: readonly OcxTool[],
): Uint8Array {
const args: Record<string, Uint8Array> = {};
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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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;
Expand All @@ -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, {
Expand Down
33 changes: 23 additions & 10 deletions src/adapters/cursor/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
}
Expand All @@ -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);
Expand All @@ -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);
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
15 changes: 9 additions & 6 deletions src/adapters/cursor/tool-definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
return tools.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog)).map(tool => {
const wireName = cursorToolWireName(tool, catalog);
return create(McpToolDefinitionSchema, {
name: wireName,
toolName: wireName,
Expand All @@ -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);
}
16 changes: 12 additions & 4 deletions src/adapters/cursor/tool-guidance.ts
Original file line number Diff line number Diff line change
@@ -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, 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.';
Expand Down Expand Up @@ -129,7 +129,15 @@ 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<OcxTool, "namespace" | "name">[],
): string[] {
if (
catalog
&& !catalog.some(isBareCodexShellBridgeTool)
&& catalog.some(tool => !tool.namespace && isClaudeClientBareToolName(tool.name))
) return [];
return NEIGHBOR_AGENT_TOOL_NAMES.filter(name => !advertisedCoversNeighbor(wireNames, name));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand All @@ -149,7 +157,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;

Expand All @@ -170,7 +178,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
Expand Down
Loading
Loading