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:
@@ -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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user