Skip to content

MCP Integration

源码版本rust-v0.145.0

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

  1. McpConnectionManager holds multiple RmcpClients, providing aggregate APIs like list_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).
  2. RmcpClient supports three transports: new_in_process_client (extension in-process), new_stdio_client (child process), new_streamable_http_client (remote HTTP + OAuth), sharing the TransportRecipe abstraction (codex-rs/rmcp-client/src/rmcp_client.rs:326-389).
  3. The mcp-server crate wraps codex as an MCP server: stdin reads JSON-RPC, MessageProcessor::process_request dispatches InitializeRequest / ListToolsRequest / CallToolRequest and other standard MCP methods, internally using ThreadManager to start a codex session (codex-rs/mcp-server/src/lib.rs:59-172).
  4. The codex MCP tool-call entry run_codex_tool_session starts thread_manager.start_thread, submits the prompt as Op::UserInput, and then uses sub_id to correlate the MCP tools/call request with codex events (codex-rs/mcp-server/src/codex_tool_runner.rs:57-141).
  5. The McpCli subcommand (list / get / add / remove / login / logout) manages MCP server config in ~/.codex/config.toml; OAuth login goes through perform_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-203run_main spawns three tasks: stdin reader / processor / stdout writer; EOF triggers cascading shutdown.codex-rs/mcp-server/src/message_processor.rs:41-130MessageProcessor holds ThreadManager and dispatches standard MCP methods.codex-rs/mcp-server/src/codex_tool_config.rs:25-101CodexToolCallParam, 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-141run_codex_tool_session, starting a codex session and submitting the initial prompt.codex-rs/mcp-server/src/exec_approval.rs:20-48ExecApprovalElicitRequestParams, the elicitation params for exec approval.codex-rs/rmcp-client/src/rmcp_client.rs:326-389RmcpClient's three transport constructors.codex-rs/codex-mcp/src/connection_manager.rs:116-124McpConnectionManager 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-170McpServerRegistration and McpCatalogBuilder, handling config / plugin / extension source conflicts.codex-rs/codex-mcp/src/server.rs:15-94EffectiveMcpServer 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:

rust
// 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:

rust
// 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:

rust
// 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_tool checks client.tool_filter.allows(tool), returning an error directly without going into the client (codex-rs/codex-mcp/src/connection_manager.rs:855-860).
  • start_thread failure must error back: when thread_manager.start_thread errors in run_codex_tool_session, it sends CallToolResult::error and returns, never entering submit (codex-rs/mcp-server/src/codex_tool_runner.rs:65-78).
  • Missing required server fails validation: validate_required_servers runs at startup and errors directly if any are missing; but wait_for_server_ready gives 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's OAuthCredentialsStoreMode and AuthKeyringBackendKind let OAuth tokens go to keyring or file; delete_oauth_tokens is the entry for the logout subcommand (codex-rs/cli/src/mcp_cmd.rs:168-172).
  • McpServerConflictAction handles same-name conflicts: when both config and plugin register a server with the same name, precedence decides the winner; disable is 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.