Codex Handbook
core/src/tools/handlers/mcp.rs 542 lines
use std::sync::Arc;use std::time::Instant;use crate::function_tool::FunctionCallError;use crate::mcp_tool_call::handle_mcp_tool_call;use crate::original_image_detail::can_request_original_image_detail;use crate::tools::context::McpToolOutput;use crate::tools::context::ToolInvocation;use crate::tools::context::ToolPayload;use crate::tools::context::boxed_tool_output;use crate::tools::flat_tool_name;use crate::tools::hook_names::HookToolName;use crate::tools::registry::CoreToolRuntime;use crate::tools::registry::PostToolUsePayload;use crate::tools::registry::PreToolUsePayload;use crate::tools::registry::ToolExecutor;use crate::tools::registry::ToolTelemetryTags;use codex_mcp::ToolInfo;use codex_tools::ResponsesApiNamespace;use codex_tools::ResponsesApiNamespaceTool;use codex_tools::ToolName;use codex_tools::ToolSearchInfo;use codex_tools::ToolSearchSourceInfo;use codex_tools::ToolSpec;use codex_tools::mcp_tool_to_responses_api_tool;use serde_json::Map;use serde_json::Value;const LEGACY_MCP_TOOL_NAME_PREFIX: &str = "mcp__";const MCP_TOOL_NAME_DELIMITER: &str = "__";pub struct McpHandler {    tool_info: ToolInfo,    spec: ToolSpec,}impl McpHandler {    pub fn new(tool_info: ToolInfo) -> Result<Self, serde_json::Error> {        let spec = create_tool_spec(&tool_info)?;        Ok(Self { tool_info, spec })    }    fn hook_tool_name(&self) -> HookToolName {        HookToolName::new(ensure_mcp_prefix(&join_tool_name(&self.tool_name())))    }}fn join_tool_name(tool_name: &ToolName) -> String {    match tool_name.namespace.as_deref() {        Some(namespace) => {            let namespace = namespace.trim_end_matches('_');            let name = tool_name.name.trim_start_matches('_');            format!("{namespace}{MCP_TOOL_NAME_DELIMITER}{name}")        }        None => tool_name.name.clone(),    }}fn ensure_mcp_prefix(name: &str) -> String {    if name.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) {        name.to_string()    } else {        format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{name}")    }}impl ToolExecutor<ToolInvocation> for McpHandler {    fn tool_name(&self) -> ToolName {        self.tool_info.canonical_tool_name()    }    fn spec(&self) -> ToolSpec {        self.spec.clone()    }    fn supports_parallel_tool_calls(&self) -> bool {        // Correctly implemented MCP servers should tolerate parallel calls to        // tools that advertise themselves as read-only.        self.tool_info.supports_parallel_tool_calls            || self                .tool_info                .tool                .annotations                .as_ref()                .and_then(|annotations| annotations.read_only_hint)                .unwrap_or(false)    }    fn search_info(&self) -> Option<ToolSearchInfo> {        let source_name = self            .tool_info            .connector_name            .as_deref()            .map(str::trim)            .filter(|connector_name| !connector_name.is_empty())            .unwrap_or_else(|| self.tool_info.server_name.trim());        let source_info = (!source_name.is_empty()).then(|| ToolSearchSourceInfo {            name: source_name.to_string(),            description: self                .tool_info                .namespace_description                .as_deref()                .map(str::trim)                .filter(|description| !description.is_empty())                .map(str::to_string),        });        ToolSearchInfo::from_spec(            build_mcp_search_text(&self.tool_info),            self.spec(),            source_info,        )    }    fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {        Box::pin(self.handle_call(invocation))    }}impl McpHandler {    async fn handle_call(        &self,        invocation: ToolInvocation,    ) -> Result<Box<dyn crate::tools::context::ToolOutput>, FunctionCallError> {        let ToolInvocation {            session,            turn,            call_id,            payload,            ..        } = invocation;        let payload = match payload {            ToolPayload::Function { arguments } => arguments,            _ => {                return Err(FunctionCallError::RespondToModel(                    "mcp handler received unsupported payload".to_string(),                ));            }        };        let started = Instant::now();        let result = handle_mcp_tool_call(            Arc::clone(&session),            &turn,            call_id.clone(),            self.tool_info.server_name.clone(),            self.tool_info.tool.name.to_string(),            self.hook_tool_name(),            payload,        )        .await;        Ok(boxed_tool_output(McpToolOutput {            result: result.result,            tool_input: result.tool_input,            wall_time: started.elapsed(),            original_image_detail_supported: can_request_original_image_detail(&turn.model_info),            truncation_policy: turn.truncation_policy,        }))    }}impl CoreToolRuntime for McpHandler {    fn telemetry_tags<'a>(        &'a self,        _invocation: &'a ToolInvocation,    ) -> futures::future::BoxFuture<'a, ToolTelemetryTags> {        Box::pin(async {            let mut tags = vec![("mcp_server", self.tool_info.server_name.clone())];            if let Some(origin) = &self.tool_info.server_origin {                tags.push(("mcp_server_origin", origin.clone()));            }            tags        })    }    fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option<PreToolUsePayload> {        let ToolPayload::Function { arguments } = &invocation.payload else {            return None;        };        Some(PreToolUsePayload {            tool_name: self.hook_tool_name(),            tool_input: mcp_hook_tool_input(arguments),        })    }    fn with_updated_hook_input(        &self,        mut invocation: ToolInvocation,        updated_input: Value,    ) -> Result<ToolInvocation, FunctionCallError> {        invocation.payload = match invocation.payload {            ToolPayload::Function { .. } => ToolPayload::Function {                arguments: serde_json::to_string(&updated_input).map_err(|err| {                    FunctionCallError::RespondToModel(format!(                        "failed to serialize rewritten MCP arguments: {err}"                    ))                })?,            },            payload => {                return Err(FunctionCallError::RespondToModel(format!(                    "tool {} does not support hook input rewriting for payload {payload:?}",                    self.tool_name()                )));            }        };        Ok(invocation)    }    fn post_tool_use_payload(        &self,        invocation: &ToolInvocation,        result: &dyn crate::tools::context::ToolOutput,    ) -> Option<PostToolUsePayload> {        let ToolPayload::Function { .. } = &invocation.payload else {            return None;        };        let tool_response =            result.post_tool_use_response(&invocation.call_id, &invocation.payload)?;        Some(PostToolUsePayload {            tool_name: self.hook_tool_name(),            tool_use_id: invocation.call_id.clone(),            tool_input: result.post_tool_use_input(&invocation.payload)?,            tool_response,        })    }}fn create_tool_spec(tool_info: &ToolInfo) -> Result<ToolSpec, serde_json::Error> {    let tool_name = tool_info.canonical_tool_name();    let tool = mcp_tool_to_responses_api_tool(&tool_name, &tool_info.tool)?;    let description = tool_info        .namespace_description        .as_deref()        .map(str::trim)        .filter(|description| !description.is_empty())        .map(str::to_string)        .or_else(|| {            tool_info                .connector_name                .as_deref()                .map(str::trim)                .filter(|connector_name| !connector_name.is_empty())                .map(|connector_name| format!("Tools for working with {connector_name}."))        })        .unwrap_or_default();    Ok(ToolSpec::Namespace(ResponsesApiNamespace {        name: tool_info.callable_namespace.clone(),        description,        tools: vec![ResponsesApiNamespaceTool::Function(tool)],    }))}fn mcp_hook_tool_input(raw_arguments: &str) -> Value {    if raw_arguments.trim().is_empty() {        return Value::Object(Map::new());    }    serde_json::from_str(raw_arguments).unwrap_or_else(|_| Value::String(raw_arguments.to_string()))}fn build_mcp_search_text(info: &ToolInfo) -> String {    let tool_name = info.canonical_tool_name();    let mut schema_properties = info        .tool        .input_schema        .get("properties")        .and_then(serde_json::Value::as_object)        .map(|map| map.keys().cloned().collect::<Vec<_>>())        .unwrap_or_default();    schema_properties.sort();    let mut parts = vec![        flat_tool_name(&tool_name).into_owned(),        info.callable_name.clone(),        info.tool.name.to_string(),        info.server_name.clone(),    ];    if let Some(title) = info.tool.title.as_deref().map(str::trim)        && !title.is_empty()    {        parts.push(title.to_string());    }    if let Some(description) = info.tool.description.as_deref().map(str::trim)        && !description.is_empty()    {        parts.push(description.to_string());    }    if let Some(connector_name) = info.connector_name.as_deref().map(str::trim)        && !connector_name.is_empty()    {        parts.push(connector_name.to_string());    }    if let Some(namespace_description) = info.namespace_description.as_deref().map(str::trim)        && !namespace_description.is_empty()    {        parts.push(namespace_description.to_string());    }    parts.extend(        info.plugin_display_names            .iter()            .map(String::as_str)            .map(str::trim)            .filter(|display_name| !display_name.is_empty())            .map(str::to_string),    );    parts.extend(schema_properties);    parts.join(" ")}#[cfg(test)]#[path = "mcp_search_tests.rs"]mod search_tests;#[cfg(test)]mod tests {    use super::*;    use crate::session::tests::make_session_and_context;    use crate::tools::context::ToolCallSource;    use crate::tools::hook_names::HookToolName;    use crate::tools::registry::PostToolUsePayload;    use crate::tools::registry::PreToolUsePayload;    use crate::turn_diff_tracker::TurnDiffTracker;    use pretty_assertions::assert_eq;    use serde_json::json;    use std::time::Duration;    use tokio::sync::Mutex;    #[tokio::test]    async fn mcp_pre_tool_use_payload_uses_prefixed_tool_name_and_raw_args() {        let payload = ToolPayload::Function {            arguments: json!({                "entities": [{                    "name": "Ada",                    "entityType": "person"                }]            })            .to_string(),        };        let (session, turn) = make_session_and_context().await;        let handler = McpHandler::new(tool_info("memory", "memory", "create_entities"))            .expect("MCP tool spec should build");        assert_eq!(            handler.pre_tool_use_payload(&ToolInvocation {                session: session.into(),                turn: turn.into(),                cancellation_token: tokio_util::sync::CancellationToken::new(),                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),                call_id: "call-mcp-pre".to_string(),                tool_name: codex_tools::ToolName::namespaced("memory", "create_entities"),                source: ToolCallSource::Direct,                payload,            }),            Some(PreToolUsePayload {                tool_name: HookToolName::new("mcp__memory__create_entities"),                tool_input: json!({                    "entities": [{                        "name": "Ada",                        "entityType": "person"                    }]                }),            })        );    }    #[tokio::test]    async fn mcp_pre_tool_use_payload_keeps_builtin_like_tool_names_namespaced() {        let payload = ToolPayload::Function {            arguments: json!({ "message": "hello" }).to_string(),        };        let (session, turn) = make_session_and_context().await;        let handler = McpHandler::new(tool_info("foo", "mcp__foo", "exec_command"))            .expect("MCP tool spec should build");        assert_eq!(            handler.pre_tool_use_payload(&ToolInvocation {                session: session.into(),                turn: turn.into(),                cancellation_token: tokio_util::sync::CancellationToken::new(),                tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),                call_id: "call-mcp-pre-builtin-like".to_string(),                tool_name: codex_tools::ToolName::namespaced("mcp__foo", "exec_command"),                source: ToolCallSource::Direct,                payload,            }),            Some(PreToolUsePayload {                tool_name: HookToolName::new("mcp__foo__exec_command"),                tool_input: json!({ "message": "hello" }),            })        );    }    #[tokio::test]    async fn mcp_updated_input_rewrites_builtin_like_tool_names_as_mcp() {        let payload = ToolPayload::Function {            arguments: json!({ "message": "hello" }).to_string(),        };        let (session, turn) = make_session_and_context().await;        let handler = McpHandler::new(tool_info("foo", "mcp__foo", "exec_command"))            .expect("MCP tool spec should build");        let invocation = handler            .with_updated_hook_input(                ToolInvocation {                    session: session.into(),                    turn: turn.into(),                    cancellation_token: tokio_util::sync::CancellationToken::new(),                    tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),                    call_id: "call-mcp-rewrite-builtin-like".to_string(),                    tool_name: codex_tools::ToolName::namespaced("mcp__foo", "exec_command"),                    source: ToolCallSource::Direct,                    payload,                },                json!({ "message": "rewritten" }),            )            .expect("MCP rewrite should succeed");        let ToolPayload::Function { arguments } = invocation.payload else {            panic!("builtin-like MCP tool should stay function-shaped");        };        assert_eq!(arguments, json!({ "message": "rewritten" }).to_string());    }    #[tokio::test]    async fn mcp_post_tool_use_payload_uses_prefixed_tool_name_args_and_result() {        let payload = ToolPayload::Function {            arguments: json!({ "path": "/tmp/notes.txt" }).to_string(),        };        let output = McpToolOutput {            result: codex_protocol::mcp::CallToolResult {                content: vec![json!({                    "type": "text",                    "text": "notes"                })],                structured_content: Some(json!({ "bytes": 5 })),                is_error: None,                meta: None,            },            tool_input: json!({                "path": {                    "file_id": "file_123"                }            }),            wall_time: Duration::from_millis(42),            original_image_detail_supported: true,            truncation_policy: codex_utils_output_truncation::TruncationPolicy::Bytes(1024),        };        let (session, turn) = make_session_and_context().await;        let handler = McpHandler::new(tool_info("filesystem", "filesystem", "read_file"))            .expect("MCP tool spec should build");        let invocation = ToolInvocation {            session: session.into(),            turn: turn.into(),            cancellation_token: tokio_util::sync::CancellationToken::new(),            tracker: Arc::new(Mutex::new(TurnDiffTracker::new())),            call_id: "call-mcp-post".to_string(),            tool_name: codex_tools::ToolName::namespaced("filesystem", "read_file"),            source: ToolCallSource::Direct,            payload,        };        assert_eq!(            handler.post_tool_use_payload(&invocation, &output),            Some(PostToolUsePayload {                tool_name: HookToolName::new("mcp__filesystem__read_file"),                tool_use_id: "call-mcp-post".to_string(),                tool_input: json!({                    "path": {                        "file_id": "file_123"                    }                }),                tool_response: json!({                    "content": [{                        "type": "text",                        "text": "notes"                    }],                    "structuredContent": { "bytes": 5 }                }),            })        );    }    #[test]    fn mcp_read_only_hint_supports_parallel_calls_without_server_opt_in() {        let mut read_only_info = tool_info("foo", "mcp__foo__", "read");        read_only_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(true));        assert!(            McpHandler::new(read_only_info)                .expect("MCP tool spec should build")                .supports_parallel_tool_calls()        );    }    #[test]    fn mcp_parallel_calls_require_read_only_hint_or_server_opt_in() {        let missing_hint_info = tool_info("foo", "mcp__foo__", "unannotated");        assert!(            !McpHandler::new(missing_hint_info)                .expect("MCP tool spec should build")                .supports_parallel_tool_calls()        );        let mut writable_info = tool_info("foo", "mcp__foo__", "write");        writable_info.tool.annotations = Some(rmcp::model::ToolAnnotations::new().read_only(false));        assert!(            !McpHandler::new(writable_info)                .expect("MCP tool spec should build")                .supports_parallel_tool_calls()        );        let mut server_opt_in_info = tool_info("foo", "mcp__foo__", "server_opt_in");        server_opt_in_info.supports_parallel_tool_calls = true;        assert!(            McpHandler::new(server_opt_in_info)                .expect("MCP tool spec should build")                .supports_parallel_tool_calls()        );    }    fn tool_info(server_name: &str, callable_namespace: &str, tool_name: &str) -> ToolInfo {        ToolInfo {            server_name: server_name.to_string(),            supports_parallel_tool_calls: false,            server_origin: None,            callable_name: tool_name.to_string(),            callable_namespace: callable_namespace.to_string(),            namespace_description: None,            tool: rmcp::model::Tool::new_with_raw(                tool_name.to_string(),                None,                Arc::new(rmcp::model::object(serde_json::json!({                    "type": "object",                }))),            ),            connector_id: None,            connector_name: None,            plugin_display_names: Vec::new(),        }    }}