diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8d0ba335b..c8d7d6161 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2055,14 +2055,18 @@ impl App { /// tradeoff because the effect is limited to stale inventory output in history, /// while request-token invalidation would add cross-cutting async state for a /// low-severity path. - fn fetch_mcp_inventory(&mut self, app_server: &AppServerSession) { + fn fetch_mcp_inventory( + &mut self, + app_server: &AppServerSession, + detail: McpServerStatusDetail, + ) { let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { - let result = fetch_all_mcp_server_statuses(request_handle) + let result = fetch_all_mcp_server_statuses(request_handle, detail) .await .map_err(|err| err.to_string()); - app_event_tx.send(AppEvent::McpInventoryLoaded { result }); + app_event_tx.send(AppEvent::McpInventoryLoaded { result, detail }); }); } @@ -2387,7 +2391,11 @@ impl App { /// /// When both the local config and the app-server report zero servers, a special /// "empty" cell is shown instead of the full table. - fn handle_mcp_inventory_result(&mut self, result: Result, String>) { + fn handle_mcp_inventory_result( + &mut self, + result: Result, String>, + detail: McpServerStatusDetail, + ) { let config = self.chat_widget.config_ref().clone(); self.chat_widget.clear_mcp_inventory_loading(); self.clear_committed_mcp_inventory_loading(); @@ -2409,9 +2417,7 @@ impl App { self.chat_widget .add_to_history(history_cell::new_mcp_tools_output_from_statuses( - &config, - &statuses, - McpServerStatusDetail::ToolsAndAuthOnly, + &config, &statuses, detail, )); } @@ -4969,11 +4975,11 @@ impl App { .on_plugin_enabled_set(cwd, plugin_id, enabled, result); } } - AppEvent::FetchMcpInventory => { - self.fetch_mcp_inventory(app_server); + AppEvent::FetchMcpInventory { detail } => { + self.fetch_mcp_inventory(app_server, detail); } - AppEvent::McpInventoryLoaded { result } => { - self.handle_mcp_inventory_result(result); + AppEvent::McpInventoryLoaded { result, detail } => { + self.handle_mcp_inventory_result(result, detail); } AppEvent::SkillsListLoaded { result } => { self.handle_skills_list_result( @@ -6671,6 +6677,7 @@ fn side_return_shortcut_matches(key_event: KeyEvent) -> bool { /// the inventory atomically. Each page requests up to 100 entries. async fn fetch_all_mcp_server_statuses( request_handle: AppServerRequestHandle, + detail: McpServerStatusDetail, ) -> Result> { let mut cursor = None; let mut statuses = Vec::new(); @@ -6683,7 +6690,7 @@ async fn fetch_all_mcp_server_statuses( params: ListMcpServerStatusParams { cursor: cursor.clone(), limit: Some(100), - detail: Some(McpServerStatusDetail::ToolsAndAuthOnly), + detail: Some(detail), }, }) .await @@ -7142,13 +7149,16 @@ mod tests { /*animations_enabled*/ false, ))); - app.handle_mcp_inventory_result(Ok(vec![McpServerStatus { - name: "docs".to_string(), - tools: HashMap::new(), - resources: Vec::new(), - resource_templates: Vec::new(), - auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, - }])); + app.handle_mcp_inventory_result( + Ok(vec![McpServerStatus { + name: "docs".to_string(), + tools: HashMap::new(), + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }]), + McpServerStatusDetail::ToolsAndAuthOnly, + ); assert_eq!(app.transcript_cells.len(), 0); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index ceec28266..8654684ac 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::AddCreditsNudgeCreditType; use codex_app_server_protocol::AddCreditsNudgeEmailStatus; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginReadParams; @@ -334,11 +335,14 @@ pub(crate) enum AppEvent { PluginInstallAuthAbandon, /// Fetch MCP inventory via app-server RPCs and render it into history. - FetchMcpInventory, + FetchMcpInventory { + detail: McpServerStatusDetail, + }, /// Result of fetching MCP inventory via app-server RPCs. McpInventoryLoaded { result: Result, String>, + detail: McpServerStatusDetail, }, /// Result of the startup skills refresh that runs after the first frame is scheduled. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 8fb926ba8..3d1c8ec60 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -95,6 +95,7 @@ use codex_app_server_protocol::GuardianApprovalReviewAction; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::McpServerStartupState; +use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; @@ -10212,7 +10213,7 @@ impl ChatWidget { /// /// The spinner lives in `active_cell` and is cleared by /// [`clear_mcp_inventory_loading`] once the result arrives. - pub(crate) fn add_mcp_output(&mut self) { + pub(crate) fn add_mcp_output(&mut self, detail: McpServerStatusDetail) { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading( @@ -10220,7 +10221,8 @@ impl ChatWidget { ))); self.bump_active_cell_revision(); self.request_redraw(); - self.app_event_tx.send(AppEvent::FetchMcpInventory); + self.app_event_tx + .send(AppEvent::FetchMcpInventory { detail }); } /// Remove the MCP loading spinner if it is still the active cell. diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index d68784eec..6e041b833 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -359,7 +359,7 @@ impl ChatWidget { self.add_app_server_stub_message("Memory maintenance"); } SlashCommand::Mcp => { - self.add_mcp_output(); + self.add_mcp_output(McpServerStatusDetail::ToolsAndAuthOnly); } SlashCommand::Apps => { self.add_connectors_output(); @@ -543,6 +543,10 @@ impl ChatWidget { } } } + SlashCommand::Mcp => match trimmed.to_ascii_lowercase().as_str() { + "verbose" => self.add_mcp_output(McpServerStatusDetail::Full), + _ => self.add_error_message("Usage: /mcp [verbose]".to_string()), + }, SlashCommand::Rename if !trimmed.is_empty() => { if !self.ensure_thread_rename_allowed() { return; diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 48e189c44..44d39476a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -73,6 +73,7 @@ pub(super) use codex_app_server_protocol::ItemGuardianApprovalReviewStartedNotif pub(super) use codex_app_server_protocol::ItemStartedNotification; pub(super) use codex_app_server_protocol::MarketplaceInterface; pub(super) use codex_app_server_protocol::McpServerStartupState; +pub(super) use codex_app_server_protocol::McpServerStatusDetail; pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification; pub(super) use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus; pub(super) use codex_app_server_protocol::PatchChangeKind; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 63ae3d099..905ae9837 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1111,7 +1111,48 @@ async fn slash_mcp_requests_inventory_via_app_server() { chat.dispatch_command(SlashCommand::Mcp); assert!(active_blob(&chat).contains("Loading MCP inventory")); - assert_matches!(rx.try_recv(), Ok(AppEvent::FetchMcpInventory)); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::FetchMcpInventory { + detail: McpServerStatusDetail::ToolsAndAuthOnly + }) + ); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_mcp_verbose_requests_full_inventory_via_app_server() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/mcp verbose"); + + assert!(active_blob(&chat).contains("Loading MCP inventory")); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::FetchMcpInventory { + detail: McpServerStatusDetail::Full + }) + ); + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_mcp_invalid_args_show_usage() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + submit_composer_text(&mut chat, "/mcp full"); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("Usage: /mcp [verbose]"), + "expected usage message, got: {rendered:?}" + ); + assert_eq!(recall_latest_after_clearing(&mut chat), "/mcp full"); assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index f0611e968..a09b313e2 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -2097,6 +2097,18 @@ pub(crate) fn new_mcp_tools_output_from_statuses( let header: Vec> = vec![" • ".into(), server.clone().into()]; lines.push(header.into()); + if matches!(detail, McpServerStatusDetail::Full) { + let enabled = cfg.map(|cfg| cfg.enabled).unwrap_or(true); + let status_text = if enabled { + "enabled".green() + } else { + "disabled".red() + }; + lines.push(vec![" • Status: ".into(), status_text].into()); + if let Some(reason) = cfg.and_then(|cfg| cfg.disabled_reason.as_ref()) { + lines.push(vec![" • Reason: ".into(), reason.to_string().dim()].into()); + } + } let auth_status = status .map(|status| match status.auth_status { codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported, @@ -3446,6 +3458,61 @@ mod tests { insta::assert_snapshot!(rendered); } + #[tokio::test] + async fn mcp_tools_output_from_statuses_renders_verbose_inventory() { + let mut config = test_config().await; + let plugin_docs = + stdio_server_config("docs-server", vec!["--stdio"], /*env*/ None, vec![]); + let servers = HashMap::from([("plugin_docs".to_string(), plugin_docs)]); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + + let statuses = vec![McpServerStatus { + name: "plugin_docs".to_string(), + tools: HashMap::from([( + "lookup".to_string(), + Tool { + description: None, + name: "lookup".to_string(), + title: None, + input_schema: serde_json::json!({"type": "object", "properties": {}}), + output_schema: None, + annotations: None, + icons: None, + meta: None, + }, + )]), + resources: vec![Resource { + annotations: None, + description: None, + mime_type: None, + name: "docs".to_string(), + size: None, + title: Some("Docs".to_string()), + uri: "file:///docs".to_string(), + icons: None, + meta: None, + }], + resource_templates: vec![ResourceTemplate { + annotations: None, + uri_template: "file:///docs/{id}".to_string(), + name: "doc-template".to_string(), + title: Some("Doc Template".to_string()), + description: None, + mime_type: None, + }], + auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported, + }]; + + let cell = + new_mcp_tools_output_from_statuses(&config, &statuses, McpServerStatusDetail::Full); + let rendered = render_lines(&cell.display_lines(/*width*/ 120)).join("\n"); + + insta::assert_snapshot!(rendered); + } + #[test] fn empty_agent_message_cell_transcript() { let cell = AgentMessageCell::new(vec![Line::default()], /*is_first_line*/ false); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ee9dc9db9..4c3623993 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -115,7 +115,7 @@ impl SlashCommand { } SlashCommand::Experimental => "toggle experimental features", SlashCommand::Memories => "configure memory use and generation", - SlashCommand::Mcp => "list configured MCP tools", + SlashCommand::Mcp => "list configured MCP tools; use /mcp verbose for details", SlashCommand::Apps => "manage apps", SlashCommand::Plugins => "browse plugins", SlashCommand::Logout => "log out of Codex", @@ -138,6 +138,7 @@ impl SlashCommand { | SlashCommand::Rename | SlashCommand::Plan | SlashCommand::Fast + | SlashCommand::Mcp | SlashCommand::Side | SlashCommand::Resume | SlashCommand::SandboxReadRoot diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_verbose_inventory.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_verbose_inventory.snap new file mode 100644 index 000000000..fb06c8493 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__mcp_tools_output_from_statuses_renders_verbose_inventory.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/history_cell.rs +expression: rendered +--- +/mcp + +🔌 MCP Tools + + • plugin_docs + • Status: enabled + • Auth: Unsupported + • Command: docs-server --stdio + • Tools: lookup + • Resources: Docs (file:///docs) + • Resource templates: Doc Template (file:///docs/{id})