Codex Handbook
tui/src/history_cell/mcp.rs 692 lines
//! MCP tool-call, inventory, and output history cells.use super::*;#[derive(Debug)]struct CompletedMcpToolCallWithImageOutput {    _image: DynamicImage,}impl HistoryCell for CompletedMcpToolCallWithImageOutput {    fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {        vec!["tool result (image output)".into()]    }    fn raw_lines(&self) -> Vec<Line<'static>> {        vec![Line::from("tool result (image output)")]    }}fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str {    match status {        McpAuthStatus::Unsupported => "Unsupported",        McpAuthStatus::NotLoggedIn => "Not logged in",        McpAuthStatus::BearerToken => "Bearer token",        McpAuthStatus::OAuth => "OAuth",    }}#[derive(Debug)]pub(crate) struct McpToolCallCell {    call_id: String,    invocation: McpInvocation,    start_time: Instant,    duration: Option<Duration>,    result: Option<Result<codex_protocol::mcp::CallToolResult, String>>,    animations_enabled: bool,}#[derive(Debug, Clone)]pub(crate) struct McpInvocation {    pub(crate) server: String,    pub(crate) tool: String,    pub(crate) arguments: Option<serde_json::Value>,}impl McpToolCallCell {    pub(crate) fn new(        call_id: String,        invocation: McpInvocation,        animations_enabled: bool,    ) -> Self {        Self {            call_id,            invocation,            start_time: Instant::now(),            duration: None,            result: None,            animations_enabled,        }    }    pub(crate) fn call_id(&self) -> &str {        &self.call_id    }    pub(crate) fn complete(        &mut self,        duration: Duration,        result: Result<codex_protocol::mcp::CallToolResult, String>,    ) -> Option<Box<dyn HistoryCell>> {        let image_cell = try_new_completed_mcp_tool_call_with_image_output(&result)            .map(|cell| Box::new(cell) as Box<dyn HistoryCell>);        self.duration = Some(duration);        self.result = Some(result);        image_cell    }    fn success(&self) -> Option<bool> {        match self.result.as_ref() {            Some(Ok(result)) => Some(!result.is_error.unwrap_or(false)),            Some(Err(_)) => Some(false),            None => None,        }    }    pub(crate) fn mark_failed(&mut self) {        let elapsed = self.start_time.elapsed();        self.duration = Some(elapsed);        self.result = Some(Err("interrupted".to_string()));    }    fn render_content_block(block: &serde_json::Value, width: usize) -> String {        let content = match serde_json::from_value::<rmcp::model::Content>(block.clone()) {            Ok(content) => content,            Err(_) => {                return format_and_truncate_tool_result(                    &block.to_string(),                    TOOL_CALL_MAX_LINES,                    width,                );            }        };        match content.raw {            rmcp::model::RawContent::Text(text) => {                format_and_truncate_tool_result(&text.text, TOOL_CALL_MAX_LINES, width)            }            rmcp::model::RawContent::Image(_) => "<image content>".to_string(),            rmcp::model::RawContent::Audio(_) => "<audio content>".to_string(),            rmcp::model::RawContent::Resource(resource) => {                let uri = match resource.resource {                    rmcp::model::ResourceContents::TextResourceContents { uri, .. } => uri,                    rmcp::model::ResourceContents::BlobResourceContents { uri, .. } => uri,                };                format!("embedded resource: {uri}")            }            rmcp::model::RawContent::ResourceLink(link) => format!("link: {}", link.uri),        }    }}impl HistoryCell for McpToolCallCell {    fn display_lines(&self, width: u16) -> Vec<Line<'static>> {        let mut lines: Vec<Line<'static>> = Vec::new();        let status = self.success();        let bullet = match status {            Some(true) => "•".green().bold(),            Some(false) => "•".red().bold(),            None => activity_indicator(                Some(self.start_time),                MotionMode::from_animations_enabled(self.animations_enabled),                ReducedMotionIndicator::StaticBullet,            )            .unwrap_or_else(|| "•".dim()),        };        let header_text = if status.is_some() {            "Called"        } else {            "Calling"        };        let invocation_line = line_to_static(&format_mcp_invocation(self.invocation.clone()));        let mut compact_spans = vec![bullet.clone(), " ".into(), header_text.bold(), " ".into()];        let mut compact_header = Line::from(compact_spans.clone());        let reserved = compact_header.width();        let inline_invocation =            invocation_line.width() <= (width as usize).saturating_sub(reserved);        if inline_invocation {            compact_header.extend(invocation_line.spans.clone());            lines.push(compact_header);        } else {            compact_spans.pop(); // drop trailing space for standalone header            lines.push(Line::from(compact_spans));            let opts = RtOptions::new((width as usize).saturating_sub(4))                .initial_indent("".into())                .subsequent_indent("    ".into());            let wrapped = adaptive_wrap_line(&invocation_line, opts);            let body_lines: Vec<Line<'static>> = wrapped.iter().map(line_to_static).collect();            lines.extend(prefix_lines(body_lines, "  └ ".dim(), "    ".into()));        }        let mut detail_lines: Vec<Line<'static>> = Vec::new();        // Reserve four columns for the tree prefix ("  └ "/"    ") and ensure the wrapper still has at least one cell to work with.        let detail_wrap_width = (width as usize).saturating_sub(4).max(1);        if let Some(result) = &self.result {            match result {                Ok(codex_protocol::mcp::CallToolResult { content, .. }) => {                    if !content.is_empty() {                        for block in content {                            let text = Self::render_content_block(block, detail_wrap_width);                            for segment in text.split('\n') {                                let line = Line::from(segment.to_string().dim());                                let wrapped = adaptive_wrap_line(                                    &line,                                    RtOptions::new(detail_wrap_width)                                        .initial_indent("".into())                                        .subsequent_indent("    ".into()),                                );                                detail_lines.extend(wrapped.iter().map(line_to_static));                            }                        }                    }                }                Err(err) => {                    let err_text = format_and_truncate_tool_result(                        &format!("Error: {err}"),                        TOOL_CALL_MAX_LINES,                        width as usize,                    );                    let err_line = Line::from(err_text.dim());                    let wrapped = adaptive_wrap_line(                        &err_line,                        RtOptions::new(detail_wrap_width)                            .initial_indent("".into())                            .subsequent_indent("    ".into()),                    );                    detail_lines.extend(wrapped.iter().map(line_to_static));                }            }        }        if !detail_lines.is_empty() {            let initial_prefix: Span<'static> = if inline_invocation {                "  └ ".dim()            } else {                "    ".into()            };            lines.extend(prefix_lines(detail_lines, initial_prefix, "    ".into()));        }        lines    }    fn raw_lines(&self) -> Vec<Line<'static>> {        let header_text = if self.success().is_some() {            "Called"        } else {            "Calling"        };        let mut lines = vec![Line::from(format!(            "{header_text} {}",            format_mcp_invocation(self.invocation.clone())        ))];        if let Some(result) = &self.result {            match result {                Ok(codex_protocol::mcp::CallToolResult { content, .. }) => {                    for block in content {                        let text = Self::render_content_block(block, RAW_TOOL_OUTPUT_WIDTH);                        lines.extend(raw_lines_from_source(&text));                    }                }                Err(err) => lines.push(Line::from(format!("Error: {err}"))),            }        }        lines    }    fn transcript_animation_tick(&self) -> Option<u64> {        if !self.animations_enabled || self.result.is_some() {            return None;        }        Some((self.start_time.elapsed().as_millis() / 50) as u64)    }}pub(crate) fn new_active_mcp_tool_call(    call_id: String,    invocation: McpInvocation,    animations_enabled: bool,) -> McpToolCallCell {    McpToolCallCell::new(call_id, invocation, animations_enabled)}/// Returns an additional history cell if an MCP tool result includes a decodable image.////// This intentionally returns at most one cell: the first image in `CallToolResult.content` that/// successfully base64-decodes and parses as an image. This is used as a lightweight “image output/// exists” affordance separate from the main MCP tool call cell.////// Manual testing tip:/// - Run the rmcp stdio test server (`codex-rs/rmcp-client/src/bin/test_stdio_server.rs`) and///   register it as an MCP server via `codex mcp add`./// - Use its `image_scenario` tool with cases like `text_then_image`,///   `invalid_base64_then_image`, or `invalid_image_bytes_then_image` to ensure this path triggers///   even when the first block is not a valid image.fn try_new_completed_mcp_tool_call_with_image_output(    result: &Result<codex_protocol::mcp::CallToolResult, String>,) -> Option<CompletedMcpToolCallWithImageOutput> {    let image = result        .as_ref()        .ok()?        .content        .iter()        .find_map(decode_mcp_image)?;    Some(CompletedMcpToolCallWithImageOutput { _image: image })}/// Decodes an MCP `ImageContent` block into an in-memory image.////// Returns `None` when the block is not an image, when base64 decoding fails, when the format/// cannot be inferred, or when the image decoder rejects the bytes.fn decode_mcp_image(block: &serde_json::Value) -> Option<DynamicImage> {    let content = serde_json::from_value::<rmcp::model::Content>(block.clone()).ok()?;    let rmcp::model::RawContent::Image(image) = content.raw else {        return None;    };    let base64_data = if let Some(data_url) = image.data.strip_prefix("data:") {        data_url.split_once(',')?.1    } else {        image.data.as_str()    };    let raw_data = base64::engine::general_purpose::STANDARD        .decode(base64_data)        .map_err(|e| {            error!("Failed to decode image data: {e}");            e        })        .ok()?;    let reader = ImageReader::new(Cursor::new(raw_data))        .with_guessed_format()        .map_err(|e| {            error!("Failed to guess image format: {e}");            e        })        .ok()?;    reader        .decode()        .map_err(|e| {            error!("Image decoding failed: {e}");            e        })        .ok()}/// Render a summary of configured MCP servers from the current `Config`.pub(crate) fn empty_mcp_output() -> PlainHistoryCell {    let lines: Vec<Line<'static>> = vec![        "/mcp".magenta().into(),        "".into(),        vec!["🔌  ".into(), "MCP Tools".bold()].into(),        "".into(),        "  • No MCP servers configured.".italic().into(),        Line::from(vec![            "    See the ".into(),            crate::terminal_hyperlinks::osc8_hyperlink(                "https://developers.openai.com/codex/mcp",                "MCP docs",            )            .underlined(),            " to configure them.".into(),        ])        .style(Style::default().add_modifier(Modifier::DIM)),    ];    PlainHistoryCell::new(lines)}#[cfg(test)]/// Render MCP tools grouped by connection using the fully-qualified tool names.pub(crate) fn new_mcp_tools_output(    config: &Config,    tools: HashMap<String, codex_protocol::mcp::Tool>,    resources: HashMap<String, Vec<Resource>>,    resource_templates: HashMap<String, Vec<ResourceTemplate>>,    auth_statuses: &HashMap<String, McpAuthStatus>,) -> PlainHistoryCell {    let mut lines: Vec<Line<'static>> = vec![        "/mcp".magenta().into(),        "".into(),        vec!["🔌  ".into(), "MCP Tools".bold()].into(),        "".into(),    ];    if tools.is_empty() {        lines.push("  • No MCP tools available.".italic().into());        lines.push("".into());    }    let effective_servers = config.mcp_servers.get().clone();    let mut servers: Vec<_> = effective_servers.iter().collect();    servers.sort_by_key(|(server, _)| *server);    for (server, cfg) in servers {        let prefix = qualified_mcp_tool_name_prefix(server);        let mut names: Vec<String> = tools            .keys()            .filter(|k| k.starts_with(&prefix))            .map(|k| k[prefix.len()..].to_string())            .collect();        names.sort();        let auth_status = auth_statuses            .get(server.as_str())            .copied()            .unwrap_or(McpAuthStatus::Unsupported);        let mut header: Vec<Span<'static>> = vec!["  • ".into(), server.clone().into()];        if !cfg.enabled {            header.push(" ".into());            header.push("(disabled)".red());            lines.push(header.into());            if let Some(reason) = cfg.disabled_reason.as_ref().map(ToString::to_string) {                lines.push(vec!["    • Reason: ".into(), reason.dim()].into());            }            lines.push(Line::from(""));            continue;        }        lines.push(header.into());        lines.push(vec!["    • Status: ".into(), "enabled".green()].into());        lines.push(            vec![                "    • Auth: ".into(),                mcp_auth_status_label(auth_status).into(),            ]            .into(),        );        match &cfg.transport {            McpServerTransportConfig::Stdio {                command,                args,                env,                env_vars,                cwd,            } => {                let args_suffix = if args.is_empty() {                    String::new()                } else {                    format!(" {}", args.join(" "))                };                let cmd_display = format!("{command}{args_suffix}");                lines.push(vec!["    • Command: ".into(), cmd_display.into()].into());                if let Some(cwd) = cwd.as_ref() {                    lines.push(vec!["    • Cwd: ".into(), cwd.display().to_string().into()].into());                }                let env_display = format_env_display(env.as_ref(), env_vars);                if env_display != "-" {                    lines.push(vec!["    • Env: ".into(), env_display.into()].into());                }            }            McpServerTransportConfig::StreamableHttp {                url,                http_headers,                env_http_headers,                ..            } => {                lines.push(vec!["    • URL: ".into(), url.clone().into()].into());                if let Some(headers) = http_headers.as_ref()                    && !headers.is_empty()                {                    let mut pairs: Vec<_> = headers.iter().collect();                    pairs.sort_by_key(|(name, _)| *name);                    let display = pairs                        .into_iter()                        .map(|(name, _)| format!("{name}=*****"))                        .collect::<Vec<_>>()                        .join(", ");                    lines.push(vec!["    • HTTP headers: ".into(), display.into()].into());                }                if let Some(headers) = env_http_headers.as_ref()                    && !headers.is_empty()                {                    let mut pairs: Vec<_> = headers.iter().collect();                    pairs.sort_by_key(|(name, _)| *name);                    let display = pairs                        .into_iter()                        .map(|(name, var)| format!("{name}={var}"))                        .collect::<Vec<_>>()                        .join(", ");                    lines.push(vec!["    • Env HTTP headers: ".into(), display.into()].into());                }            }        }        if names.is_empty() {            lines.push("    • Tools: (none)".into());        } else {            lines.push(vec!["    • Tools: ".into(), names.join(", ").into()].into());        }        let server_resources: Vec<Resource> =            resources.get(server.as_str()).cloned().unwrap_or_default();        if server_resources.is_empty() {            lines.push("    • Resources: (none)".into());        } else {            let mut spans: Vec<Span<'static>> = vec!["    • Resources: ".into()];            for (idx, resource) in server_resources.iter().enumerate() {                if idx > 0 {                    spans.push(", ".into());                }                let label = resource.title.as_ref().unwrap_or(&resource.name);                spans.push(label.clone().into());                spans.push(" ".into());                spans.push(format!("({})", resource.uri).dim());            }            lines.push(spans.into());        }        let server_templates: Vec<ResourceTemplate> = resource_templates            .get(server.as_str())            .cloned()            .unwrap_or_default();        if server_templates.is_empty() {            lines.push("    • Resource templates: (none)".into());        } else {            let mut spans: Vec<Span<'static>> = vec!["    • Resource templates: ".into()];            for (idx, template) in server_templates.iter().enumerate() {                if idx > 0 {                    spans.push(", ".into());                }                let label = template.title.as_ref().unwrap_or(&template.name);                spans.push(label.clone().into());                spans.push(" ".into());                spans.push(format!("({})", template.uri_template).dim());            }            lines.push(spans.into());        }        lines.push(Line::from(""));    }    PlainHistoryCell { lines }}/// Build the `/mcp` history cell from app-server `McpServerStatus` responses.////// The server list comes directly from the app-server status response, sorted/// alphabetically. The TUI deliberately does not enrich these rows from/// client-local config because the app-server owns the remote MCP state.////// This mirrors the layout of [`new_mcp_tools_output`] but sources data from/// the paginated RPC response rather than the in-process `McpManager`. The/// `detail` flag controls whether resources and resource templates are rendered.pub(crate) fn new_mcp_tools_output_from_statuses(    statuses: &[McpServerStatus],    detail: McpServerStatusDetail,) -> PlainHistoryCell {    let mut lines: Vec<Line<'static>> = vec![        "/mcp".magenta().into(),        "".into(),        vec!["🔌  ".into(), "MCP Tools".bold()].into(),        "".into(),    ];    let mut statuses = statuses.iter().collect::<Vec<_>>();    statuses.sort_by(|a, b| a.name.cmp(&b.name));    let has_any_tools = statuses.iter().any(|status| !status.tools.is_empty());    if !has_any_tools {        lines.push("  • No MCP tools available.".italic().into());        lines.push("".into());    }    for status in statuses {        let header: Vec<Span<'static>> = vec!["  • ".into(), status.name.clone().into()];        lines.push(header.into());        let auth_status = match status.auth_status {            codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,            codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,            codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,            codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth,        };        lines.push(            vec![                "    • Auth: ".into(),                mcp_auth_status_label(auth_status).into(),            ]            .into(),        );        let mut names = status.tools.keys().cloned().collect::<Vec<_>>();        names.sort();        if names.is_empty() {            lines.push("    • Tools: (none)".into());        } else {            lines.push(vec!["    • Tools: ".into(), names.join(", ").into()].into());        }        if matches!(detail, McpServerStatusDetail::Full) {            let server_resources = status.resources.clone();            if server_resources.is_empty() {                lines.push("    • Resources: (none)".into());            } else {                let mut spans: Vec<Span<'static>> = vec!["    • Resources: ".into()];                for (idx, resource) in server_resources.iter().enumerate() {                    if idx > 0 {                        spans.push(", ".into());                    }                    let label = resource.title.as_ref().unwrap_or(&resource.name);                    spans.push(label.clone().into());                    spans.push(" ".into());                    spans.push(format!("({})", resource.uri).dim());                }                lines.push(spans.into());            }            let server_templates = status.resource_templates.clone();            if server_templates.is_empty() {                lines.push("    • Resource templates: (none)".into());            } else {                let mut spans: Vec<Span<'static>> = vec!["    • Resource templates: ".into()];                for (idx, template) in server_templates.iter().enumerate() {                    if idx > 0 {                        spans.push(", ".into());                    }                    let label = template.title.as_ref().unwrap_or(&template.name);                    spans.push(label.clone().into());                    spans.push(" ".into());                    spans.push(format!("({})", template.uri_template).dim());                }                lines.push(spans.into());            }        }        lines.push(Line::from(""));    }    PlainHistoryCell { lines }}/// A transient history cell that shows an animated spinner while the MCP/// inventory RPC is in flight.////// Inserted as the `active_cell` by `ChatWidget::add_mcp_output()` and removed/// once the fetch completes. The app removes committed copies from transcript/// history, while `ChatWidget::clear_mcp_inventory_loading()` only clears the/// in-flight `active_cell`.#[derive(Debug)]pub(crate) struct McpInventoryLoadingCell {    start_time: Instant,    animations_enabled: bool,}impl McpInventoryLoadingCell {    pub(crate) fn new(animations_enabled: bool) -> Self {        Self {            start_time: Instant::now(),            animations_enabled,        }    }}impl HistoryCell for McpInventoryLoadingCell {    fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {        vec![            vec![                activity_indicator(                    Some(self.start_time),                    MotionMode::from_animations_enabled(self.animations_enabled),                    ReducedMotionIndicator::StaticBullet,                )                .unwrap_or_else(|| "•".dim()),                " ".into(),                "Loading MCP inventory".bold(),                "…".dim(),            ]            .into(),        ]    }    fn raw_lines(&self) -> Vec<Line<'static>> {        vec![Line::from("Loading MCP inventory...")]    }    fn transcript_animation_tick(&self) -> Option<u64> {        if !self.animations_enabled {            return None;        }        Some((self.start_time.elapsed().as_millis() / 50) as u64)    }}/// Convenience constructor for [`McpInventoryLoadingCell`].pub(crate) fn new_mcp_inventory_loading(animations_enabled: bool) -> McpInventoryLoadingCell {    McpInventoryLoadingCell::new(animations_enabled)}fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {    let args_str = invocation        .arguments        .as_ref()        .map(|v: &serde_json::Value| {            // Use compact form to keep things short but readable.            serde_json::to_string(v).unwrap_or_else(|_| v.to_string())        })        .unwrap_or_default();    let invocation_spans = vec![        invocation.server.clone().cyan(),        ".".into(),        invocation.tool.cyan(),        "(".into(),        args_str.dim(),        ")".into(),    ];    invocation_spans.into()}