Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
},
"metadata": {
"description": "Codex plugins to use in Claude Code for delegation and code review.",
"version": "1.0.45"
"version": "1.0.46"
},
"plugins": [
{
"name": "codex",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"version": "1.0.45",
"version": "1.0.46",
"author": {
"name": "OpenAI"
},
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,24 @@ A turn that goes silent is interrupted rather than left to hang. Four budgets de

While several quick tools are in flight, the most patient one sets the window, since the watchdog is asking whether anything at all is happening on the turn. When one fires, the run fails with `failureClass: "stalled"`.

### MCP Servers

Threads this plugin starts inherit whatever MCP servers your `~/.codex/config.toml` configures; the plugin does not add or configure any of its own.

Those threads are non-interactive by construction — they run with `approvalPolicy: "never"` and there is nobody at the keyboard to answer a prompt — so **the plugin approves MCP tool calls on its own**. That is a real consequence worth knowing: an MCP server you have configured can be called, unattended, by any reviewer or delegated task this plugin runs, and an MCP tool is not confined by the thread's sandbox the way a shell command is.

To keep a particular server out of these threads, name it in `CODEX_DISABLED_MCP_SERVERS`:

| Variable | Default | What it does |
| --- | --- | --- |
| `CODEX_DISABLED_MCP_SERVERS` | *(unset)* | Comma-separated MCP server names to disable on threads this plugin starts. Your interactive `codex` sessions are unaffected. |

```bash
export CODEX_DISABLED_MCP_SERVERS=codegraph,some-other-server
```

Names must match `[A-Za-z0-9_-]+` — the plugin splices each one into a Codex config path, and a name needing quotes would make Codex read it as a new server definition and fail the thread outright. Anything else is skipped with a warning on stderr, leaving that server enabled.

### Moving The Work Over To Codex

Delegated tasks and any [stop gate](#enabling-review-gate) run can also be directly resumed inside Codex by running `codex resume` either with the specific session ID you received from running `/codex:result` or `/codex:status` or by selecting it from the list.
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openai/codex-plugin-cc",
"version": "1.0.45",
"version": "1.0.46",
"private": true,
"type": "module",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
Expand Down
2 changes: 1 addition & 1 deletion plugins/codex/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codex",
"version": "1.0.45",
"version": "1.0.46",
"description": "Use Codex from Claude Code to review code or delegate tasks.",
"author": {
"name": "OpenAI"
Expand Down
18 changes: 18 additions & 0 deletions plugins/codex/scripts/lib/app-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,24 @@ class AppServerClientBase {
}

handleServerRequest(message) {
if (message.method === "mcpServer/elicitation/request") {
// These threads use approvalPolicy "never" and have no interactive user to answer an
// approval gate; -32601 is interpreted by app-server as a user denial instead.
// Only the one shape we recognize — a form asking to run an MCP tool — is consented to.
// Anything else, including an unfamiliar approval kind or a url-mode flow, is declined:
// empty content is a meaningful answer to that form and to nothing else.
const isToolCallApproval =
message.params?.mode === "form" &&
message.params?._meta?.codex_approval_kind === "mcp_tool_call";
this.sendMessage({
id: message.id,
result: isToolCallApproval
? { action: "accept", content: {}, _meta: null }
: { action: "decline", content: null, _meta: null }
});
return;
}

this.sendMessage({
id: message.id,
error: buildJsonRpcError(-32601, `Unsupported server request: ${message.method}`)
Expand Down
45 changes: 37 additions & 8 deletions plugins/codex/scripts/lib/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -93,11 +93,15 @@ function buildThreadParams(cwd, options = {}) {
serviceName: SERVICE_NAME,
ephemeral: options.ephemeral ?? true
};
const config = { ...(options.config ?? {}) };
for (const name of resolveDisabledMcpServers()) {
config[`mcp_servers.${name}.enabled`] = false;
}
if (options.writableRoots?.length > 0) {
params.config = {
...(options.config ?? {}),
"sandbox_workspace_write.writable_roots": options.writableRoots
};
config["sandbox_workspace_write.writable_roots"] = options.writableRoots;
}
if (Object.keys(config).length > 0) {
params.config = config;
}
return params;
}
Expand All @@ -111,11 +115,15 @@ function buildResumeParams(threadId, cwd, options = {}) {
approvalPolicy: options.approvalPolicy ?? "never",
sandbox: options.sandbox ?? "read-only"
};
const config = { ...(options.config ?? {}) };
for (const name of resolveDisabledMcpServers()) {
config[`mcp_servers.${name}.enabled`] = false;
}
if (options.writableRoots?.length > 0) {
params.config = {
...(options.config ?? {}),
"sandbox_workspace_write.writable_roots": options.writableRoots
};
config["sandbox_workspace_write.writable_roots"] = options.writableRoots;
}
if (Object.keys(config).length > 0) {
params.config = config;
}
return params;
}
Expand Down Expand Up @@ -194,6 +202,27 @@ function resolveToolMaxInFlightMs(options = {}) {
return DEFAULT_TOOL_MAX_INFLIGHT_MS;
}

function resolveDisabledMcpServers() {
const seen = new Set();
const disabled = [];
for (const entry of (process.env.CODEX_DISABLED_MCP_SERVERS ?? "").split(",")) {
const name = entry.trim();
if (!name || seen.has(name)) {
continue;
}
seen.add(name);
// Only a bare TOML key can be spliced into a dotted config path. Quoting the name instead
// makes Codex read the segment as a new server table with no transport, which fails
// thread/start for the whole run rather than just leaving that server enabled.
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
process.stderr.write(`Skipping disabled MCP server "${name}": not a bare TOML key.\n`);
continue;
}
disabled.push(name);
}
return disabled;
}

function shorten(text, limit = 72) {
const normalized = String(text ?? "").trim().replace(/\s+/g, " ");
if (!normalized) {
Expand Down
202 changes: 202 additions & 0 deletions tests/fake-codex-fixture.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const readline = require("node:readline");
const STATE_PATH = ${JSON.stringify(statePath)};
const BEHAVIOR = ${JSON.stringify(behavior)};
const interruptibleTurns = new Map();
const pendingServerRequests = new Map();
if (BEHAVIOR === "close-stalls") {
const keepAlive = setInterval(() => {}, 1000);
process.on("SIGTERM", () => {});
Expand Down Expand Up @@ -212,6 +213,83 @@ function send(message) {
process.stdout.write(JSON.stringify(message) + "\\n");
}

function requestServerReply(id, method, params, onReply) {
pendingServerRequests.set(id, onReply);
send({ id, method, params });
}

function emitMcpApprovalCompletion(state, threadId, turnId, reply) {
state.lastElicitationReply = reply;
saveState(state);
if (reply.error) {
send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } });
send({ method: "error", params: { threadId, turnId, error: { message: "user rejected MCP tool call" } } });
send({ method: "turn/completed", params: { threadId, turn: buildTurn(turnId, "failed") } });
return;
}

const toolOutput = "MCP tool output: codegraph_status completed.";
send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } });
send({
method: "item/started",
params: {
threadId,
turnId,
item: {
type: "mcpToolCall",
id: "mcp_" + turnId,
server: "codegraph",
tool: "codegraph_status",
status: "inProgress"
}
}
});
send({
method: "item/completed",
params: {
threadId,
turnId,
item: {
type: "mcpToolCall",
id: "mcp_" + turnId,
server: "codegraph",
tool: "codegraph_status",
status: "completed",
result: toolOutput
}
}
});
send({
method: "item/completed",
params: {
threadId,
turnId,
item: { type: "agentMessage", id: "msg_" + turnId, text: toolOutput, phase: "final_answer" }
}
});
send({ method: "turn/completed", params: { threadId, turn: buildTurn(turnId, "completed") } });
}

function emitMcpDeclineCompletion(state, threadId, turnId, reply) {
state.lastElicitationReply = reply;
saveState(state);
if (reply.error) {
send({ method: "turn/started", params: { threadId, turn: buildTurn(turnId) } });
send({ method: "error", params: { threadId, turnId, error: { message: "user rejected MCP tool call" } } });
send({ method: "turn/completed", params: { threadId, turn: buildTurn(turnId, "failed") } });
return;
}

emitTurnCompleted(threadId, turnId, {
completed: {
type: "agentMessage",
id: "msg_" + turnId,
text: "MCP elicitation declined.",
phase: "final_answer"
}
});
}

function nextThread(state, cwd, ephemeral) {
const thread = {
id: "thr_" + state.nextThreadId++,
Expand Down Expand Up @@ -400,6 +478,12 @@ rl.on("line", (line) => {

const message = JSON.parse(line);
const state = loadState();
const pendingReply = pendingServerRequests.get(message.id);
if (pendingReply) {
pendingServerRequests.delete(message.id);
pendingReply(message);
return;
}

try {
switch (message.method) {
Expand Down Expand Up @@ -721,6 +805,124 @@ rl.on("line", (line) => {
break;
}

if (BEHAVIOR === "mcp-elicitation-approval") {
requestServerReply(
"elicitation_" + turnId,
"mcpServer/elicitation/request",
{
threadId: thread.id,
turnId,
serverName: "codegraph",
mode: "form",
_meta: {
codex_approval_kind: "mcp_tool_call",
persist: ["session", "always"],
tool_description: "Index health check (files / nodes / edges). Skip unless debugging.",
tool_params: {},
tool_params_display: []
},
message: "Allow the codegraph MCP server to run tool \\"codegraph_status\\"?",
requestedSchema: { type: "object", properties: {} }
},
(reply) => emitMcpApprovalCompletion(state, thread.id, turnId, reply)
);
break;
}

if (BEHAVIOR === "mcp-elicitation-decline") {
requestServerReply(
"elicitation_" + turnId,
"mcpServer/elicitation/request",
{
threadId: thread.id,
turnId,
serverName: "codegraph",
mode: "form",
_meta: {},
message: "Please provide the requested codegraph filter.",
requestedSchema: { type: "object", properties: { filter: { type: "string" } } }
},
(reply) => emitMcpDeclineCompletion(state, thread.id, turnId, reply)
);
break;
}

if (BEHAVIOR === "mcp-elicitation-unknown-kind") {
requestServerReply(
"elicitation_" + turnId,
"mcpServer/elicitation/request",
{
threadId: thread.id,
turnId,
serverName: "codegraph",
mode: "form",
_meta: { codex_approval_kind: "some_future_kind" },
message: "Approve something this client has never heard of?",
requestedSchema: { type: "object", properties: {} }
},
(reply) => emitMcpDeclineCompletion(state, thread.id, turnId, reply)
);
break;
}

if (BEHAVIOR === "mcp-elicitation-null-kind") {
requestServerReply(
"elicitation_" + turnId,
"mcpServer/elicitation/request",
{
threadId: thread.id,
turnId,
serverName: "codegraph",
mode: "form",
_meta: { codex_approval_kind: null },
message: "Please provide the requested codegraph filter.",
requestedSchema: { type: "object", properties: { filter: { type: "string" } } }
},
(reply) => emitMcpDeclineCompletion(state, thread.id, turnId, reply)
);
break;
}

if (BEHAVIOR === "mcp-elicitation-url-mode") {
requestServerReply(
"elicitation_" + turnId,
"mcpServer/elicitation/request",
{
threadId: thread.id,
turnId,
serverName: "codegraph",
mode: "url",
_meta: { codex_approval_kind: "mcp_tool_call" },
message: "Finish signing in to codegraph.",
url: "https://example.invalid/oauth",
elicitationId: "elicit_" + turnId
},
(reply) => emitMcpDeclineCompletion(state, thread.id, turnId, reply)
);
break;
}

if (BEHAVIOR === "unknown-server-request") {
requestServerReply(
"unknown_" + turnId,
"unknown/server/request",
{ threadId: thread.id, turnId },
(reply) => {
state.lastUnknownServerRequestReply = reply;
saveState(state);
emitTurnCompleted(thread.id, turnId, {
completed: {
type: "agentMessage",
id: "msg_" + turnId,
text: "Unknown server request ignored.",
phase: "final_answer"
}
});
}
);
break;
}

const payload = message.params.outputSchema && message.params.outputSchema.properties && message.params.outputSchema.properties.verdict
? structuredReviewPayload(prompt)
: taskPayload(prompt, thread.name && thread.name.startsWith("Codex Companion Task") && prompt.includes("Continue from the current thread state"));
Expand Down
Loading