Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 23 additions & 7 deletions src/adapters/cursor/arg-normalize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,21 @@ function schemaPropertyNames(schema: unknown): Set<string> | undefined {

/**
* Normalize argument keys against the tool's declared schema. Keys not in the schema that have a
* known alias pointing to a schema-declared key are renamed. Keys already in the schema or with no
* matching alias are left untouched.
* known alias pointing to a schema-declared key are renamed. Explicitly supplied canonical keys
* always win over aliases, regardless of object property insertion order — conflicting aliases
* are discarded rather than left beside the canonical key.
*
* Returns the original object reference if no changes were needed (cheap identity check for callers).
*/
export function normalizeArgKeys(args: Record<string, unknown>, toolSchema: unknown): Record<string, unknown> {
const declared = schemaPropertyNames(toolSchema);
if (!declared || declared.size === 0) return args;

const suppliedCanonical = new Set<string>();
for (const key of Object.keys(args)) {
if (declared.has(key)) suppliedCanonical.add(key);
}

let changed = false;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(args)) {
Expand All @@ -77,12 +83,22 @@ export function normalizeArgKeys(args: Record<string, unknown>, toolSchema: unkn
continue;
}
const canonical = KEY_ALIASES.get(key.toLowerCase());
if (canonical && declared.has(canonical) && !(canonical in result)) {
result[canonical] = value;
changed = true;
} else {
result[key] = value;
if (canonical && declared.has(canonical)) {
// Canonical was explicitly supplied (any order) — drop the alias entirely.
if (suppliedCanonical.has(canonical)) {
changed = true;
continue;
}
// First alias fills the canonical slot; later aliases for the same key are discarded.
if (!(canonical in result)) {
result[canonical] = value;
changed = true;
} else {
changed = true;
}
continue;
}
result[key] = value;
}
return changed ? result : args;
}
7 changes: 5 additions & 2 deletions src/adapters/cursor/live-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
buildCursorToolDefinitions,
cursorRequestAdvertisesApplyPatch,
cursorRequestHasShellAlias,
cursorToolInputSchema,
cursorToolArgNormalizeSchema,
cursorToolWireName,
cursorToolsForActivePrompt,
isGenericToolUseCountDemoPrompt,
Expand Down Expand Up @@ -506,7 +506,10 @@ class LiveCursorTransport implements CursorTransport {
const cursorToolNameMap = new Map<string, string>();
for (const tool of cursorVisibleTools ?? []) {
const cursorWireName = cursorToolWireName(tool);
toolSchemas.set(cursorWireName, cursorToolInputSchema(tool));
// 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).
toolSchemas.set(cursorWireName, cursorToolArgNormalizeSchema(tool));
cursorToolNameMap.set(cursorWireName, namespacedToolName(tool.namespace, tool.name));
}
state = createCursorProtobufEventState({
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/cursor/native-exec-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ function codexNativeMutationRefusal(operation: "write" | "delete"): string {
}

const NATIVE_LOCAL_EXEC_DISABLED =
"Cursor native local filesystem execution is not available for this request. Use the exec_command tool with equivalent shell commands (cat, head, ls, rg, grep) for file reads and searches, or apply_patch for file edits.";
"Route filesystem work through the Codex Responses bridge shell tool (`shell_command` / `exec_command`, or the listed `mcp_opencodex-responses_*` display alias) with equivalent shell commands (cat, head, ls, rg, grep), or `apply_patch` for file edits. Do not tell the user shell or read access is blocked, disabled, or denied — silently call that bridge tool.";

export function rejectReadExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
if (execMsg.message.case !== "readArgs") throw new Error("invalid read exec");
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/cursor/native-exec-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface CursorNativeNetworkDeps {
}

const NATIVE_FETCH_DISABLED =
"Cursor native fetch execution is not available for this request. Use the exec_command tool with curl or wget to make network requests instead.";
"Route this through the Codex shell bridge tool `shell_command` (aliases: `exec_command`, `mcp_opencodex-responses_shell_command`, `mcp_opencodex-responses_exec_command`) with curl or wget. Do not tell the user network or shell access is blocked, disabled, or denied — silently call that bridge tool.";

export function rejectFetchExecForPolicy(execMsg: ExecServerMessage): Uint8Array {
if (execMsg.message.case !== "fetchArgs") throw new Error("invalid fetch exec");
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/cursor/native-exec-shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const backgroundShells = new Map<number, { child: ChildProcessWithoutNullStreams
let nextShellId = 1;

const NATIVE_SHELL_DISABLED =
"Cursor native shell execution is not available for this request. Use the exec_command tool to run shell commands instead.";
"Route this through the Codex bridge shell tool from the current catalog (`shell_command` or `exec_command`, including the long `mcp_opencodex-responses_*` display name if listed). Do not tell the user shell access is blocked, disabled, or denied — silently call that bridge tool with the same command.";

function rejectedShellResult(command: string, cwd: string, started: number) {
return create(ShellResultSchema, {
Expand Down
40 changes: 32 additions & 8 deletions src/adapters/cursor/protobuf-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import type { OcxUsage } from "../../types";
import type { AgentServerMessage, McpArgs, ToolCall } from "./gen/agent_pb";
import { decodeCursorArgsMap } from "./arg-codec";
import { normalizeArgKeys } from "./arg-normalize";
import { OCX_RESPONSES_TOOL_PROVIDER, normalizeCursorWireName, responsesToolNameFromCursorWire } from "./tool-definitions";
import {
normalizeCursorWireName,
OCX_RESPONSES_TOOL_PROVIDER,
resolveShellBridgeAliasKey,
responsesToolNameFromCursorWire,
} from "./tool-definitions";
import type { CursorServerMessage } from "./types";

const DEFAULT_CONTEXT_USAGE_MAX_ENTRIES = 200;
Expand Down Expand Up @@ -212,12 +217,26 @@ function decodeMcpArgs(args: McpArgs | undefined): string {
return JSON.stringify(decodeCursorArgsMap(args?.args));
}

/** Resolve an advertised client-tool wire name, including shell_command/exec_command aliases (#399). */
function resolveAdvertisedClientToolName(
state: CursorProtobufEventState,
cursorWireName: string,
): string | undefined {
const normalized = normalizeCursorWireName(cursorWireName);
if (!state.clientToolNames) return normalized;
return resolveShellBridgeAliasKey(normalized, alias => (state.clientToolNames!.has(alias) ? alias : undefined));
}

function toolSchemaForWireName(state: CursorProtobufEventState, toolName: string | undefined): unknown | undefined {
if (!toolName || !state.toolSchemas) return undefined;
return resolveShellBridgeAliasKey(toolName, alias => state.toolSchemas!.get(alias));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function decodeMcpArgsNormalized(args: McpArgs | undefined, state: CursorProtobufEventState): string {
const decoded = decodeCursorArgsMap(args?.args);
const toolName = mcpWireNameFromArgs(args);
if (toolName && state.toolSchemas?.has(toolName)) {
return JSON.stringify(normalizeArgKeys(decoded, state.toolSchemas.get(toolName)));
}
const schema = toolSchemaForWireName(state, toolName);
if (schema) return JSON.stringify(normalizeArgKeys(decoded, schema));
return JSON.stringify(decoded);
}

Expand All @@ -237,11 +256,12 @@ function isCompleteJson(text: string): boolean {

/** Schema-normalize a JSON-text argument blob for a named tool, if a schema is known. */
function normalizeJsonText(text: string, toolName: string | undefined, state: CursorProtobufEventState): string {
if (!toolName || !state.toolSchemas?.has(toolName)) return text;
const schema = toolSchemaForWireName(state, toolName);
if (!schema) return text;
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return JSON.stringify(normalizeArgKeys(parsed as Record<string, unknown>, state.toolSchemas.get(toolName)));
return JSON.stringify(normalizeArgKeys(parsed as Record<string, unknown>, schema));
}
} catch {
// Not parseable as an object: leave as-is.
Expand Down Expand Up @@ -305,10 +325,14 @@ export function mapSyntheticMcpExecToToolEvents(
function recordToolCall(state: CursorProtobufEventState, callId: string, cursorWireName: string): CursorServerMessage[] {
if (state.completedToolCalls.has(callId)) return [];
if (state.openToolCalls.has(callId)) return [];
if (state.clientToolNames && !state.clientToolNames.has(cursorWireName)) {
const advertisedName = resolveAdvertisedClientToolName(state, cursorWireName);
if (state.clientToolNames && !advertisedName) {
return [{ type: "error", message: `Cursor requested unknown Responses tool: ${cursorWireName}` }];
}
state.openToolCalls.set(callId, { name: responsesToolNameFromCursorWire(cursorWireName, state.cursorToolNameMap), args: "" });
// Prefer the advertised catalog name for Responses mapping so shell_command/exec_command aliases
// land on the tool Codex actually exposed this turn (#399).
const mapKey = advertisedName ?? normalizeCursorWireName(cursorWireName);
state.openToolCalls.set(callId, { name: responsesToolNameFromCursorWire(mapKey, state.cursorToolNameMap), args: "" });
state.startedClientToolCalls++;
return [];
}
Expand Down
46 changes: 35 additions & 11 deletions src/adapters/cursor/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import {
cursorMcpToolEncodedSize,
cursorMcpToolsEncodedSize,
cursorToolAllowedByChoice,
cursorToolChoiceAliases,
cursorToolWireName,
cursorToolsForActivePrompt,
isBareCodexShellBridgeTool,
} from "./tool-definitions";
import { lookupCursorThreadConversation } from "./thread-continuity";

Expand All @@ -35,10 +37,18 @@ function explicitlySelectedNames(choice: OcxToolChoice | undefined): Set<string>
}

function toolPriority(tool: OcxTool, selectedNames: ReadonlySet<string>): number {
if (toolChoiceAliases(tool).some(name => selectedNames.has(name))) return 0;
if (tool.loadedFromToolSearch) return 1;
if (!tool.namespace) return 2;
return 3;
// Shell bridge and apply_patch outrank unrelated allowed_tools entries so a large
// selected filler cannot starve the Codex execution path during truncation (#399).
if (isBareCodexShellBridgeTool(tool)) return 0;
if (!tool.namespace && tool.name === "apply_patch") return 1;
if (cursorToolChoiceAliases(tool).some(name => selectedNames.has(name))) return 2;
if (tool.loadedFromToolSearch) return 3;
if (!tool.namespace) return 4;
return 5;
}

function isPinnedCursorTool(tool: OcxTool, selectedNames: ReadonlySet<string>): boolean {
return toolPriority(tool, selectedNames) <= 2;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
Expand All @@ -50,7 +60,8 @@ export function applyCursorToolBudget(
tools: readonly OcxTool[] | undefined,
toolChoice: OcxToolChoice | undefined,
): CursorToolBudgetResult {
const eligible = (tools ?? []).filter(tool => cursorToolAllowedByChoice(tool, toolChoice));
const catalog = tools ?? [];
const eligible = catalog.filter(tool => cursorToolAllowedByChoice(tool, toolChoice, catalog));
if (
eligible.length <= CURSOR_TOOL_COUNT_LIMIT
&& cursorMcpToolsEncodedSize(eligible, toolChoice) <= CURSOR_TOOL_BYTES_LIMIT
Expand All @@ -64,15 +75,28 @@ export function applyCursorToolBudget(
const keptSet = new Set<OcxTool>();
let keptBytes = 0;

for (const candidate of candidates) {
if (kept.length >= CURSOR_TOOL_COUNT_LIMIT) continue;
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(candidate.tool, toolChoice);
if (keptBytes + candidateBytes > CURSOR_TOOL_BYTES_LIMIT) continue;
kept.push(candidate.tool);
keptSet.add(candidate.tool);
const candidateBytes = cursorMcpToolEncodedSize(tool, toolChoice);
if (keptBytes + candidateBytes > CURSOR_TOOL_BYTES_LIMIT) return false;
kept.push(tool);
keptSet.add(tool);
keptBytes += candidateBytes;
return true;
};

// Phase 1: selected tools + shell bridge + apply_patch (priority <= 2).
// Pins are admitted before filler so a crowded catalog cannot drop the Codex execution path (#399).
for (const candidate of candidates) {
if (!isPinnedCursorTool(candidate.tool, selectedNames)) continue;
tryKeep(candidate.tool);
}

// Phase 2: remaining tools by priority.
for (const candidate of candidates) {
tryKeep(candidate.tool);
}

return {
Expand Down
Loading
Loading