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
63 changes: 59 additions & 4 deletions plugins/codex/scripts/lib/app-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,64 @@ const PLUGIN_MANIFEST = JSON.parse(fs.readFileSync(PLUGIN_MANIFEST_URL, "utf8"))
export const BROKER_ENDPOINT_ENV = "CODEX_COMPANION_APP_SERVER_ENDPOINT";
export const BROKER_BUSY_RPC_CODE = -32001;

/**
* Server-initiated request Codex uses to gate MCP tool calls. The same method
* also carries ordinary form and URL elicitations from configured MCP servers.
* See `ServerRequest` in `codex-rs/app-server-protocol`.
*/
export const MCP_ELICITATION_REQUEST_METHOD = "mcpServer/elicitation/request";

/**
* Discriminator that marks a privileged Codex approval rather than a plain MCP
* elicitation. See `APPROVAL_KIND_KEY` / `APPROVAL_KIND_MCP_TOOL_CALL` in
* `codex-rs/protocol/src/mcp_approval_meta.rs`.
*/
const APPROVAL_KIND_KEY = "codex_approval_kind";
const APPROVAL_KIND_MCP_TOOL_CALL = "mcp_tool_call";

/** @param {unknown} meta */
function isMcpToolCallApproval(meta) {
return (
typeof meta === "object" &&
meta !== null &&
meta[APPROVAL_KIND_KEY] === APPROVAL_KIND_MCP_TOOL_CALL
);
}

/**
* Build the reply to a server-initiated request.
*
* Codex gates MCP tool calls behind an MCP elicitation rather than the
* exec/patch approval channel, so the `approvalPolicy: "never"` that every
* thread started by this client already declares does not cover them.
* Replying with an error leaves the elicitation unresolved, and core maps that
* to `ReviewDecision::Abort`, surfaced as "user rejected MCP tool call". Runs
* driven by this client are headless, so no one can accept interactively and
* every MCP tool call would fail. Accept those explicitly.
*
* The same method also delivers ordinary form and URL elicitations from
* configured MCP servers. A headless client cannot render a form or complete a
* URL flow, so accepting one with empty content would hand the server bogus
* consent or invalid input. Decline those instead, which is a valid protocol
* answer and lets the server fail cleanly.
*
* @param {{ id: unknown, method?: string, params?: { _meta?: unknown } }} message
*/
export function buildServerRequestResponse(message) {
if (message.method === MCP_ELICITATION_REQUEST_METHOD) {
const action = isMcpToolCallApproval(message.params?._meta) ? "accept" : "decline";
return {
id: message.id,
result: { action, content: null, _meta: null }
};
}

return {
id: message.id,
error: buildJsonRpcError(-32601, `Unsupported server request: ${message.method}`)
};
}

/** @type {ClientInfo} */
const DEFAULT_CLIENT_INFO = {
title: "Codex Plugin",
Expand Down Expand Up @@ -154,10 +212,7 @@ class AppServerClientBase {
}

handleServerRequest(message) {
this.sendMessage({
id: message.id,
error: buildJsonRpcError(-32601, `Unsupported server request: ${message.method}`)
});
this.sendMessage(buildServerRequestResponse(message));
}

handleExit(error) {
Expand Down
106 changes: 106 additions & 0 deletions tests/app-server.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import test from "node:test";
import assert from "node:assert/strict";

import {
MCP_ELICITATION_REQUEST_METHOD,
buildServerRequestResponse
} from "../plugins/codex/scripts/lib/app-server.mjs";

test("accepts MCP tool-call approvals so headless MCP tool calls are not denied", () => {
const response = buildServerRequestResponse({
id: 12,
method: MCP_ELICITATION_REQUEST_METHOD,
params: {
threadId: "thread-1",
turnId: "turn-1",
serverName: "example",
mode: "form",
message: "Allow this tool call?",
requestedSchema: { type: "object", properties: {} },
_meta: { codex_approval_kind: "mcp_tool_call" }
}
});

assert.deepEqual(response, {
id: 12,
result: { action: "accept", content: null, _meta: null }
});
});

test("declines plain form elicitations this headless client cannot fill in", () => {
const response = buildServerRequestResponse({
id: 13,
method: MCP_ELICITATION_REQUEST_METHOD,
params: {
threadId: "thread-1",
turnId: "turn-1",
serverName: "example",
mode: "form",
message: "Enter your project name",
requestedSchema: {
type: "object",
properties: { project: { type: "string" } },
required: ["project"]
},
_meta: null
}
});

assert.equal(response.result.action, "decline");
assert.equal(response.result.content, null);
});

test("declines URL elicitations, e.g. an auth flow nobody can complete", () => {
const response = buildServerRequestResponse({
id: 14,
method: MCP_ELICITATION_REQUEST_METHOD,
params: {
threadId: "thread-1",
serverName: "example",
mode: "url",
message: "Authorize this connector",
url: "https://example.com/oauth",
elicitationId: "elicit-1"
}
});

assert.equal(response.result.action, "decline");
});

test("declines approvals of a kind this client does not implement", () => {
const response = buildServerRequestResponse({
id: 15,
method: MCP_ELICITATION_REQUEST_METHOD,
params: {
serverName: "example",
mode: "form",
message: "Add this tool?",
_meta: { codex_approval_kind: "tool_suggestion" }
}
});

assert.equal(response.result.action, "decline");
});

test("still rejects server requests the client does not implement", () => {
const response = buildServerRequestResponse({
id: "req-7",
method: "item/tool/requestUserInput"
});

assert.equal(response.id, "req-7");
assert.equal(response.result, undefined);
assert.equal(response.error.code, -32601);
assert.match(response.error.message, /item\/tool\/requestUserInput/);
});

test("preserves the request id type for string ids", () => {
const response = buildServerRequestResponse({
id: "elicitation-42",
method: MCP_ELICITATION_REQUEST_METHOD,
params: { _meta: { codex_approval_kind: "mcp_tool_call" } }
});

assert.equal(response.id, "elicitation-42");
assert.equal(response.result.action, "accept");
});