mcpServers when you open a thread. Those servers are attached to this thread only.
{ type: "http" | "sse", url, headers? }{ type: "stdio", command, args?, env? }
sse, so a Codex request containing an SSE entry is rejected.
Server names computer_use and ade-cto are reserved, and a request may
contain at most 32 servers.
Supplying mcpServers turns on strict mode unless you set loadUserMcpServers: true. Strict means: withhold the user’s and project’s own MCP config, and try to load only the servers you supplied.
Both options apply on create only. Reopening an existing key re-applies the tool surface that key was created with, and the SDK logs one line when the values passed differ — so do not present “only your tools are loaded” on the strength of a loadUserMcpServers: false passed to a resume. See Threads.
The one exception is refresh.mcpServers, for a token or a URL that changed. See Credentials that change.
Honesty table
mcpCapability
Every thread that requested MCP reports what it actually got. null means one of two things: this thread never asked for MCP, or it asked and an older runtime omitted the report. The SDK logs the second case (requested MCP but the runtime reported no capability). Do not read null as “no MCP was requested” when you passed mcpServers or loadUserMcpServers: false — treat the guarantee as unverified.
On a delivery-only thread (
loadUserMcpServers: true) the user’s own MCP loads by design. strictRequested is false, residual is null, and level makes no isolation claim. Treating level === "enforced" as “only my tools” on that thread would be backwards.
Presence of the mcpCapability object is not a guarantee. Read strictRequested, then branch on level.
After setModel, the report is refreshed. A Claude thread switched to Codex will not keep advertising enforced.
Opt back in
false is not the same as omitting the field. Omitting both mcpServers and loadUserMcpServers lets the embedded session profile decide (SDK threads are strict by default).
A deny policy can hide a server you injected
Injecting a server and gating it are two decisions, and on Claude the second can undo the first. Underpermissions: { …, fallback: "deny" } the session reaches only the MCP servers the policy names, so a server passed in mcpServers and absent from the policy is unreachable. There is no error: the tools are simply never called, and the model reports that it could not do the thing.
permissionCapability.residual lists them. See Permissions.
Credentials that change
An MCP server usually authenticates with a bearer token inheaders.Authorization. The token changes on sign-out, on an account switch, or when you rotate it. The port in url can change when the old one is busy. A thread that keeps the old values fails MCP auth for good.
Header values are never stored
The SDK does not write MCP header values to disk. Its thread store keeps the header names only (headerNames). The runtime does the same in its own chat state. A thread store that an SDK before 0.3 wrote is migrated when the SDK first reads it: the values are removed, the names are kept, and the file is written again.
So each create, resume, recreate and restart needs the values again. They come from one of three places. The first that has them wins:
- Headers that you pass in this call (
refresh.mcpServers, ormcpServerson a create). - The client’s
mcpHeaderscallback. - Nothing. The server connects without its headers, and the SDK logs a line that names the server and the missing header names.
url with its query string (the runtime needs it to reconnect), and the env values of a stdio server. Do not put a secret in either. Put it in a header.
mcpHeaders
autoRestart. Return undefined for a server that needs no headers. The SDK sends the result to the runtime and does not store it.
On a resume, the SDK pushes the resolved servers to the runtime when the stored record names headers. It does this automatically when you pass mcpHeaders.
refresh.mcpServers
refresh.mcpServers replaces the thread’s servers wholesale. It is the one option that a resume does not ignore.
The stored record is updated each time, with header names only.
thread.updateMcpServers()
mcpCapability. The provider process restarts, and the next turn uses the new URL and headers. A remote server that you pass without headers gets them from mcpHeaders.
The same checks as a create apply: provider shape, reserved names, and at most 32 servers.
It throws invalid_option:
- While a turn is in flight. Wait for the turn to end, or call
interrupt(). - On a runtime that does not advertise
capabilities.personalChats.updateMcpServers(before 1.2.81). An older runtime would ignore the servers and keep the old credentials, which is worse than an error.
refresh or mcpHeaders keeps the old servers and logs a warning.
thread.updateMcpServers is not on the Electron bridge. Only main may change a thread’s servers.
After a runtime restart
The runtime does not keep header values either. After it restarts, a server whose headers it withheld connects without them. The transcript gets onesystem_notice that asks the host to send the credentials again. With mcpHeaders, the SDK sends them again for you: during autoRestart, and when you next open the key with a new client.
Redaction
The runtime removes secrets from MCP error and notice text before it writes them to the transcript or the log: the query string, fragment and user info of a URL,Bearer tokens, and the exact header values of the server. This covers Codex MCP startup failures, Codex stderr lines, app-server spawn errors and URL validation errors.
Resource links
A tool result can carry MCPresource_link items. The runtime gives them to you as structured data on the tool_result event:
The field is absent when the result has no links.
@ade-dev/chat-ui shows them on tool chips as row.resourceLinks. See Chat UI.
Approvals for MCP tools
On Codex, every MCP tool call raises an approval. A permission policy decides it, the same way as on Claude. See Permissions.Next
Permissions and approvals
Decide which of those tools run without asking, and answer the ones that do.
