Codex CLI does not interpolate ${CLAUDE_PLUGIN_ROOT} in plugin .mcp.json args, breaking imessage MCP
Summary
The imessage plugin from anthropics/claude-plugins-official ships a .mcp.json that references ${CLAUDE_PLUGIN_ROOT} — a placeholder Claude Code interpolates with the plugin install dir. Codex CLI passes the literal string through to the spawned process, so bun run --cwd ${CLAUDE_PLUGIN_ROOT} ... fails with ENOENT: Could not change directory to "${CLAUDE_PLUGIN_ROOT}".
Reproduction
codex plugin marketplace add claude-plugins-official https://github.com/anthropics/claude-plugins-official.git- Enable
imessage@claude-plugins-official - Launch Codex — startup log shows:
MCP client for \imessage\failed to start: MCP startup failed: handshaking with MCP server failed: connection closed: initialize response
\codex mcp list\ confirms the literal placeholder:
\\\\
imessage bun run --cwd \${CLAUDE_PLUGIN_ROOT} --shell=bun --silent start
\\
Expected
Codex CLI should interpolate \\${CLAUDE_PLUGIN_ROOT}\ (and likely \\${CLAUDE_PLUGIN_DATA}\, etc.) in \.mcp.json\ \args[]\ and \command\ per the Claude Code plugin spec, since plugins shared between marketplaces rely on this idiom.
Workaround
Register a custom MCP via \codex mcp add\ with the absolute install path; or edit cache \.mcp.json\ (reverts on plugin re-extract).
Affected versions
Codex CLI (current GA as of 2026-04-25), claude-plugins-official imessage 0.1.0.
2 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Another repro / data point —
mksglu/context-modeplugin hits the exact same bug on Codex CLI v0.130.0.After fixing the marketplace layout so the plugin actually shows up in
/plugin(context-mode#525), the MCP server still fails to start with the same error:Codex log shows the literal placeholder is passed through:
I probed what Codex actually passes to the spawned MCP process by replacing the server with
node -e "console.error('CWD='+process.cwd()); console.error('ENV='+Object.keys(process.env).join('|'))":| Mechanism | Codex v0.130.0 behavior |
|-----------|-------------------------|
|
cwdof spawned process | user's launch dir, NOT plugin root || Env vars | only 10 POSIX vars (PATH, HOME, USER, …). No
CODEX_*,CLAUDE_*,PLUGIN_*||
${VAR}inargs| passed literally (no shell expansion, no substitution) |So plugin-bundled MCP servers have no way at all to resolve their own location from inside the spawned process. Claude Code expands
${CLAUDE_PLUGIN_ROOT}, so the same.mcp.jsonworks there.Suggested fix (in priority order)
${CODEX_PLUGIN_ROOT}(and ideally also${CLAUDE_PLUGIN_ROOT}for cross-runtime plugins) in.mcp.jsoncommandandargs[]. Cheapest fix; preserves existing manifests.cwd = plugin_rootwhen launching bundled MCP servers. This alone would unblock most plugins (relative./start.mjswould just work). No spec change needed.CODEX_PLUGIN_ROOTas an env var so launcher scripts can self-resolve viaprocess.env.CODEX_PLUGIN_ROOT.Any one of these three would close the gap. (2) is probably the simplest semantic and matches what users expect from "bundled plugin component".
Current user-side workaround
Bypass the plugin manifest entirely by declaring the server in
~/.codex/config.toml:This works but defeats the whole point of plugin packaging — every user has to know the absolute install path, and upgrades break it.
Why this matters beyond imessage
Any non-trivial plugin that ships a real MCP server (not just
npx some-packagefrom PATH) is broken on Codex CLI today. context-mode, imessage, and likely many others. The plugin model effectively only works for plugins that publish their MCP as a globally-installed binary.Happy to test patches against either case.