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
This commit is contained in:
Mohamed Boudra
2026-07-11 22:15:17 +08:00
committed by GitHub
parent adc9b3b43c
commit c800c6a3f1
4 changed files with 210 additions and 7 deletions
@@ -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" } },
@@ -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<string, AgentPermissionRequest>();
private mcpElicitationPermissionIds = new Map<number, string>();
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<unknown> {
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 {
@@ -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({
@@ -61,6 +61,15 @@ export interface FakeCodexAppServer {
reason: string;
}): void;
waitForCommandApprovalDecision(itemId: string): Promise<unknown>;
requestMcpElicitation(params: {
threadId: string;
turnId: string | null;
serverName: string;
message: string;
requestedSchema: Record<string, unknown>;
}): void;
waitForMcpElicitationDecision(): Promise<unknown>;
resolvesMcpElicitation(): void;
}
export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
@@ -139,6 +148,7 @@ export function createFakeCodexAppServer(
const messages: JsonObject[] = [];
const errors: Error[] = [];
const approvalRequestIds = new Map<string, number>();
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 });
},
};
}