Extending Codex with MCP servers
Registering MCP tool servers, scoping their permissions, discovering what they expose, and avoiding the context flooding that comes with too many tools.
Registering a server
# ~/.codex/config.toml
# Each server is a process the agent may start and call tools on.
# Confirm the exact key names against your installed version.
[mcp_servers.docs]
command = "npx"
args = ["-y", "@example/docs-server"]
env = { DOCS_INDEX_PATH = "/Users/me/docs-index" }
startup_timeout_sec = 20
[mcp_servers.repo_metrics]
command = "python"
args = ["-m", "tools.metrics_server"]
cwd = "/Users/me/metrics"
# a remote server over HTTP rather than a local process
[mcp_servers.issue_tracker]
url = "https://mcp.internal.example.com/tracker"
# authentication comes from the environment, never from this file# inspect what the agent can actually call, before trusting a task to it
codex --sandbox read-only "list every tool you have available, grouped by server, and stop"
# confirm which servers started and which failed
codex -c model_reasoning_effort="low" "report the status of each MCP server, then stop"- An MCP server is a capability grant, not a convenience. Adding one means every future run can call it, including runs you did not design with it in mind.
- Credentials belong in the environment, injected by the shell or the CI secret store. A token written into
config.tomlis a token committed to a dotfiles repository eventually. - Prefer stdio servers for local tools: no port to expose, no network surface, and the process lifetime is bounded by the session.
- A remote server is a new trust boundary. It can return data that shapes the agent's next action, so treat its output as untrusted input.
Scoping what a server may do
| Server type | Risk | Mitigation |
|---|---|---|
| Read-only filesystem search | Low | Point it at a specific directory, not the home folder |
| Issue tracker reads | Low | Read-only token |
| CI or deployment control | High | Require human approval; never in an unattended run |
| Database access | High | Read replica plus a restricted role |
| Web fetch | Medium | Prompt-injection surface; treat returned text as untrusted |
| Write access to a remote system | Very high | Separate credentials, separate environment, explicit approval |
# a minimal local MCP-style server: read-only, one directory, no network
import json
import sys
from pathlib import Path
ROOT = Path("/Users/me/project/docs").resolve()
def safe_path(relative: str) -> Path:
candidate = (ROOT / relative).resolve()
if not str(candidate).startswith(str(ROOT)):
raise ValueError("path escapes the served root")
return candidate
def search(query: str, limit: int = 10):
hits = []
for path in ROOT.rglob("*.md"):
text = path.read_text(encoding="utf-8", errors="ignore")
if query.lower() in text.lower():
hits.append({"path": str(path.relative_to(ROOT)),
"excerpt": text[:200]})
if len(hits) >= limit:
break
return hits
TOOLS = {"search": search}
def handle(request):
name = request.get("tool")
if name not in TOOLS:
return {"error": f"unknown tool: {name}"}
try:
return {"result": TOOLS[name](**request.get("arguments", {}))}
except Exception as exc:
return {"error": f"{type(exc).__name__}: {exc}"}
for line in sys.stdin:
request = json.loads(line)
print(json.dumps(handle(request)), flush=True)- Constrain every path against a resolved root. A tool that accepts a path from the model will eventually be handed
../../.ssh/id_rsa. - Return errors as values rather than crashing. A server that dies mid-session leaves the agent with a tool that appears to exist and never responds.
- Keep the tool surface small. Three precise tools are easier to reason about and harder to misuse than twenty overlapping ones.
- Server output enters the model's context. A response that contains instructions in prose is an injection attempt, whether it was written by a person or a database row.
Context cost and tool selection
- Every tool's name, description and parameter schema is part of the prompt. Twenty tools with verbose schemas can consume more context than the task itself.
- Long tool results crowd out the repository. Return a bounded number of results with short excerpts, and let the agent ask for more if it needs them.
- Enable servers per task and per profile rather than globally. Documentation search is valuable while writing docs and noise while refactoring billing code.
- A server that times out is worse than an absent one: the agent retries and the run stalls. Set a startup timeout and check that each server is reachable before relying on it.
# keep the base configuration clean and add servers per profile
[profiles.docs]
model_reasoning_effort = "low"
sandbox_mode = "read-only"
mcp_servers = ["docs"]
[profiles.implement]
model_reasoning_effort = "medium"
sandbox_mode = "workspace-write"
mcp_servers = ["docs", "repo_metrics"]
# the deployment server is deliberately absent from every profile⚠️
An MCP server is code you did not write running with the permissions you granted it, and its output reaches the model as trusted-looking text. Review a server before adding it the way you would review a dependency: read what it does, pin its version, and prefer one with no write access over one that needs it.
FAQ
Should I connect a deployment tool?
Not to an unattended run, and not with credentials that can reach production. Publish a plan, review it as a human, and let a separate pipeline apply it. The convenience is not worth converting an agent mistake into an outage.
How many MCP servers is too many?
When the tool descriptions dominate the context or the agent starts choosing the wrong tool. Prune to the servers a task actually needs, and prefer a small tool surface with clear names.
Related
Configuration with config.toml and profiles Security, cost and team practices
Last refreshed 2026-09-18.