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();
|
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 () => {
|
test("initializes Codex app-server without making Paseo the request originator", async () => {
|
||||||
let initializeParams: unknown;
|
let initializeParams: unknown;
|
||||||
const appServer = createFakeCodexAppServer({
|
const appServer = createFakeCodexAppServer({
|
||||||
@@ -579,7 +653,7 @@ describe("Codex app-server provider", () => {
|
|||||||
title: "Codex App Server Daemon",
|
title: "Codex App Server Daemon",
|
||||||
version: "0.0.0",
|
version: "0.0.0",
|
||||||
},
|
},
|
||||||
capabilities: { experimentalApi: true },
|
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
|
||||||
});
|
});
|
||||||
appServer.assertNoErrors();
|
appServer.assertNoErrors();
|
||||||
await session.close();
|
await session.close();
|
||||||
@@ -3771,7 +3845,7 @@ describe("Codex importable sessions", () => {
|
|||||||
title: "Codex App Server Daemon",
|
title: "Codex App Server Daemon",
|
||||||
version: "0.0.0",
|
version: "0.0.0",
|
||||||
},
|
},
|
||||||
capabilities: { experimentalApi: true },
|
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
|
{ method: "thread/list", params: { limit: 50, cwd: "/workspace/project-a" } },
|
||||||
|
|||||||
@@ -2997,12 +2997,13 @@ export function buildCodexAppServerEnv(
|
|||||||
|
|
||||||
function buildCodexAppServerInitializeParams(): {
|
function buildCodexAppServerInitializeParams(): {
|
||||||
clientInfo: { name: string; title: string; version: string };
|
clientInfo: { name: string; title: string; version: string };
|
||||||
capabilities: { experimentalApi: true };
|
capabilities: { experimentalApi: true; mcpServerOpenaiFormElicitation: true };
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
clientInfo: CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO,
|
clientInfo: CODEX_NON_ORIGINATING_APP_SERVER_CLIENT_INFO,
|
||||||
capabilities: {
|
capabilities: {
|
||||||
experimentalApi: true,
|
experimentalApi: true,
|
||||||
|
mcpServerOpenaiFormElicitation: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3081,11 +3082,12 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private historyPending = false;
|
private historyPending = false;
|
||||||
private persistedHistory: PersistedTimelineEntry[] = [];
|
private persistedHistory: PersistedTimelineEntry[] = [];
|
||||||
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
private pendingPermissions = new Map<string, AgentPermissionRequest>();
|
||||||
|
private mcpElicitationPermissionIds = new Map<number, string>();
|
||||||
private pendingPermissionHandlers = new Map<
|
private pendingPermissionHandlers = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
kind: "command" | "file" | "question" | "plan";
|
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||||
questions?: CodexQuestionPrompt[];
|
questions?: CodexQuestionPrompt[];
|
||||||
planText?: string;
|
planText?: string;
|
||||||
}
|
}
|
||||||
@@ -3427,6 +3429,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.client.setRequestHandler("item/tool/requestUserInput", (params) =>
|
this.client.setRequestHandler("item/tool/requestUserInput", (params) =>
|
||||||
this.handleToolApprovalRequest(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.
|
// Keep the legacy method name for older Codex builds.
|
||||||
this.client.setRequestHandler("tool/requestUserInput", (params) =>
|
this.client.setRequestHandler("tool/requestUserInput", (params) =>
|
||||||
this.handleToolApprovalRequest(params),
|
this.handleToolApprovalRequest(params),
|
||||||
@@ -3949,6 +3954,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pending.kind === "mcp_elicitation") {
|
||||||
|
pending.resolve({
|
||||||
|
action: resolvePermissionDecision(response),
|
||||||
|
content: response.behavior === "allow" ? {} : null,
|
||||||
|
_meta: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const questions = pending.questions ?? [];
|
const questions = pending.questions ?? [];
|
||||||
const itemId =
|
const itemId =
|
||||||
typeof pendingRequest?.metadata?.itemId === "string"
|
typeof pendingRequest?.metadata?.itemId === "string"
|
||||||
@@ -4003,7 +4017,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
response: AgentPermissionResponse;
|
response: AgentPermissionResponse;
|
||||||
pending: {
|
pending: {
|
||||||
resolve: (value: unknown) => void;
|
resolve: (value: unknown) => void;
|
||||||
kind: "command" | "file" | "question" | "plan";
|
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
|
||||||
questions?: CodexQuestionPrompt[];
|
questions?: CodexQuestionPrompt[];
|
||||||
planText?: string;
|
planText?: string;
|
||||||
};
|
};
|
||||||
@@ -4470,6 +4484,27 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleNotification(method: string, params: unknown): void {
|
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 });
|
const parsed = CodexNotificationSchema.parse({ method, params });
|
||||||
this.traceParsedNotification(method, params, parsed);
|
this.traceParsedNotification(method, params, parsed);
|
||||||
const route = this.resolveCodexThreadRoute(getCodexNotificationThreadId(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 {
|
export class CodexAppServerAgentClient implements AgentClient {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ interface PendingRequest {
|
|||||||
timer: NodeJS.Timeout;
|
timer: NodeJS.Timeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestHandler = (params: unknown) => unknown;
|
type RequestHandler = (params: unknown, requestId: number) => unknown;
|
||||||
type NotificationHandler = (method: string, params: unknown) => void;
|
type NotificationHandler = (method: string, params: unknown) => void;
|
||||||
|
|
||||||
export interface CodexThreadForkParams {
|
export interface CodexThreadForkParams {
|
||||||
@@ -321,7 +321,7 @@ export class CodexAppServerClient {
|
|||||||
this.traceRawEvent(request);
|
this.traceRawEvent(request);
|
||||||
const handler = this.requestHandlers.get(request.method);
|
const handler = this.requestHandlers.get(request.method);
|
||||||
try {
|
try {
|
||||||
const result = handler ? await handler(request.params) : {};
|
const result = handler ? await handler(request.params, request.id) : {};
|
||||||
this.writeJsonRpcResponse({ id: request.id, result });
|
this.writeJsonRpcResponse({ id: request.id, result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.writeJsonRpcResponse({
|
this.writeJsonRpcResponse({
|
||||||
|
|||||||
@@ -61,6 +61,15 @@ export interface FakeCodexAppServer {
|
|||||||
reason: string;
|
reason: string;
|
||||||
}): void;
|
}): void;
|
||||||
waitForCommandApprovalDecision(itemId: string): Promise<unknown>;
|
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 {
|
export function createCodexAppServerChildProcess(): CodexAppServerChildProcess {
|
||||||
@@ -139,6 +148,7 @@ export function createFakeCodexAppServer(
|
|||||||
const messages: JsonObject[] = [];
|
const messages: JsonObject[] = [];
|
||||||
const errors: Error[] = [];
|
const errors: Error[] = [];
|
||||||
const approvalRequestIds = new Map<string, number>();
|
const approvalRequestIds = new Map<string, number>();
|
||||||
|
let mcpElicitationRequestId: number | undefined;
|
||||||
const waiters = new Set<{
|
const waiters = new Set<{
|
||||||
predicate: (message: JsonObject) => boolean;
|
predicate: (message: JsonObject) => boolean;
|
||||||
resolve: (message: JsonObject) => void;
|
resolve: (message: JsonObject) => void;
|
||||||
@@ -402,6 +412,42 @@ export function createFakeCodexAppServer(
|
|||||||
);
|
);
|
||||||
return message.result;
|
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