MCP Integration
codex's MCP (Model Context Protocol) integration works both ways: internally, it connects to user-configured MCP servers (stdio / streamable HTTP / in-process) via rmcp-client, exposing their tools, resources, and prompts to the model; externally, the mcp-server crate wraps codex itself as an MCP server so other MCP hosts (like Claude Desktop) can call codex as a tool. codex-mcp is the runtime in between, managing connection lifecycle, tool catalog, and elicitation routing.
Responsibilities
McpConnectionManagerholds multipleRmcpClients, providing aggregate APIs likelist_all_tools/call_tool/list_all_resources; tool filter, approval mode, and tool timeout are all managed here (codex-rs/codex-mcp/src/connection_manager.rs:116-124).RmcpClientsupports three transports:new_in_process_client(extension in-process),new_stdio_client(child process),new_streamable_http_client(remote HTTP + OAuth), sharing theTransportRecipeabstraction (codex-rs/rmcp-client/src/rmcp_client.rs:326-389).- The
mcp-servercrate wraps codex as an MCP server: stdin reads JSON-RPC,MessageProcessor::process_requestdispatchesInitializeRequest/ListToolsRequest/CallToolRequestand other standard MCP methods, internally usingThreadManagerto start a codex session (codex-rs/mcp-server/src/lib.rs:59-172). - The
codexMCP tool-call entryrun_codex_tool_sessionstartsthread_manager.start_thread, submits the prompt asOp::UserInput, and then usessub_idto correlate the MCPtools/callrequest with codex events (codex-rs/mcp-server/src/codex_tool_runner.rs:57-141). - The
McpClisubcommand (list/get/add/remove/login/logout) manages MCP server config in~/.codex/config.toml; OAuth login goes throughperform_oauth_login(codex-rs/cli/src/mcp_cmd.rs:45-61).
Design motivation
codex splits MCP into two crates rather than one, because server and client are completely different roles: client (rmcp-client + codex-mcp) runs inside the codex process, exposing external MCP server tools to the model; server (mcp-server) exposes codex itself to other MCP hosts. Both share the rmcp SDK but their runtimes are independent; this way the server crate can be compiled independently, and the client crate is not polluted by the server's ThreadManager startup logic.
The TransportRecipe abstraction exists because stdio / streamable HTTP / in-process have completely different connection recovery and lifecycle management: a stdio child process crash needs a restart, a broken HTTP connection needs reconnect + OAuth retry, in-process has no transport layer. Storing transport info in a recipe lets RmcpClient rebuild the transport by recipe after a disconnect.
McpServerMetadata's supports_parallel_tool_calls / default_tools_approval_mode / tool_approval_modes are per-server config read from config — some MCP servers don't support concurrent calls (many codex apps), and some tools need separate approval. tool_approval_mode(tool_name) prefers the tool name, falls back to the server default, then the global default.
The codex MCP tool's approval flow uses elicitation: when codex internally needs to exec a command or apply a patch, it does not execute directly; it sends elicitation/create to the MCP host (e.g. the Claude Desktop UI) to pop a confirmation box. ExecApprovalElicitRequestParams carries codex_command / codex_cwd / codex_parsed_cmd; the client uses these fields to render the confirmation UI.
McpCatalogBuilder uses RegistrationPrecedence to resolve same-name conflicts: config > plugin > compatibility > extension; the latter can only fill gaps the former leaves. McpServerConflictAction decides whether to disable or override on conflict.
Key files
codex-rs/mcp-server/src/lib.rs:59-203 — run_main spawns three tasks: stdin reader / processor / stdout writer; EOF triggers cascading shutdown.codex-rs/mcp-server/src/message_processor.rs:41-130 — MessageProcessor holds ThreadManager and dispatches standard MCP methods.codex-rs/mcp-server/src/codex_tool_config.rs:25-101 — CodexToolCallParam, the configurable fields (prompt / model / cwd / approval / sandbox / config) when a client invokes the codex tool.codex-rs/mcp-server/src/codex_tool_runner.rs:57-141 — run_codex_tool_session, starting a codex session and submitting the initial prompt.codex-rs/mcp-server/src/exec_approval.rs:20-48 — ExecApprovalElicitRequestParams, the elicitation params for exec approval.codex-rs/rmcp-client/src/rmcp_client.rs:326-389 — RmcpClient's three transport constructors.codex-rs/codex-mcp/src/connection_manager.rs:116-124 — McpConnectionManager fields, holding clients / metadata / required_servers / elicitation_requests.codex-rs/codex-mcp/src/connection_manager.rs:848-883 — the call_tool impl; the tool_filter rejects disabled tools.codex-rs/codex-mcp/src/catalog.rs:79-170 — McpServerRegistration and McpCatalogBuilder, handling config / plugin / extension source conflicts.codex-rs/codex-mcp/src/server.rs:15-94 — EffectiveMcpServer and McpServerMetadata, runtime-attached launch policy and per-tool approval.codex-rs/cli/src/mcp_cmd.rs:45-105 — the McpCli subcommand and AddMcpTransportArgs.The three tasks of mcp-server are explicitly separated into stdin / processor / stdout, a typical line-delimited JSON-RPC server. EOF triggers incoming_tx drop, the processor receives None and exits, and the stdout writer follows:
// mcp-server/src/lib.rs:148-172 — processor 主循环
let processor_handle = tokio::spawn({
let mut processor = MessageProcessor::new(/* ... */).await;
async move {
while let Some(msg) = incoming_rx.recv().await {
match msg {
JsonRpcMessage::Request(r) => processor.process_request(r).await,
JsonRpcMessage::Response(r) => processor.process_response(r).await,
JsonRpcMessage::Notification(n) => processor.process_notification(n).await,
JsonRpcMessage::Error(e) => processor.process_error(e),
}
}
info!("processor task exited (channel closed)");
}
});run_codex_tool_session uses the MCP request id as the codex sub_id, so subsequent codex events (ExecApproval, PatchApproval) can be correlated back to the original tools/call request. The failure paths (start_thread failure, submit failure) all explicitly send CallToolResult::error:
// mcp-server/src/codex_tool_runner.rs:98-119 — sub_id 关联 + submit
let sub_id = id.to_string();
running_requests_id_to_codex_uuid
.lock()
.await
.insert(id.clone(), thread_id);
let submission = Submission {
id: sub_id.clone(),
op: Op::UserInput {
items: vec![UserInput::Text {
text: initial_prompt.clone(),
text_elements: Vec::new(),
}],
// ...
},
// ...
};
if let Err(e) = thread.submit_with_id(submission).await {
tracing::error!("Failed to submit initial prompt: {e}");
let result = create_call_tool_result_with_thread_id(
thread_id, format!("Failed to submit initial prompt: {e}"), Some(true),
);
outgoing.send_response(id.clone(), result).await;
running_requests_id_to_codex_uuid.lock().await.remove(&id);
return;
}Before routing to a specific client, call_tool passes through tool_filter.allows(tool); disabled tools error out directly. tool_timeout is per-server config, preventing a slow MCP server from stalling the whole turn:
// codex-mcp/src/connection_manager.rs:848-866 — call_tool 路由
pub async fn call_tool(&self, server: &str, tool: &str, arguments: Option<serde_json::Value>, meta: Option<serde_json::Value>) -> Result<CallToolResult> {
let client = self.client_by_name(server).await?;
if !client.tool_filter.allows(tool) {
return Err(anyhow!("tool '{tool}' is disabled for MCP server '{server}'"));
}
let result: rmcp::model::CallToolResult = client
.client
.call_tool(tool.to_string(), arguments, meta, client.tool_timeout)
.await
.with_context(|| format!("tool call failed for `{server}/{tool}`"))?;
// ...
}Data flow
Boundaries and failures
- Tool filter rejects disabled tools: the first line of
call_toolchecksclient.tool_filter.allows(tool), returning an error directly without going into the client (codex-rs/codex-mcp/src/connection_manager.rs:855-860). start_threadfailure must error back: whenthread_manager.start_threaderrors inrun_codex_tool_session, it sendsCallToolResult::errorand returns, never entering submit (codex-rs/mcp-server/src/codex_tool_runner.rs:65-78).- Missing required server fails validation:
validate_required_serversruns at startup and errors directly if any are missing; butwait_for_server_readygives a timeout buffer, so a slow-starting server isn't immediately declared dead (codex-rs/codex-mcp/src/connection_manager.rs:399-485). - OAuth credentials are stored separately:
rmcp-client'sOAuthCredentialsStoreModeandAuthKeyringBackendKindlet OAuth tokens go to keyring or file;delete_oauth_tokensis the entry for thelogoutsubcommand (codex-rs/cli/src/mcp_cmd.rs:168-172). McpServerConflictActionhandles same-name conflicts: when both config and plugin register a server with the same name, precedence decides the winner;disableis the soft-conflict handling mode (codex-rs/codex-mcp/src/catalog.rs:170-184).
Summary
MCP integration has two sides: rmcp-client + codex-mcp aggregate external MCP server tools for codex's internal use; mcp-server exposes codex itself as an MCP server for external hosts. Both sides share the rmcp SDK as protocol base, but their runtimes and lifecycles are independent. McpConnectionManager is the core of the client side; run_codex_tool_session is the tool-call entry on the server side. To see how a codex session runs the model loop after being started, continue with Agent main loop; to see how MCP servers hang under the app-server process, see App-server architecture.