From c800c6a3f155df0e45e97a1771649f596a1f831e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 11 Jul 2026 16:15:17 +0200 Subject: [PATCH] Allow Codex MCP approval prompts (#2001) * fix(codex): surface MCP approval prompts * fix(codex): return accepted elicitation content * fix(codex): reject unsupported MCP URL elicitations * fix(codex): clear resolved MCP approvals * fix(codex): decline unsupported MCP forms * fix(codex): cancel resolved MCP prompts * fix(codex): ignore resolved MCP approvals * fix(codex): request MCP form elicitations --- .../providers/codex-app-server-agent.test.ts | 78 +++++++++++++++- .../agent/providers/codex-app-server-agent.ts | 89 ++++++++++++++++++- .../providers/codex/app-server-transport.ts | 4 +- .../codex/test-utils/fake-app-server.ts | 46 ++++++++++ 4 files changed, 210 insertions(+), 7 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 56ec1fc3b..cc4c00929 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -554,6 +554,80 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("surfaces an MCP elicitation and returns Codex's required approval action", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + + await session.connect(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const permissionRequested = waitForNextPermission(session); + appServer.requestMcpElicitation({ + threadId: "thread-1", + turnId: "turn-1", + serverName: "browser", + message: "Allow the browser to open this page?", + requestedSchema: { + type: "object", + properties: {}, + }, + }); + + const permission = await permissionRequested; + expect(permission.request).toEqual({ + id: expect.any(String), + provider: "codex", + name: "CodexMcpElicitation", + kind: "tool", + title: "MCP approval: browser", + description: "Allow the browser to open this page?", + input: { + mode: "openai/form", + requestedSchema: { + type: "object", + properties: {}, + }, + url: null, + }, + metadata: { + threadId: "thread-1", + turnId: "turn-1", + serverName: "browser", + elicitationId: null, + }, + }); + await session.respondToPermission(permission.request.id, { behavior: "allow" }); + + await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({ + action: "accept", + content: {}, + _meta: null, + }); + appServer.resolvesMcpElicitation(); + + await vi.waitFor(() => { + expect(events).toContainEqual({ + type: "permission_resolved", + provider: "codex", + requestId: permission.request.id, + resolution: { behavior: "allow" }, + }); + }); + expect(events).not.toContainEqual({ + type: "permission_resolved", + provider: "codex", + requestId: permission.request.id, + resolution: { behavior: "deny", interrupt: true }, + }); + await session.close(); + }); + test("initializes Codex app-server without making Paseo the request originator", async () => { let initializeParams: unknown; const appServer = createFakeCodexAppServer({ @@ -579,7 +653,7 @@ describe("Codex app-server provider", () => { title: "Codex App Server Daemon", version: "0.0.0", }, - capabilities: { experimentalApi: true }, + capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true }, }); appServer.assertNoErrors(); await session.close(); @@ -3771,7 +3845,7 @@ describe("Codex importable sessions", () => { title: "Codex App Server Daemon", version: "0.0.0", }, - capabilities: { experimentalApi: true }, + capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true }, }, }, { method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index cd140fce6..c9239dd0a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -2997,12 +2997,13 @@ export function buildCodexAppServerEnv( function buildCodexAppServerInitializeParams(): { clientInfo: { name: string; title: string; version: string }; - capabilities: { experimentalApi: true }; + capabilities: { experimentalApi: true; mcpServerOpenaiFormElicitation: true }; } { return { clientInfo: CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO, capabilities: { experimentalApi: true, + mcpServerOpenaiFormElicitation: true, }, }; } @@ -3081,11 +3082,12 @@ export class CodexAppServerAgentSession implements AgentSession { private historyPending = false; private persistedHistory: PersistedTimelineEntry[] = []; private pendingPermissions = new Map(); + private mcpElicitationPermissionIds = new Map(); private pendingPermissionHandlers = new Map< string, { resolve: (value: unknown) => void; - kind: "command" | "file" | "question" | "plan"; + kind: "command" | "file" | "question" | "mcp_elicitation" | "plan"; questions?: CodexQuestionPrompt[]; planText?: string; } @@ -3427,6 +3429,9 @@ export class CodexAppServerAgentSession implements AgentSession { this.client.setRequestHandler("item/tool/requestUserInput", (params) => this.handleToolApprovalRequest(params), ); + this.client.setRequestHandler("mcpServer/elicitation/request", (params, requestId) => + this.handleMcpElicitationRequest(params, requestId), + ); // Keep the legacy method name for older Codex builds. this.client.setRequestHandler("tool/requestUserInput", (params) => this.handleToolApprovalRequest(params), @@ -3949,6 +3954,15 @@ export class CodexAppServerAgentSession implements AgentSession { return; } + if (pending.kind === "mcp_elicitation") { + pending.resolve({ + action: resolvePermissionDecision(response), + content: response.behavior === "allow" ? {} : null, + _meta: null, + }); + return; + } + const questions = pending.questions ?? []; const itemId = typeof pendingRequest?.metadata?.itemId === "string" @@ -4003,7 +4017,7 @@ export class CodexAppServerAgentSession implements AgentSession { response: AgentPermissionResponse; pending: { resolve: (value: unknown) => void; - kind: "command" | "file" | "question" | "plan"; + kind: "command" | "file" | "question" | "mcp_elicitation" | "plan"; questions?: CodexQuestionPrompt[]; planText?: string; }; @@ -4470,6 +4484,27 @@ export class CodexAppServerAgentSession implements AgentSession { } private handleNotification(method: string, params: unknown): void { + const notificationParams = toObjectRecord(params); + if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") { + const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId); + if (requestId) { + const pending = this.pendingPermissionHandlers.get(requestId); + this.mcpElicitationPermissionIds.delete(notificationParams.requestId); + if (!pending) { + return; + } + this.pendingPermissions.delete(requestId); + this.pendingPermissionHandlers.delete(requestId); + pending.resolve({ action: "cancel", content: null, _meta: null }); + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId, + resolution: { behavior: "deny", interrupt: true }, + }); + } + return; + } const parsed = CodexNotificationSchema.parse({ method, params }); this.traceParsedNotification(method, params, parsed); const route = this.resolveCodexThreadRoute(getCodexNotificationThreadId(parsed)); @@ -5820,6 +5855,54 @@ export class CodexAppServerAgentSession implements AgentSession { }); }); } + + private handleMcpElicitationRequest(params: unknown, serverRequestId: number): Promise { + const parsed = z + .object({ + threadId: z.string(), + turnId: z.string().nullable().optional(), + serverName: z.string(), + mode: z.enum(["form", "openai/form", "url"]), + message: z.string(), + requestedSchema: z.unknown().optional(), + url: z.string().optional(), + elicitationId: z.string().optional(), + }) + .parse(params); + if (parsed.mode === "url") { + return Promise.resolve({ action: "decline", content: null, _meta: null }); + } + const requiredFields = toObjectRecord(parsed.requestedSchema)?.required; + if (Array.isArray(requiredFields) && requiredFields.length > 0) { + return Promise.resolve({ action: "decline", content: null, _meta: null }); + } + const requestId = `permission-${randomUUID()}`; + const request: AgentPermissionRequest = { + id: requestId, + provider: CODEX_PROVIDER, + name: "CodexMcpElicitation", + kind: "tool", + title: `MCP approval: ${parsed.serverName}`, + description: parsed.message, + input: { + mode: parsed.mode, + requestedSchema: parsed.requestedSchema ?? null, + url: parsed.url ?? null, + }, + metadata: { + threadId: parsed.threadId, + turnId: parsed.turnId ?? null, + serverName: parsed.serverName, + elicitationId: parsed.elicitationId ?? null, + }, + }; + this.pendingPermissions.set(requestId, request); + this.mcpElicitationPermissionIds.set(serverRequestId, requestId); + this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); + return new Promise((resolve) => { + this.pendingPermissionHandlers.set(requestId, { resolve, kind: "mcp_elicitation" }); + }); + } } export class CodexAppServerAgentClient implements AgentClient { diff --git a/packages/server/src/server/agent/providers/codex/app-server-transport.ts b/packages/server/src/server/agent/providers/codex/app-server-transport.ts index e99360bf3..81a58bb9b 100644 --- a/packages/server/src/server/agent/providers/codex/app-server-transport.ts +++ b/packages/server/src/server/agent/providers/codex/app-server-transport.ts @@ -33,7 +33,7 @@ interface PendingRequest { timer: NodeJS.Timeout; } -type RequestHandler = (params: unknown) => unknown; +type RequestHandler = (params: unknown, requestId: number) => unknown; type NotificationHandler = (method: string, params: unknown) => void; export interface CodexThreadForkParams { @@ -321,7 +321,7 @@ export class CodexAppServerClient { this.traceRawEvent(request); const handler = this.requestHandlers.get(request.method); try { - const result = handler ? await handler(request.params) : {}; + const result = handler ? await handler(request.params, request.id) : {}; this.writeJsonRpcResponse({ id: request.id, result }); } catch (error) { this.writeJsonRpcResponse({ diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 5a3da2409..8302e6d93 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -61,6 +61,15 @@ export interface FakeCodexAppServer { reason: string; }): void; waitForCommandApprovalDecision(itemId: string): Promise; + requestMcpElicitation(params: { + threadId: string; + turnId: string | null; + serverName: string; + message: string; + requestedSchema: Record; + }): void; + waitForMcpElicitationDecision(): Promise; + resolvesMcpElicitation(): void; } export function createCodexAppServerChildProcess(): CodexAppServerChildProcess { @@ -139,6 +148,7 @@ export function createFakeCodexAppServer( const messages: JsonObject[] = []; const errors: Error[] = []; const approvalRequestIds = new Map(); + let mcpElicitationRequestId: number | undefined; const waiters = new Set<{ predicate: (message: JsonObject) => boolean; resolve: (message: JsonObject) => void; @@ -402,6 +412,42 @@ export function createFakeCodexAppServer( ); return message.result; }, + requestMcpElicitation(params) { + const requestId = nextServerRequestId; + nextServerRequestId += 1; + mcpElicitationRequestId = requestId; + child.stdout.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id: requestId, + method: "mcpServer/elicitation/request", + params: { + ...params, + mode: "openai/form", + _meta: null, + }, + })}\n`, + ); + }, + async waitForMcpElicitationDecision() { + if (mcpElicitationRequestId === undefined) { + throw new Error("No pending fake Codex app-server MCP elicitation"); + } + const message = await waitForMessage( + (candidate) => + candidate.id === mcpElicitationRequestId && + !("method" in candidate) && + "result" in candidate, + "MCP elicitation response", + ); + return message.result; + }, + resolvesMcpElicitation() { + if (mcpElicitationRequestId === undefined) { + throw new Error("No pending fake Codex app-server MCP elicitation"); + } + writeNotification("serverRequest/resolved", { requestId: mcpElicitationRequestId }); + }, }; }