codex exec silently auto-cancels MCP tool calls regardless of default_tools_approval_mode

Open 💬 6 comments Opened Jun 24, 2026 by alexandraabbas

When invoking codex exec (non-interactive), MCP tool calls are auto-cancelled with the message user cancelled MCP tool call, even when default_tools_approval_mode = "approve" is set in ~/.codex/config.toml for that MCP. Interactive codex honors the same config and runs the same MCP without issue.

This is surprising because (a) there is no "user" to cancel in non-interactive mode, and (b) the docs imply default_tools_approval_mode controls this exact case.

Reproduce

  1. Install a plugin with a bundled MCP server (or configure any user-side MCP). Example using an existing plugin:

``bash
codex plugin marketplace add meridianlabs-ai/inspect-skills
codex plugin add inspect-skills@meridian
``

  1. Add the enable block to ~/.codex/config.toml:

``toml
[plugins."inspect-skills".mcp_servers."py-repl"]
enabled = true
default_tools_approval_mode = "approve"
``

  1. Confirm the MCP is registered:

``bash
codex mcp list
# py-repl uvx ... enabled Unsupported
``

  1. Call it via codex exec:

``bash
codex exec --sandbox read-only "Use the py-repl MCP. Call execute_python with code='print(2+2)'."
``

Output:
``
mcp: py-repl/execute_python started
mcp: py-repl/execute_python (failed)
user cancelled MCP tool call
``

  1. Run the same prompt in interactive codex (no exec): the MCP call goes through and returns 4.

I verified all three accepted values of default_tools_approval_mode (auto, prompt, approve) — all produce the same user cancelled MCP tool call in codex exec. Dropping --sandbox read-only also doesn't help (the cancellation is not from the sandbox layer).

Expected

codex exec should honor default_tools_approval_mode = "approve" and auto-approve the call, or provide a narrower --auto-approve-mcp style flag for non-interactive use.

Current workaround

codex exec --dangerously-bypass-approvals-and-sandbox lets MCP calls through. But this also disables the sandbox, which is a much bigger blast radius than just auto-approving an explicitly-approved MCP. For automation use cases (CI, scripted runs), a smaller knob would be a much better fit.

Environment

  • Codex v0.139.0 (installed via brew)
  • macOS 26.3.1 (aarch64)
  • Plugin sourced from a local marketplace; same behavior expected with git-based marketplaces

View original on GitHub ↗

6 Comments

abhay-codes07 · 2 months ago

I spent some time tracing this through the code and I think I can narrow down where it goes wrong, though there is one thing I would need you to confirm.

First the good news: default_tools_approval_mode = "approve" really should auto-run the call in codex exec, even though exec uses the Never approval policy. The value approve becomes AppToolApproval::Approve, and mcp_permission_prompt_is_auto_approved short-circuits to approved as soon as it sees Approve, before it ever tries to ask anyone (the first check in that function, codex-rs/codex-mcp/src/mcp/mod.rs). So if the approve setting were actually reaching the resolver, exec would not cancel. That also fits your observation that all three values behave the same in exec: it looks like the configured value is not being applied at all, rather than approve being handled wrong.

Where that value is supposed to come from at call time is codex-rs/core/src/mcp_tool_call.rs around lines 169-183. The logic is roughly:

  • apps server -> app policy
  • else if the server is a "selected plugin" server -> read the already-resolved metadata (which does carry default_tools_approval_mode)
  • else -> custom_mcp_tool_approval_mode, which re-reads config

For a plugin you installed with codex plugin add and enabled normally, is_selected_plugin_mcp_server is false (that flag is only for the thread/subagent "selected plugin" case), so you land in the custom_mcp_tool_approval_mode branch. That function does read plugin MCP policy, and there is a unit test showing it returns Approve for a per-tool approval_mode = "approve" and the server default otherwise (custom_mcp_tool_approval_mode_uses_plugin_mcp_policy in mcp_tool_call_tests.rs). So the fallback itself works, as long as the server name it looks up matches the key under [plugins."...".mcp_servers."..."].

