codex_mcp_emit¶
MCP server emission into Codex's .codex/config.toml (open-items remediation OPEN-6).
Verified against OpenAI's Codex MCP documentation (2026-08-10): servers live under
[mcp_servers.<id>] tables; stdio servers use command/args/env_vars; HTTP
servers use url/bearer_token_env_var.
Unlike mcp_emit's inert JSON sidecar (never auto-loaded by Claude
Code), .codex/config.toml is a real, live config Codex reads to launch MCP
servers. Writing an entry here is activation-adjacent, not documentation — so this
module uses the same stricter auto-wire bar as the Goose recipe extension path
(mcp-emit.md's Goose section): first-party, every tool read-only, no
security_review.required. Anything else is skipped and surfaced, never silently
activated.
Opt-in via --target-host-features codex:mcp.
Why Goose's bar, not Claude's — checked against Codex's own baseline¶
Goose's stricter bar is justified relative to a specific fact: every Goose agent
defaults to the developer builtin extension (unconditional local shell, always on).
Borrowing that threshold for Codex without checking Codex's own baseline would be an
unverified analogy. The 2026-08-10 premise that "suggest is the default approval
mode, every action requires approval" was withdrawn on 2026-09-29 after re-checking
the current docs (agent-approvals-security.md, extend/mcp.md):
- approval_policy is on-request, never or { granular = … }, and untrusted is retired.
- A version-controlled folder starts in the Auto preset (workspace-write +
on-request), where commands the sandbox allows run without approval.
- The default workspace-write sandbox still has no network and confines writes to the
workspace.
The emitter therefore restores a per-call prompt explicitly: every server it wires carries
default_tools_approval_mode = "prompt" (supported values: auto, prompt, writes,
approve). An operator may relax a single tool under [mcp_servers.<id>.tools.<tool>]
with approval_mode.
No comment-preserving TOML round-trip — text-level splice instead¶
.codex/config.toml is shared and multi-purpose (sandbox/profile settings live
alongside [mcp_servers.*]); it cannot be blind-overwritten. stdlib tomllib is
read-only and drops comments/formatting on parse, and no TOML-writing dependency is
declared (this project's one runtime dependency is jsonschema), so this module
never parses-and-reserializes the whole file. It splices at the text level:
locate and remove any existing [mcp_servers.*] table blocks by line-scanning the
raw text (bounded by a # --- BEGIN/END agentteams-managed mcp_servers --- marker
pair, or a bare [mcp_servers.*] header from a hand-authored/pre-marker file), then
append freshly-rendered tables. Everything else — comments, unrelated tables,
formatting — is intended to survive unchanged. A content-preservation check
(parse both the pre- and post-splice text with tomllib, compare every key
outside mcp_servers) verifies this before any write; a mismatch — e.g. a
hand-authored multi-line string whose content merely looks like a table
header confusing the line scan — refuses the write entirely rather than risk
silent data loss. A single-generation .bak copy of the pre-existing file is
also written before any splice. This mirrors the project's own house style for
surgical text replacement (fences's fenced-region merge and
yaml_frontmatter's line-anchored front-matter boundary scan) rather than a
general-purpose round-trip.
Re-running is idempotent: the managed block is replaced, not accumulated.
Mapping (mcp-server.schema.json → Codex TOML)¶
| mcp-server.schema field | Codex [mcp_servers.<id>] field |
|---|---|
transport: stdio + command + args |
command, args |
transport: http + auth.url |
url |
auth.mechanism: env + credential_ref (stdio) |
env_vars: [<credential_ref>] (forwards a named host env var — distinct from env, which sets literal values) |
auth.mechanism: env + credential_ref (http) |
bearer_token_env_var: <credential_ref> |
auth.mechanism values secret-store/oauth are not expressible in this field set
and are skipped + surfaced (Codex's own auth: oauth\|chatgpt concept is a built-in
flow Codex manages itself, not the same as this schema's abstract mechanism enum).
stdio servers need command; http servers need auth.url — absent either, the
server is skipped + surfaced, never silently dropped.
Public Surface¶
@dataclass
class CodexMCPEmissionResult:
written: list[str]
errors: list[str]
gated_off: bool
wired: list[str]
not_wired: dict[str, str] # server_id -> reason
dropped_unmanaged: list[str] # pre-existing hand-authored server ids this run replaced
success: bool # property: len(errors) == 0
codex:mcp host-feature token is active.
emit_codex_mcp_config(
*,
servers: list[dict],
features: list[str],
output_root: Path,
dry_run: bool = False,
) -> CodexMCPEmissionResult
output_root/.codex/config.toml. No-op
(gated_off=True) when codex:mcp is not active; no-op when servers is empty.
Non-conforming entries (schema validation, via mcp_emit._inert_problems)
are routed to result.errors. An existing file that fails to parse as TOML refuses
the splice entirely (result.errors, file untouched) rather than guessing.
Pipeline wiring¶
cli.artifacts._emit_codex_mcp_if_enabled mirrors _emit_mcp_servers_if_enabled's
gate/best-effort shape and is called alongside it from
cli.artifacts._emit_host_mcp_artifacts_if_enabled — the one call site
cli.generate's three build paths invoke, so adding a future host's MCP emitter
means touching artifacts.py once, not generate.py's call sites again. Fires
independently of which --framework a given generate call renders — like the
Claude sidecar, this is a host-scoped artifact (which local tools read MCP config
from), not a per-framework rendering output.
toml_write¶
The hand-rolled TOML table serializer this module renders through is documented
separately: toml_write.