Skip to content

Commit 238afc0

Browse files
LLM-27035 [Codex] Add missing elicitation
1 parent 77ef966 commit 238afc0

12 files changed

Lines changed: 771 additions & 5 deletions

src/CodexAcpClient.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import {isCodexAuthRequest} from "./CodexAuthMethod";
22
import type {EmbeddedResourceResource} from "@agentclientprotocol/sdk";
33
import * as acp from "@agentclientprotocol/sdk";
44
import {type McpServer, RequestError} from "@agentclientprotocol/sdk";
5-
import type {ApprovalHandler, CodexAppServerClient} from "./CodexAppServerClient";
5+
import type {ApprovalHandler, CodexAppServerClient, ElicitationHandler} from "./CodexAppServerClient";
66
import open from "open";
77
import type {Disposable} from "vscode-jsonrpc";
88
import type {
@@ -360,10 +360,12 @@ export class CodexAcpClient {
360360
async subscribeToSessionEvents(
361361
sessionId: string,
362362
eventHandler: (result: ServerNotification) => void,
363-
approvalHandler: ApprovalHandler
363+
approvalHandler: ApprovalHandler,
364+
elicitationHandler: ElicitationHandler
364365
) {
365366
this.codexClient.onServerNotification(sessionId, eventHandler);
366367
this.codexClient.onApprovalRequest(sessionId, approvalHandler);
368+
this.codexClient.onElicitationRequest(sessionId, elicitationHandler);
367369
}
368370

369371
async sendPrompt(

src/CodexAcpServer.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
} from "@agentclientprotocol/sdk";
88
import {CodexEventHandler} from "./CodexEventHandler";
99
import {CodexApprovalHandler} from "./CodexApprovalHandler";
10+
import {CodexElicitationHandler} from "./CodexElicitationHandler";
11+
import {PendingMcpApprovals} from "./PendingMcpApprovals";
1012
import {CodexAuthMethods, type CodexAuthRequest} from "./CodexAuthMethod";
1113
import {CodexAcpClient, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient";
1214
import {ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection";
@@ -705,11 +707,14 @@ export class CodexAcpServer implements acp.Agent {
705707
sessionState.lastTokenUsage = null;
706708

707709
try {
708-
const eventHandler = new CodexEventHandler(this.connection, sessionState);
710+
const pendingMcpApprovals = new PendingMcpApprovals();
711+
const eventHandler = new CodexEventHandler(this.connection, sessionState, pendingMcpApprovals);
709712
const approvalHandler = new CodexApprovalHandler(this.connection, sessionState);
713+
const elicitationHandler = new CodexElicitationHandler(this.connection, sessionState, pendingMcpApprovals);
710714
await this.codexAcpClient.subscribeToSessionEvents(params.sessionId,
711715
(event) => eventHandler.handleNotification(event),
712-
approvalHandler);
716+
approvalHandler,
717+
elicitationHandler);
713718

714719
if (await this.availableCommands.tryHandle(params.prompt, sessionState)) {
715720
logger.log("Prompt handled by a command");

src/CodexAppServerClient.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import type {
2828
CommandExecutionRequestApprovalResponse,
2929
FileChangeRequestApprovalParams,
3030
FileChangeRequestApprovalResponse,
31+
McpServerElicitationRequestParams,
32+
McpServerElicitationRequestResponse,
3133
ThreadResumeParams,
3234
ThreadResumeResponse,
3335
SkillsListParams,
@@ -41,6 +43,10 @@ export interface ApprovalHandler {
4143
handleFileChange(params: FileChangeRequestApprovalParams): Promise<FileChangeRequestApprovalResponse>;
4244
}
4345

46+
export interface ElicitationHandler {
47+
handleElicitation(params: McpServerElicitationRequestParams): Promise<McpServerElicitationRequestResponse>;
48+
}
49+
4450
const CommandExecutionApprovalRequest = new RequestType<
4551
CommandExecutionRequestApprovalParams,
4652
CommandExecutionRequestApprovalResponse,
@@ -53,13 +59,20 @@ const FileChangeApprovalRequest = new RequestType<
5359
void
5460
>('item/fileChange/requestApproval');
5561

62+
const McpServerElicitationRequest = new RequestType<
63+
McpServerElicitationRequestParams,
64+
McpServerElicitationRequestResponse,
65+
void
66+
>('mcpServer/elicitation/request');
67+
5668
/**
5769
* A type-safe client over the Codex App Server's JSON-RPC API.
5870
* Maps each request to its expected response and exposes clear, typed methods for supported JSON-RPC operations.
5971
*/
6072
export class CodexAppServerClient {
6173
readonly connection: MessageConnection;
6274
private approvalHandlers = new Map<string, ApprovalHandler>();
75+
private elicitationHandlers = new Map<string, ElicitationHandler>();
6376
private mcpStartupCompleteVersion = 0;
6477
private lastMcpStartupComplete: McpStartupCompleteEvent | null = null;
6578
private readonly mcpStartupCompleteResolvers: Array<SignalResolver<McpStartupCompleteEvent>> = [];
@@ -98,12 +111,24 @@ export class CodexAppServerClient {
98111
}
99112
return await handler.handleFileChange(params);
100113
});
114+
115+
this.connection.onRequest(McpServerElicitationRequest, async (params) => {
116+
const handler = this.elicitationHandlers.get(params.threadId);
117+
if (!handler) {
118+
return { action: "cancel", content: null, _meta: null };
119+
}
120+
return await handler.handleElicitation(params);
121+
});
101122
}
102123

103124
onApprovalRequest(threadId: string, handler: ApprovalHandler): void {
104125
this.approvalHandlers.set(threadId, handler);
105126
}
106127

128+
onElicitationRequest(threadId: string, handler: ElicitationHandler): void {
129+
this.elicitationHandlers.set(threadId, handler);
130+
}
131+
107132
async initialize(params: InitializeParams): Promise<InitializeResponse> {
108133
return await this.sendRequest({ method: "initialize", params: params });
109134
}

src/CodexElicitationHandler.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import * as acp from "@agentclientprotocol/sdk";
2+
import type { SessionState } from "./CodexAcpServer";
3+
import type { ElicitationHandler } from "./CodexAppServerClient";
4+
import type {
5+
McpServerElicitationRequestParams,
6+
McpServerElicitationRequestResponse,
7+
} from "./app-server/v2";
8+
import { logger } from "./Logger";
9+
import type { PendingMcpApprovals } from "./PendingMcpApprovals";
10+
11+
// Standard elicitation options (non-tool-call approval).
12+
const ELICITATION_OPTIONS: acp.PermissionOption[] = [
13+
{ optionId: "accept", name: "Accept", kind: "allow_once" },
14+
{ optionId: "decline", name: "Decline", kind: "reject_once" },
15+
];
16+
17+
// Option IDs used for MCP tool call approval persist choices.
18+
const OPTION_ALLOW_ONCE = "allow_once";
19+
const OPTION_ALLOW_SESSION = "allow_session";
20+
const OPTION_ALLOW_ALWAYS = "allow_always";
21+
22+
type PersistValue = "session" | "always";
23+
24+
/**
25+
* Parses the `persist` field from the elicitation request `_meta`.
26+
* Codex advertises which persistence options the client should show.
27+
* Returns a set of supported persist values.
28+
*/
29+
function parsePersistOptions(meta: unknown): Set<PersistValue> {
30+
const result = new Set<PersistValue>();
31+
if (!meta || typeof meta !== "object") return result;
32+
const persist = (meta as Record<string, unknown>)["persist"];
33+
if (persist === "session") {
34+
result.add("session");
35+
} else if (persist === "always") {
36+
result.add("always");
37+
} else if (Array.isArray(persist)) {
38+
if (persist.includes("session")) result.add("session");
39+
if (persist.includes("always")) result.add("always");
40+
}
41+
return result;
42+
}
43+
44+
function isMcpToolCallApproval(meta: unknown): boolean {
45+
return (
46+
meta !== null &&
47+
typeof meta === "object" &&
48+
(meta as Record<string, unknown>)["codex_approval_kind"] === "mcp_tool_call"
49+
);
50+
}
51+
52+
/**
53+
* Builds the ACP permission options for an MCP tool call approval elicitation.
54+
* Always includes "Allow Once"; adds session/always persist options when advertised.
55+
*/
56+
function buildToolApprovalOptions(persistOptions: Set<PersistValue>): acp.PermissionOption[] {
57+
const options: acp.PermissionOption[] = [
58+
{ optionId: OPTION_ALLOW_ONCE, name: "Allow", kind: "allow_once" },
59+
];
60+
if (persistOptions.has("session")) {
61+
options.push({ optionId: OPTION_ALLOW_SESSION, name: "Allow for This Session", kind: "allow_always" });
62+
}
63+
if (persistOptions.has("always")) {
64+
options.push({ optionId: OPTION_ALLOW_ALWAYS, name: "Allow and Don't Ask Again", kind: "allow_always" });
65+
}
66+
options.push({ optionId: "decline", name: "Decline", kind: "reject_once" });
67+
return options;
68+
}
69+
70+
export class CodexElicitationHandler implements ElicitationHandler {
71+
private readonly connection: acp.AgentSideConnection;
72+
private readonly sessionState: SessionState;
73+
private readonly pendingMcpApprovals: PendingMcpApprovals | undefined;
74+
75+
constructor(
76+
connection: acp.AgentSideConnection,
77+
sessionState: SessionState,
78+
pendingMcpApprovals?: PendingMcpApprovals
79+
) {
80+
this.connection = connection;
81+
this.sessionState = sessionState;
82+
this.pendingMcpApprovals = pendingMcpApprovals;
83+
}
84+
85+
async handleElicitation(
86+
params: McpServerElicitationRequestParams
87+
): Promise<McpServerElicitationRequestResponse> {
88+
try {
89+
const request = this.buildPermissionRequest(params);
90+
const response = await this.connection.requestPermission(request);
91+
return this.convertResponse(response);
92+
} catch (error) {
93+
logger.error("Error handling MCP elicitation request", error);
94+
return { action: "cancel", content: null, _meta: null };
95+
}
96+
}
97+
98+
private buildPermissionRequest(
99+
params: McpServerElicitationRequestParams
100+
): acp.RequestPermissionRequest {
101+
const sessionId = this.sessionState.sessionId;
102+
const messageContent: acp.ToolCallContent = {
103+
type: "content",
104+
content: { type: "text", text: params.message },
105+
};
106+
107+
const meta = params._meta;
108+
const isToolApproval = isMcpToolCallApproval(meta);
109+
const options = isToolApproval
110+
? buildToolApprovalOptions(parsePersistOptions(meta))
111+
: ELICITATION_OPTIONS;
112+
113+
if (params.mode === "form") {
114+
const correlatedCallId = isToolApproval
115+
? this.pendingMcpApprovals?.pop(params.threadId, params.serverName)
116+
: undefined;
117+
if (correlatedCallId !== undefined) {
118+
// The tool call item is already visible in the IDE conversation history because
119+
// item/started was emitted before the elicitation request. Sending content or
120+
// rawInput here would duplicate that information in the approval widget.
121+
return {
122+
sessionId,
123+
toolCall: {
124+
toolCallId: correlatedCallId,
125+
kind: "execute",
126+
status: "pending",
127+
// content: [messageContent], — omitted: already rendered via item/started
128+
// rawInput: { ... } — omitted: same reason
129+
},
130+
options,
131+
};
132+
}
133+
return {
134+
sessionId,
135+
toolCall: {
136+
toolCallId: `elicitation-${params.serverName}`,
137+
kind: isToolApproval ? "execute" : "other",
138+
status: "pending",
139+
content: [messageContent],
140+
rawInput: { serverName: params.serverName, schema: params.requestedSchema },
141+
},
142+
options,
143+
};
144+
} else {
145+
return {
146+
sessionId,
147+
toolCall: {
148+
toolCallId: `elicitation-${params.elicitationId}`,
149+
kind: "fetch",
150+
status: "pending",
151+
content: [messageContent],
152+
rawInput: { serverName: params.serverName, url: params.url },
153+
},
154+
options,
155+
};
156+
}
157+
}
158+
159+
private convertResponse(
160+
response: acp.RequestPermissionResponse
161+
): McpServerElicitationRequestResponse {
162+
if (response.outcome.outcome === "cancelled") {
163+
return { action: "cancel", content: null, _meta: null };
164+
}
165+
166+
const optionId = response.outcome.optionId;
167+
if (optionId === OPTION_ALLOW_SESSION) {
168+
return { action: "accept", content: null, _meta: { persist: "session" } };
169+
}
170+
if (optionId === OPTION_ALLOW_ALWAYS) {
171+
return { action: "accept", content: null, _meta: { persist: "always" } };
172+
}
173+
if (optionId === OPTION_ALLOW_ONCE || optionId === "accept") {
174+
return { action: "accept", content: null, _meta: null };
175+
}
176+
return { action: "decline", content: null, _meta: null };
177+
}
178+
}

src/CodexEventHandler.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
fuzzyFileSearchToolCallId,
3535
} from "./CodexToolCallMapper";
3636
import { stripShellPrefix } from "./CommandUtils";
37+
import type { PendingMcpApprovals } from "./PendingMcpApprovals";
3738

3839
export { stripShellPrefix };
3940

@@ -43,10 +44,12 @@ export class CodexEventHandler {
4344
private readonly sessionState: SessionState;
4445
private failure: RequestError | null = null;
4546
private readonly activeFuzzyFileSearchSessions = new Set<string>();
47+
private readonly pendingMcpApprovals: PendingMcpApprovals | undefined;
4648

47-
constructor(connection: acp.AgentSideConnection, sessionState: SessionState) {
49+
constructor(connection: acp.AgentSideConnection, sessionState: SessionState, pendingMcpApprovals?: PendingMcpApprovals) {
4850
this.connection = connection;
4951
this.sessionState = sessionState;
52+
this.pendingMcpApprovals = pendingMcpApprovals;
5053
}
5154

5255
getFailure(): RequestError | null {
@@ -193,6 +196,7 @@ export class CodexEventHandler {
193196
case "commandExecution":
194197
return await createCommandExecutionUpdate(event.item);
195198
case "mcpToolCall":
199+
this.pendingMcpApprovals?.record(event.threadId, event.item.server, event.item.id);
196200
return await createMcpToolCallUpdate(event.item);
197201
case "dynamicToolCall":
198202
return await createDynamicToolCallUpdate(event.item);

src/PendingMcpApprovals.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// In Rust, the MCP elicitation handler receives ElicitationRequestEvent directly from the MCP
2+
// protocol layer, where id is set to "mcp_tool_call_approval_<call_id>" — the call ID is extracted
3+
// by stripping that prefix.
4+
//
5+
// In TypeScript, Codex speaks the app-server JSON-RPC protocol (v2), where McpServerElicitationRequestParams
6+
// omits elicitationId for form mode, so the MCP-level ID never reaches the client.
7+
//
8+
// Workaround: before requesting approval, Codex emits an item/started notification with an mcpToolCall
9+
// item carrying the call id and server name. This class stores (threadId, serverName) → callId so the
10+
// elicitation handler can retrieve it when the request arrives.
11+
//
12+
// Multiple calls are safe because Codex requests approval synchronously — it blocks on one tool call's
13+
// elicitation before starting the next, so there is at most one pending approval per (threadId, serverName).
14+
export class PendingMcpApprovals {
15+
private readonly pending = new Map<string, string>();
16+
17+
record(threadId: string, serverName: string, callId: string): void {
18+
this.pending.set(`${threadId}:${serverName}`, callId);
19+
}
20+
21+
pop(threadId: string, serverName: string): string | undefined {
22+
const key = `${threadId}:${serverName}`;
23+
const callId = this.pending.get(key);
24+
this.pending.delete(key);
25+
return callId;
26+
}
27+
}

0 commit comments

Comments
 (0)