codex exec silently auto-cancels MCP tool calls regardless of default_tools_approval_mode
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
- 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
- Add the enable block to
~/.codex/config.toml:
``toml``
[plugins."inspect-skills".mcp_servers."py-repl"]
enabled = true
default_tools_approval_mode = "approve"
- Confirm the MCP is registered:
``bash``
codex mcp list
# py-repl uvx ... enabled Unsupported
- 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
- Run the same prompt in interactive
codex(noexec): the MCP call goes through and returns4.
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
6 Comments
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 incodex exec, even though exec uses theNeverapproval policy. The valueapprovebecomesAppToolApproval::Approve, andmcp_permission_prompt_is_auto_approvedshort-circuits to approved as soon as it seesApprove, 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 thanapprovebeing handled wrong.Where that value is supposed to come from at call time is
codex-rs/core/src/mcp_tool_call.rsaround lines 169-183. The logic is roughly:default_tools_approval_mode)custom_mcp_tool_approval_mode, which re-reads configFor a plugin you installed with
codex plugin addand enabled normally,is_selected_plugin_mcp_serveris false (that flag is only for the thread/subagent "selected plugin" case), so you land in thecustom_mcp_tool_approval_modebranch. That function does read plugin MCP policy, and there is a unit test showing it returnsApprovefor a per-toolapproval_mode = "approve"and the server default otherwise (custom_mcp_tool_approval_mode_uses_plugin_mcp_policyinmcp_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:[plugins."inspect-skills".mcp_servers."py-repl"], but installed plugins are usually keyed with the marketplace too, likeinspect-skills@meridian. If the real key isinspect-skills@meridian, the block underinspect-skillsis just inert.py-repl, theget()misses.Could you share two things:
codex mcp listfor this server (the name as registered),[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_modefor 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.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:
Tests I would pin:
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.
@abhay-codes07 your hypothesis is correct. I tested both config shapes locally and the bare-
inspect-skillskey silently misses the lookup; the marketplace-suffixed key works.Repro setup
codex-cli 0.142.0meridianpointing at our checkout ofmeridianlabs-ai/inspect-skillscodex mcp listline:``
`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
py-repl` (no plugin prefix).The registered server name is the bare
Test prompt (same for both variants):
Variant A — bare plugin key (what our setup script writes today)
Relevant TOML:
Result:
Auto-cancelled despite no user input. Bug reproduced.
Variant B — marketplace-suffixed plugin key
Relevant TOML:
Result:
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_modesilently lands onAutowhenplugin.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:
config_lookup_misslog line at the moment of the fallback would have made this trivial to diagnose. Right now it presents asuser cancelled MCP tool callin 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.shto write the marketplace-suffixed key.For posterity / anyone landing here from search: the fix on our side is in meridianlabs-ai/inspect-skills#22. The
setup-codex.shscript now writes[plugins."inspect-skills@meridian".mcp_servers."py-repl"]instead of the bareinspect-skillskey. With that change, both interactivecodexandcodex execwork without--dangerously-bypass-approvals-and-sandbox.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 execreportsuser cancelled MCP tool callwhen nothing ever prompted the user.Root cause: exec has no interactive surface, so
request_user_inputreturnsNone, andparse_mcp_tool_approval_responsecollapses thatNoneintoCancel(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
Unavailabledecision for "approval required but not requestable", separate fromCancel(user was asked and declined). A missing response now resolves toUnavailable, 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 viadefault_tools_approval_modeor 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
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=nullin models.json (what the DeepSeek setup script generates), MCP tools are silently hidden from the model entirely in exec mode. After settingsupports_search_tool: falsethe 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).