Spec file
Codex MCP in config.toml: a self-check for stdio and HTTP server fields
Use the official field lists to tell stdio from HTTP servers, and a self-written lint to check mixed fields, effective tools and secret handling.
Codex stores MCP servers in [mcp_servers.<name>] tables in config.toml, and the CLI, IDE extension and desktop app share that configuration. The fields fall into two groups: stdio servers start a local process with command, while HTTP servers are reached with url. Mixing the groups in one table, or writing a static token into the file, are the easiest mistakes. This guide organizes the documented field groups and adds a self-written Python lint as a first pass before committing a configuration. It is not an official validator, Codex was not started and no MCP server was contacted.
- Verified
- · Not tested by us
- Applies to
- Codex Applies to the MCP configuration shared by the Codex CLI, IDE extension and desktop app. The page gives no unified version floor; fields follow the official page as of 2026-11-05.[1]
- Tools
File locations
| Path | Scope | How it is read |
|---|---|---|
~/.codex/config.toml | User-level MCP server configuration shared by the CLI, IDE extension and desktop app. | Codex reads the [mcp_servers.<name>] tables in it.[1] |
./.codex/config.toml | Project-scoped MCP server configuration, effective for trusted projects only. | Used together with the user configuration; keep it reviewable and free of secrets.[1] |
When to use
- You are writing or reviewing [mcp_servers.*] configuration and want to rule out mixed fields and plaintext secrets before connecting.[1]
When not to use
- You need to verify that a server connects, that OAuth login works or that tool output is trustworthy; those must be checked in a real environment.[1]
Two field groups
The docs split the fields into three blocks. A stdio server runs as a local process, a Streamable HTTP server is reached at an address, and the remaining options are shared. The required field differs between the two transports.[1]
| Group | Fields | Notes |
|---|---|---|
| stdio | command (required), args, env, env_vars, cwd | command starts the server; env_vars lists environment variable names to allow and forward |
| HTTP | url (required), auth, bearer_token_env_var, http_headers, env_http_headers, http_headers_helper | A bearer token is referenced by environment variable name; http_headers hold static values; the helper is for HTTP connections made from the local environment, not stdio |
| Shared | enabled, required, enabled_tools, disabled_tools, default_tools_approval_mode, tools.<tool>.approval_mode, startup_timeout_sec, tool_timeout_sec | disabled_tools applies after enabled_tools; the defaults are 10 seconds for startup and 60 seconds per tool call |
The documented values for default_tools_approval_mode are auto, prompt, writes and approve, and writes prompts for tools not marked read-only. On credentials: bearer_token_env_var holds only an environment variable name, and when a pre-registered OAuth client secret is configured through codex mcp add, Codex saves it as mcp_servers.<name>.oauth.client_secret in config.toml, which the docs say to keep private and out of version control.[1]
A self-written lint
The script reads a TOML file and checks each server: that exactly one of command and url is present, that fields from the other group are not mixed in, which tools remain after subtracting disabled_tools from enabled_tools, whether the approval mode is within the documented values, whether http_headers contains a header that looks like a static secret, and whether an OAuth client_secret is stored in the file. It needs the Python 3.11+ tomllib and is meant to run with python3 -I against the file path you pass in. The rules are my own reading of the official field lists, not official validation, and they do not cover every field.[1]
import sys
import tomllib
STDIO_ONLY = {"args", "env", "env_vars", "cwd"}
HTTP_ONLY = {"auth", "bearer_token_env_var", "http_headers", "env_http_headers", "http_headers_helper"}
MODES = {"auto", "prompt", "writes", "approve"}
with open(sys.argv[1], "rb") as handle:
config = tomllib.load(handle)
for name, server in config.get("mcp_servers", {}).items():
keys, notes = set(server), []
if ("command" in keys) == ("url" in keys):
notes.append("need exactly one of command / url")
if "url" in keys and keys & STDIO_ONLY:
notes.append("stdio-only keys on an HTTP server")
if "command" in keys and keys & HTTP_ONLY:
notes.append("HTTP-only keys on a stdio server")
enabled, disabled = server.get("enabled_tools"), set(server.get("disabled_tools", []))
if enabled is not None:
notes.append("effective tools: " + str([t for t in enabled if t not in disabled]))
if server.get("default_tools_approval_mode", "auto") not in MODES:
notes.append("unknown default_tools_approval_mode")
for header in server.get("http_headers", {}):
if any(word in header.lower() for word in ("authorization", "token", "key")):
notes.append(f"static secret-like header in file: {header}")
if "client_secret" in server.get("oauth", {}):
notes.append("oauth.client_secret is stored in this file")
print(name, "->", notes or ["no findings"])
In the sample below docs_search is an HTTP server with a bearer environment variable, local_files is a stdio server, and bad_mix deliberately puts both command and url in one table. The effective tools in the output come from enabled_tools minus disabled_tools. The sample is original and was not used to connect to any real service.[1]
[mcp_servers.docs_search]
url = "https://mcp.example.com/docs"
bearer_token_env_var = "DOCS_MCP_TOKEN"
enabled_tools = ["search", "fetch", "export"]
disabled_tools = ["export"]
default_tools_approval_mode = "writes"
tool_timeout_sec = 30
[mcp_servers.local_files]
command = "node"
args = ["./tools/files-server.mjs"]
env_vars = ["FILES_ROOT"]
startup_timeout_sec = 20
required = true
[mcp_servers.bad_mix]
url = "https://mcp.example.com/mix"
command = "node"
default_tools_approval_mode = "always"
http_headers = { "Authorization" = "Bearer demo-literal" }
docs_search -> ["effective tools: ['search', 'fetch']"]
local_files -> ['no findings']
bad_mix -> ['need exactly one of command / url', 'HTTP-only keys on a stdio server', 'unknown default_tools_approval_mode', 'static secret-like header in file: Authorization']
Gaps and stop conditions
- The docs do not say how Codex handles a table that sets both command and url. The lint flags it as a conflict only because each is the required field of a different transport in the field lists; the actual behavior is unverified.
- The lint checks header names (those containing authorization, token or key) and does not inspect plaintext values in env or decide whether a value really is a secret, so it can miss cases and flag false ones.
- The docs also cover OAuth callbacks, CIMD and DCR registration, plugin-provided MCP servers and remote execution environments. None of that is modeled in the lint; read the relevant official sections when you need them.
- Whether a server starts and whether its tool list matches expectations must be observed in a real environment, for example with codex mcp list or /mcp in the TUI. This guide did not run them.
Before real adoption, make sure the lint reports no "need exactly one of command / url" and no static-secret notes for your configuration, then confirm you trust the server source and review the tool list and approval mode. If any of that is unclear, set enabled to false instead of connecting with default approvals. Only the official field descriptions were checked here: Codex was not started, no MCP server was contacted, and neither server safety nor cost is certified.[1]
Sources
All links are official pages; verification dates are listed per source, and links point to the version checked.