Skip to content

Commit c7b6656

Browse files
committed
Handle MCP server elicitation approvals via ACP
1 parent b2dc107 commit c7b6656

2 files changed

Lines changed: 204 additions & 6 deletions

File tree

src/CodexAppServerClient.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,14 @@ import type {
3434
SkillsListResponse,
3535
ListMcpServerStatusParams,
3636
ListMcpServerStatusResponse, ConfigReadParams, ConfigReadResponse,
37+
McpServerElicitationRequestParams,
38+
McpServerElicitationRequestResponse,
3739
} from "./app-server/v2";
3840

3941
export interface ApprovalHandler {
4042
handleCommandExecution(params: CommandExecutionRequestApprovalParams): Promise<CommandExecutionRequestApprovalResponse>;
4143
handleFileChange(params: FileChangeRequestApprovalParams): Promise<FileChangeRequestApprovalResponse>;
44+
handleMcpServerElicitation(params: McpServerElicitationRequestParams): Promise<McpServerElicitationRequestResponse>;
4245
}
4346

4447
const CommandExecutionApprovalRequest = new RequestType<
@@ -53,6 +56,12 @@ const FileChangeApprovalRequest = new RequestType<
5356
void
5457
>('item/fileChange/requestApproval');
5558

59+
const McpServerElicitationRequest = new RequestType<
60+
McpServerElicitationRequestParams,
61+
McpServerElicitationRequestResponse,
62+
void
63+
>('mcpServer/elicitation/request');
64+
5665
/**
5766
* A type-safe client over the Codex App Server's JSON-RPC API.
5867
* Maps each request to its expected response and exposes clear, typed methods for supported JSON-RPC operations.
@@ -98,6 +107,14 @@ export class CodexAppServerClient {
98107
}
99108
return await handler.handleFileChange(params);
100109
});
110+
111+
this.connection.onRequest(McpServerElicitationRequest, async (params) => {
112+
const handler = this.approvalHandlers.get(params.threadId);
113+
if (!handler) {
114+
return { action: "cancel", content: null, _meta: null };
115+
}
116+
return await handler.handleMcpServerElicitation(params);
117+
});
101118
}
102119

103120
onApprovalRequest(threadId: string, handler: ApprovalHandler): void {

src/CodexApprovalHandler.ts

Lines changed: 187 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@ import type {
55
CommandExecutionRequestApprovalParams,
66
CommandExecutionRequestApprovalResponse,
77
FileChangeRequestApprovalParams,
8-
FileChangeRequestApprovalResponse
8+
FileChangeRequestApprovalResponse,
9+
McpServerElicitationRequestParams,
10+
McpServerElicitationRequestResponse
911
} from "./app-server/v2";
12+
import type {JsonValue} from "./app-server/serde_json/JsonValue";
1013
import type {ToolCallContent} from "@agentclientprotocol/sdk/dist/schema/types.gen";
1114
import {logger} from "./Logger";
1215
import {stripShellPrefix} from "./CodexEventHandler";
@@ -17,6 +20,14 @@ const APPROVAL_OPTIONS: acp.PermissionOption[] = [
1720
{ optionId: "reject_once", name: "Reject", kind: "reject_once" },
1821
];
1922

23+
const ELICITATION_ALLOW_ONCE_OPTION_ID = "allow_once";
24+
const ELICITATION_ALLOW_SESSION_OPTION_ID = "allow_for_session";
25+
const ELICITATION_ALLOW_ALWAYS_OPTION_ID = "allow_always";
26+
const ELICITATION_DENY_ONCE_OPTION_ID = "deny_once";
27+
28+
type ElicitationPersistOption = "session" | "always";
29+
type JsonObject = { [key: string]: JsonValue };
30+
2031
export class CodexApprovalHandler implements ApprovalHandler {
2132
private readonly connection: acp.AgentSideConnection;
2233
private readonly sessionState: SessionState;
@@ -57,11 +68,25 @@ export class CodexApprovalHandler implements ApprovalHandler {
5768
}
5869
}
5970

71+
async handleMcpServerElicitation(
72+
params: McpServerElicitationRequestParams
73+
): Promise<McpServerElicitationRequestResponse> {
74+
try {
75+
const sessionId = this.sessionState.sessionId;
76+
const acpRequest = this.buildMcpServerElicitationPermissionRequest(sessionId, params);
77+
const response = await this.connection.requestPermission(acpRequest);
78+
return this.convertMcpServerElicitationResponse(params, response);
79+
} catch (error) {
80+
logger.error("Error requesting MCP server elicitation approval", error);
81+
return this.createCancelledMcpServerElicitationResponse();
82+
}
83+
}
84+
6085
private buildCommandPermissionRequest(
6186
sessionId: string,
6287
params: CommandExecutionRequestApprovalParams
6388
): acp.RequestPermissionRequest {
64-
const reasonContent = this.createContentFromReason(params.reason ?? null);
89+
const reasonContent = this.createTextContent(params.reason ?? null);
6590
return {
6691
sessionId,
6792
toolCall: {
@@ -75,24 +100,31 @@ export class CodexApprovalHandler implements ApprovalHandler {
75100
};
76101
}
77102

78-
private createContentFromReason(reason: string | null): ToolCallContent | null {
79-
if (reason === null || reason === "") {
103+
private createTextContent(text: string | null): ToolCallContent | null {
104+
if (text === null || text === "") {
80105
return null;
81106
}
82107
return {
83108
type: "content",
84109
content: {
85110
type: "text",
86-
text: reason
111+
text
87112
}
88113
}
89114
}
90115

116+
private createTextContents(...texts: Array<string | null | undefined>): Array<ToolCallContent> | null {
117+
const contents = texts
118+
.map(text => this.createTextContent(text ?? null))
119+
.filter((content): content is ToolCallContent => content !== null)
120+
return contents.length > 0 ? contents : null
121+
}
122+
91123
private buildFileChangePermissionRequest(
92124
sessionId: string,
93125
params: FileChangeRequestApprovalParams
94126
): acp.RequestPermissionRequest {
95-
const reasonContent = this.createContentFromReason(params.reason ?? null);
127+
const reasonContent = this.createTextContent(params.reason ?? null);
96128
return {
97129
sessionId,
98130
toolCall: {
@@ -105,6 +137,117 @@ export class CodexApprovalHandler implements ApprovalHandler {
105137
};
106138
}
107139

140+
private buildMcpServerElicitationPermissionRequest(
141+
sessionId: string,
142+
params: McpServerElicitationRequestParams
143+
): acp.RequestPermissionRequest {
144+
const meta = this.asRecord(params._meta);
145+
const persist = meta?.["persist"];
146+
const persistOptions = (Array.isArray(persist) ? persist : [persist]).filter(
147+
(value): value is ElicitationPersistOption => value === "session" || value === "always"
148+
);
149+
const toolDescription = typeof meta?.["tool_description"] === "string"
150+
? meta["tool_description"]
151+
: null;
152+
const rawInput = this.tryToJsonValue(meta?.["tool_params"]) ?? null;
153+
154+
return {
155+
sessionId,
156+
toolCall: {
157+
toolCallId: this.buildMcpServerElicitationToolCallId(params),
158+
title: params.message !== "" ? params.message : "MCP permission request",
159+
kind: "other",
160+
status: "pending",
161+
content: this.createTextContents(toolDescription),
162+
rawInput,
163+
},
164+
options: this.buildMcpServerElicitationOptions(persistOptions),
165+
};
166+
}
167+
168+
private buildMcpServerElicitationToolCallId(
169+
params: McpServerElicitationRequestParams
170+
): string {
171+
return `mcp-elicitation:${params.serverName}:${params.turnId ?? params.threadId}:${params.mode}`;
172+
}
173+
174+
private buildMcpServerElicitationOptions(
175+
persistOptions: Array<ElicitationPersistOption>
176+
): Array<acp.PermissionOption> {
177+
const options: Array<acp.PermissionOption> = [
178+
{ optionId: ELICITATION_ALLOW_ONCE_OPTION_ID, name: "Allow Once", kind: "allow_once" },
179+
]
180+
181+
if (persistOptions.includes("session")) {
182+
options.push({
183+
optionId: ELICITATION_ALLOW_SESSION_OPTION_ID,
184+
name: "Allow for Session",
185+
kind: "allow_always",
186+
})
187+
}
188+
189+
if (persistOptions.includes("always")) {
190+
options.push({
191+
optionId: ELICITATION_ALLOW_ALWAYS_OPTION_ID,
192+
name: "Always Allow",
193+
kind: "allow_always",
194+
})
195+
}
196+
197+
options.push({
198+
optionId: ELICITATION_DENY_ONCE_OPTION_ID,
199+
name: "Deny Once",
200+
kind: "reject_once",
201+
})
202+
203+
return options
204+
}
205+
206+
private asRecord(value: unknown): Record<string, unknown> | null {
207+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
208+
return null
209+
}
210+
return value as Record<string, unknown>
211+
}
212+
213+
private tryToJsonValue(value: unknown): JsonValue | undefined {
214+
if (
215+
value === null ||
216+
typeof value === "string" ||
217+
typeof value === "number" ||
218+
typeof value === "boolean"
219+
) {
220+
return value
221+
}
222+
223+
if (Array.isArray(value)) {
224+
const convertedValues: Array<JsonValue> = []
225+
for (const item of value) {
226+
const convertedItem = this.tryToJsonValue(item)
227+
if (convertedItem === undefined) {
228+
return undefined
229+
}
230+
convertedValues.push(convertedItem)
231+
}
232+
return convertedValues
233+
}
234+
235+
if (typeof value === "object") {
236+
const record = value as Record<string, unknown>
237+
const jsonObject: JsonObject = {}
238+
for (const [key, nestedValue] of Object.entries(record)) {
239+
const convertedValue = this.tryToJsonValue(nestedValue)
240+
if (convertedValue === undefined) {
241+
return undefined
242+
}
243+
jsonObject[key] = convertedValue
244+
}
245+
return jsonObject
246+
}
247+
248+
return undefined
249+
}
250+
108251
private convertCommandResponse(
109252
response: acp.RequestPermissionResponse
110253
): CommandExecutionRequestApprovalResponse {
@@ -138,4 +281,42 @@ export class CodexApprovalHandler implements ApprovalHandler {
138281
return { decision: "cancel" };
139282
}
140283
}
284+
285+
private convertMcpServerElicitationResponse(
286+
params: McpServerElicitationRequestParams,
287+
response: acp.RequestPermissionResponse
288+
): McpServerElicitationRequestResponse {
289+
if (response.outcome.outcome === "cancelled") {
290+
return this.createCancelledMcpServerElicitationResponse();
291+
}
292+
293+
switch (response.outcome.optionId) {
294+
case ELICITATION_ALLOW_ONCE_OPTION_ID:
295+
return this.createAcceptedMcpServerElicitationResponse(params, null);
296+
case ELICITATION_ALLOW_SESSION_OPTION_ID:
297+
return this.createAcceptedMcpServerElicitationResponse(params, { persist: "session" });
298+
case ELICITATION_ALLOW_ALWAYS_OPTION_ID:
299+
return this.createAcceptedMcpServerElicitationResponse(params, { persist: "always" });
300+
case ELICITATION_DENY_ONCE_OPTION_ID:
301+
case "reject_once":
302+
return { action: "decline", content: null, _meta: null };
303+
default:
304+
return this.createCancelledMcpServerElicitationResponse();
305+
}
306+
}
307+
308+
private createAcceptedMcpServerElicitationResponse(
309+
params: McpServerElicitationRequestParams,
310+
meta: JsonObject | null
311+
): McpServerElicitationRequestResponse {
312+
return {
313+
action: "accept",
314+
content: params.mode === "form" ? {} : null,
315+
_meta: meta,
316+
}
317+
}
318+
319+
private createCancelledMcpServerElicitationResponse(): McpServerElicitationRequestResponse {
320+
return { action: "cancel", content: null, _meta: null }
321+
}
141322
}

0 commit comments

Comments
 (0)