Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/codex/app-server-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,24 @@ export class CodexAppServerClient {
}
}

if (isMcpToolApprovalElicitation(parsed, method)) {
if (responseId !== null) {
this.send({
id: parsed.id,
result: buildMcpToolApprovalAcceptance(parsed),
});
}
this.emit({
event: "approval_auto_approved",
sessionId: this.currentTurn?.sessionId ?? null,
threadId: this.currentTurn?.threadId ?? this.threadId,
turnId: this.currentTurn?.turnId ?? null,
raw: parsed,
...optionalTelemetry(this.lastUsage, this.lastRateLimits),
});
return;
}

if (isApprovalRequest(parsed, method)) {
if (responseId !== null) {
this.send({
Expand Down Expand Up @@ -889,6 +907,62 @@ function isApprovalRequest(
return containsStringValue(message, "approval");
}

function isMcpToolApprovalElicitation(
message: JsonObject,
method: string | null,
): boolean {
if (method !== "mcpServer/elicitation/request") {
return false;
}

return (
extractNestedString(message, ["params", "_meta", "codex_approval_kind"]) ===
"mcp_tool_call"
);
}

function buildMcpToolApprovalAcceptance(message: JsonObject): JsonObject {
const persist = pickMcpToolApprovalPersist(message);
return {
action: "accept",
content: {},
...(persist === null ? { _meta: null } : { _meta: { persist } }),
};
}

function pickMcpToolApprovalPersist(message: JsonObject): string | null {
let persist: unknown = message;
for (const segment of ["params", "_meta", "persist"]) {
if (
persist === null ||
typeof persist !== "object" ||
Array.isArray(persist)
) {
persist = null;
break;
}
persist = (persist as JsonObject)[segment];
}

if (persist === "always" || persist === "session") {
return persist;
}

if (Array.isArray(persist)) {
const values = persist.filter(
(value): value is string => typeof value === "string",
);
if (values.includes("always")) {
return "always";
}
if (values.includes("session")) {
return "session";
}
}

return null;
}

function isToolCallRequest(
message: JsonObject,
method: string | null,
Expand Down
35 changes: 35 additions & 0 deletions tests/codex/app-server-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,41 @@ describe("CodexAppServerClient", () => {
await client.close();
});

it("accepts MCP tool approval elicitation with the expected payload", async () => {
const workspace = await createWorkspace();
const events: CodexClientEvent[] = [];
const client = createClient("mcp-elicitation", workspace, events);

const result = await client.startSession({
prompt: "Handle MCP elicitation",
title: "ABC-123: Example",
});

expect(result).toMatchObject({
status: "completed",
message: "MCP elicitation handled",
usage: {
inputTokens: 15,
outputTokens: 10,
totalTokens: 25,
},
rateLimits: {
requestsRemaining: 10,
tokensRemaining: 1000,
},
});
expect(events).toContainEqual(
expect.objectContaining({
event: "approval_auto_approved",
raw: expect.objectContaining({
method: "mcpServer/elicitation/request",
}),
}),
);

await client.close();
});

it("fails the turn when user-input-required is emitted through a compatible variant", async () => {
const workspace = await createWorkspace();
const events: CodexClientEvent[] = [];
Expand Down
55 changes: 55 additions & 0 deletions tests/fixtures/codex-fake-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,24 @@ async function handleMessage(message) {
return;
}

if (scenario === "mcp-elicitation") {
setTimeout(() => {
writeJson({
id: "elicitation-1",
method: "mcpServer/elicitation/request",
params: {
serverName: "linear",
_meta: {
codex_approval_kind: "mcp_tool_call",
persist: ["session", "always"],
},
message: 'Allow the linear MCP server to run tool "save_comment"?',
},
});
}, 10);
return;
}

if (scenario === "user-input") {
setTimeout(() => {
writeJson({
Expand Down Expand Up @@ -262,6 +280,43 @@ async function handleMessage(message) {
return;
}

if (message.id === "elicitation-1") {
assertEqual(
message.result?.action,
"accept",
"MCP elicitation must be accepted with action=accept",
);
assertEqual(
typeof message.result?.content,
"object",
"MCP elicitation acceptance must include content object",
);
assertEqual(
message.result?._meta?.persist,
"always",
"MCP elicitation must prefer persist=always when present",
);

setTimeout(() => {
writeJson({
method: "turn/completed",
params: {
message: "MCP elicitation handled",
usage: {
inputTokens: 15,
outputTokens: 10,
totalTokens: 25,
},
rateLimits: {
requestsRemaining: 10,
tokensRemaining: 1000,
},
},
});
}, 10);
return;
}

if (message.id === "tool-1") {
if (scenario === "linear-tool") {
assertEqual(
Expand Down