That last part is where I think it breaks, and it is what I cannot confirm without your setup. The lookup is plugin.mcp_servers.get(server), keyed by the runtime server name. Two things would make that miss and silently fall back to the default (Auto), which then needs approval and gets cancelled in exec:

  • the plugin config key. You wrote [plugins."inspect-skills".mcp_servers."py-repl"], but installed plugins are usually keyed with the marketplace too, like inspect-skills@meridian. If the real key is inspect-skills@meridian, the block under inspect-skills is just inert.
  • the server name as actually registered. If it is namespaced/qualified rather than the bare py-repl, the get() misses.

Could you share two things:

  1. the exact line from codex mcp list for this server (the name as registered),
  2. your exact config keys (is it [plugins."inspect-skills"...] or [plugins."inspect-skills@meridian"...]?).

With those I can tell whether this is a config key/name mismatch (more of a docs/validation gap) or a real plumbing bug in the fallback lookup.

One thing I would suggest regardless of the above: the resolved server metadata already carries default_tools_approval_mode for every enabled server, including plugin servers, but the call-time code only consults it for "selected plugin" servers and re-reads config for everything else. Reading from that metadata for all servers would be more robust and would avoid this whole class of name-matching issues. Happy to put that together if it turns out to be the right direction.

rpelevin · 2 months ago

One invariant I would preserve here is that an approval-required MCP call should never collapse into a user-cancelled state unless the user actually cancelled it.

Even if the root cause is a plugin key mismatch, server-name lookup miss, or call-time plumbing bug, the observable contract should stay explicit:

  1. resolved approval mode is captured before dispatch;
  2. the config source and matched server id are recorded with the tool call;
  3. an unresolved config lookup has a visible reason code;
  4. noninteractive exec with no prompt surface returns approval_unavailable or deferred, not user_cancelled;
  5. approve means approved by resolved policy, not bypass all approvals and sandbox;
  6. prompt, approve, auto, explicit deny, config miss, runtime inability to ask, and actual user cancellation remain distinguishable terminal states.

Tests I would pin:

  1. plugin MCP server default mode approve in exec produces an allowed_by_config outcome;
  2. mismatched plugin config key fails validation or records config_lookup_miss;
  3. server-name mismatch records the runtime server id and looked-up config key;
  4. prompt mode in noninteractive exec returns approval_unavailable or deferred;
  5. explicit interactive denial remains denied_by_user;
  6. an actual cancellation remains user_cancelled;
  7. the sandboxed approved path does not require the broader bypass-approvals escape hatch.

The useful debugging artifact is a small decision record around the tool call: server id, tool name, config source, resolved approval mode, outcome, reason code, and whether a human prompt was possible. That would make docs mismatch, config-key mismatch, server-name mismatch, and runtime plumbing bug separable instead of all presenting as the same cancellation.

Boundary: architecture and test-shape feedback only; no claim about using this project or running its code.

alexandraabbas · 2 months ago

@abhay-codes07 your hypothesis is correct. I tested both config shapes locally and the bare-inspect-skills key silently misses the lookup; the marketplace-suffixed key works.

Repro setup

  • Codex codex-cli 0.142.0
  • Plugin installed from a local marketplace meridian pointing at our checkout of meridianlabs-ai/inspect-skills
  • codex mcp list line:

``
py-repl uvx --with inspect-ai==0.3.241 --with pandas==2.3.3 --with pyarrow==24.0.0 mcp-python==0.1.4 - - enabled Unsupported
`
The registered server name is the bare
py-repl` (no plugin prefix).

Test prompt (same for both variants):

codex exec --cd <trusted-dir> 'Call the py-repl execute_python MCP tool with code="print(2+2)". Return the exact value printed. Do not call any other tool.'

Variant A — bare plugin key (what our setup script writes today)

Relevant TOML:

[plugins."inspect-skills".mcp_servers."py-repl"]
enabled = true
default_tools_approval_mode = "approve"

