diff --git a/src/codex/app-server-client.ts b/src/codex/app-server-client.ts index aa40e13..bbc4243 100644 --- a/src/codex/app-server-client.ts +++ b/src/codex/app-server-client.ts @@ -481,6 +481,24 @@ export class CodexAppServerClient { } } + if (isMcpToolApprovalElicitation(parsed, method)) { + if (responseId !== null) { + this.send({ + id: parsed.id, + result: buildMcpToolApprovalAcceptance(parsed), + }); + } + this.emit({ + event: "approval_auto_approved", + sessionId: this.currentTurn?.sessionId ?? null, + threadId: this.currentTurn?.threadId ?? this.threadId, + turnId: this.currentTurn?.turnId ?? null, + raw: parsed, + ...optionalTelemetry(this.lastUsage, this.lastRateLimits), + }); + return; + } + if (isApprovalRequest(parsed, method)) { if (responseId !== null) { this.send({ @@ -889,6 +907,62 @@ function isApprovalRequest( return containsStringValue(message, "approval"); } +function isMcpToolApprovalElicitation( + message: JsonObject, + method: string | null, +): boolean { + if (method !== "mcpServer/elicitation/request") { + return false; + } + + return ( + extractNestedString(message, ["params", "_meta", "codex_approval_kind"]) === + "mcp_tool_call" + ); +} + +function buildMcpToolApprovalAcceptance(message: JsonObject): JsonObject { + const persist = pickMcpToolApprovalPersist(message); + return { + action: "accept", + content: {}, + ...(persist === null ? { _meta: null } : { _meta: { persist } }), + }; +} + +function pickMcpToolApprovalPersist(message: JsonObject): string | null { + let persist: unknown = message; + for (const segment of ["params", "_meta", "persist"]) { + if ( + persist === null || + typeof persist !== "object" || + Array.isArray(persist) + ) { + persist = null; + break; + } + persist = (persist as JsonObject)[segment]; + } + + if (persist === "always" || persist === "session") { + return persist; + } + + if (Array.isArray(persist)) { + const values = persist.filter( + (value): value is string => typeof value === "string", + ); + if (values.includes("always")) { + return "always"; + } + if (values.includes("session")) { + return "session"; + } + } + + return null; +} + function isToolCallRequest( message: JsonObject, method: string | null, diff --git a/tests/codex/app-server-client.test.ts b/tests/codex/app-server-client.test.ts index ea0485a..7cc7928 100644 --- a/tests/codex/app-server-client.test.ts +++ b/tests/codex/app-server-client.test.ts @@ -170,6 +170,41 @@ describe("CodexAppServerClient", () => { await client.close(); }); + it("accepts MCP tool approval elicitation with the expected payload", async () => { + const workspace = await createWorkspace(); + const events: CodexClientEvent[] = []; + const client = createClient("mcp-elicitation", workspace, events); + + const result = await client.startSession({ + prompt: "Handle MCP elicitation", + title: "ABC-123: Example", + }); + + expect(result).toMatchObject({ + status: "completed", + message: "MCP elicitation handled", + usage: { + inputTokens: 15, + outputTokens: 10, + totalTokens: 25, + }, + rateLimits: { + requestsRemaining: 10, + tokensRemaining: 1000, + }, + }); + expect(events).toContainEqual( + expect.objectContaining({ + event: "approval_auto_approved", + raw: expect.objectContaining({ + method: "mcpServer/elicitation/request", + }), + }), + ); + + await client.close(); + }); + it("fails the turn when user-input-required is emitted through a compatible variant", async () => { const workspace = await createWorkspace(); const events: CodexClientEvent[] = []; diff --git a/tests/fixtures/codex-fake-server.mjs b/tests/fixtures/codex-fake-server.mjs index dbe7262..e6b5f81 100644 --- a/tests/fixtures/codex-fake-server.mjs +++ b/tests/fixtures/codex-fake-server.mjs @@ -137,6 +137,24 @@ async function handleMessage(message) { return; } + if (scenario === "mcp-elicitation") { + setTimeout(() => { + writeJson({ + id: "elicitation-1", + method: "mcpServer/elicitation/request", + params: { + serverName: "linear", + _meta: { + codex_approval_kind: "mcp_tool_call", + persist: ["session", "always"], + }, + message: 'Allow the linear MCP server to run tool "save_comment"?', + }, + }); + }, 10); + return; + } + if (scenario === "user-input") { setTimeout(() => { writeJson({ @@ -262,6 +280,43 @@ async function handleMessage(message) { return; } + if (message.id === "elicitation-1") { + assertEqual( + message.result?.action, + "accept", + "MCP elicitation must be accepted with action=accept", + ); + assertEqual( + typeof message.result?.content, + "object", + "MCP elicitation acceptance must include content object", + ); + assertEqual( + message.result?._meta?.persist, + "always", + "MCP elicitation must prefer persist=always when present", + ); + + setTimeout(() => { + writeJson({ + method: "turn/completed", + params: { + message: "MCP elicitation handled", + usage: { + inputTokens: 15, + outputTokens: 10, + totalTokens: 25, + }, + rateLimits: { + requestsRemaining: 10, + tokensRemaining: 1000, + }, + }, + }); + }, 10); + return; + } + if (message.id === "tool-1") { if (scenario === "linear-tool") { assertEqual(