[plugins."inspect-skills@meridian"]
enabled = true

Result:

mcp: py-repl/execute_python started
mcp: py-repl/execute_python (failed)
user cancelled MCP tool call

Auto-cancelled despite no user input. Bug reproduced.

Variant B — marketplace-suffixed plugin key

Relevant TOML:

[plugins."inspect-skills@meridian"]
enabled = true

[plugins."inspect-skills@meridian".mcp_servers."py-repl"]
enabled = true
default_tools_approval_mode = "approve"

Result:

mcp: py-repl/execute_python started
mcp: py-repl/execute_python (completed)
codex
4

Tool call auto-approved and completed. Bug does not reproduce.

Reading

The plumbing works; my config key was wrong, and the fallback into custom_mcp_tool_approval_mode silently lands on Auto when plugin.mcp_servers.get(server) misses, which then needs approval and gets cancelled in exec. So this isn't a Codex bug in the cancellation path itself, it's a docs / silent-miss gap.

Two suggestions following from this:

  1. The "selected plugin" branch you mentioned, or really any branch that reads from already-resolved server metadata, would have side-stepped the key mismatch entirely. The metadata knows what key the server came from. Reading from it for all plugin servers (not just the "selected plugin" case) would make the call-time lookup robust without depending on the user typing the key correctly.
  2. A config_lookup_miss log line at the moment of the fallback would have made this trivial to diagnose. Right now it presents as user cancelled MCP tool call in a non-interactive exec where no user was ever asked, which is the misleading symptom that drove the original report (and matches the invariant @rpelevin laid out).

I'll fix our setup-codex.sh to write the marketplace-suffixed key.

alexandraabbas · 2 months ago

For posterity / anyone landing here from search: the fix on our side is in meridianlabs-ai/inspect-skills#22. The setup-codex.sh script now writes [plugins."inspect-skills@meridian".mcp_servers."py-repl"] instead of the bare inspect-skills key. With that change, both interactive codex and codex exec work without --dangerously-bypass-approvals-and-sandbox.

abhay-codes07 · 1 month ago

Agreed the config-key miss is user-side, but there's a separate Codex-side defect worth fixing on its own: a non-interactive codex exec reports user cancelled MCP tool call when nothing ever prompted the user.

Root cause: exec has no interactive surface, so request_user_input returns None, and parse_mcp_tool_approval_response collapses that None into Cancel (core/src/mcp_tool_call.rs). "Couldn't ask" and "user said no" end up as the same terminal state, which is what made this hard to diagnose.

The fix keeps them distinct (in line with @rpelevin's invariant): a new Unavailable decision for "approval required but not requestable", separate from Cancel (user was asked and declined). A missing response now resolves to Unavailable, and the skipped call surfaces an actionable message instead of blaming the user: approval is required, this session can't prompt for it, pre-approve via default_tools_approval_mode or run interactively.

Scope: this doesn't auto-approve a mis-keyed config, it just stops non-interactive runs from reporting a cancellation that never happened.

A new unit test plus the existing approval suite pass (111 passed, 0 failed in codex-core), and rustfmt is clean under the pinned toolchain.

Branch: https://github.com/abhay-codes07/codex/tree/fix/mcp-approval-unavailable-noninteractive

PRs here are invite-only, so I've left it as a branch; can open one if that's useful.
cc @alexandraabbas

TunaTung · 27 days ago

Additional context from the DeepSeek-official-setup side (Windows, 0.146.0): the failure appears even earlier than approval — with supports_search_tool=true + tool_mode=null in models.json (what the DeepSeek setup script generates), MCP tools are silently hidden from the model entirely in exec mode. After setting supports_search_tool: false the tools are exposed, and with --dangerously-bypass-approvals-and-sandbox (still the only non-interactive approval bypass, per #24135) calls complete successfully. So this report sits on top of two stacked issues: the tool-surface hiding (models.json, workaround in #36382) and the missing non-interactive approval path (#24135).