| 1 | //! In-TUI MCP manager command parser. |
| 2 | |
| 3 | use crate::commands::traits::{CommandInfo, RegisterCommand}; |
| 4 | use crate::localization::MessageId; |
| 5 | use crate::tui::app::{App, AppAction, McpUiAction}; |
| 6 | |
| 7 | use crate::commands::CommandResult; |
| 8 | |
| 9 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 10 | name: "mcp", |
| 11 | aliases: &[], |
| 12 | usage: "/mcp [init|import|import approve <name>|import decline <name>|recommendations|add recommended <id>|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|doctor|validate|restart|reload]", |
| 13 | description_id: MessageId::CmdMcpDescription, |
| 14 | }; |
| 15 | |
| 16 | pub(in crate::commands) struct McpCmd; |
| 17 | |
| 18 | impl RegisterCommand for McpCmd { |
| 19 | fn info() -> &'static CommandInfo { |
| 20 | &COMMAND_INFO |
| 21 | } |
| 22 | |
| 23 | fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 24 | mcp(app, arg) |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | fn mcp(_app: &mut App, args: Option<&str>) -> CommandResult { |
| 29 | let raw = args.unwrap_or("").trim(); |
| 30 | if raw.is_empty() || raw.eq_ignore_ascii_case("status") || raw.eq_ignore_ascii_case("list") { |
| 31 | return CommandResult::action(AppAction::Mcp(McpUiAction::Show)); |
| 32 | } |
| 33 | |
| 34 | let mut parts = raw.split_whitespace(); |
| 35 | let action = parts.next().unwrap_or("").to_ascii_lowercase(); |
| 36 | match action.as_str() { |
| 37 | "init" => CommandResult::action(AppAction::Mcp(McpUiAction::Init { |
| 38 | force: parts.any(|part| part == "--force" || part == "-f"), |
| 39 | })), |
| 40 | "recommend" | "recommended" | "recommendations" => { |
| 41 | CommandResult::message(recommended_mcp_text()) |
| 42 | } |
| 43 | "add" => parse_add(parts.collect()), |
| 44 | "enable" => match parse_name(parts.next(), "Usage: /mcp enable <name>") { |
| 45 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Enable { name })), |
| 46 | Err(msg) => CommandResult::error(msg), |
| 47 | }, |
| 48 | "disable" => match parse_name(parts.next(), "Usage: /mcp disable <name>") { |
| 49 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Disable { name })), |
| 50 | Err(msg) => CommandResult::error(msg), |
| 51 | }, |
| 52 | "remove" | "rm" => match parse_name(parts.next(), "Usage: /mcp remove <name>") { |
| 53 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Remove { name })), |
| 54 | Err(msg) => CommandResult::error(msg), |
| 55 | }, |
| 56 | "login" => match parse_name(parts.next(), "Usage: /mcp login <name> [--scope scope]") { |
| 57 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Login { |
| 58 | name, |
| 59 | scopes: parse_scopes(parts.collect()), |
| 60 | })), |
| 61 | Err(msg) => CommandResult::error(msg), |
| 62 | }, |
| 63 | "logout" => match parse_name(parts.next(), "Usage: /mcp logout <name>") { |
| 64 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Logout { name })), |
| 65 | Err(msg) => CommandResult::error(msg), |
| 66 | }, |
| 67 | "import" | "marketplace" | "sources" => { |
| 68 | let sub = parts.next().unwrap_or("").to_ascii_lowercase(); |
| 69 | match sub.as_str() { |
| 70 | "" | "list" | "status" => { |
| 71 | CommandResult::action(AppAction::Mcp(McpUiAction::ImportList)) |
| 72 | } |
| 73 | "approve" | "add" => { |
| 74 | match parse_name(parts.next(), "Usage: /mcp import approve <name>") { |
| 75 | Ok(name) => { |
| 76 | CommandResult::action(AppAction::Mcp(McpUiAction::ImportApprove { |
| 77 | name, |
| 78 | })) |
| 79 | } |
| 80 | Err(msg) => CommandResult::error(msg), |
| 81 | } |
| 82 | } |
| 83 | "decline" | "deny" | "reject" => { |
| 84 | match parse_name(parts.next(), "Usage: /mcp import decline <name>") { |
| 85 | Ok(name) => { |
| 86 | CommandResult::action(AppAction::Mcp(McpUiAction::ImportDecline { |
| 87 | name, |
| 88 | })) |
| 89 | } |
| 90 | Err(msg) => CommandResult::error(msg), |
| 91 | } |
| 92 | } |
| 93 | _ => { |
| 94 | CommandResult::error("Usage: /mcp import [list|approve <name>|decline <name>]") |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | "validate" | "doctor" => CommandResult::action(AppAction::Mcp(McpUiAction::Validate)), |
| 99 | "reload" | "reconnect" | "restart" => { |
| 100 | CommandResult::action(AppAction::Mcp(McpUiAction::Reload)) |
| 101 | } |
| 102 | _ => CommandResult::error( |
| 103 | "Usage: /mcp [init|import|recommendations|add recommended <id>|add stdio <name> <command> [args...]|add http <name> <url>|enable <name>|disable <name>|remove <name>|login <name>|logout <name>|doctor|validate|restart|reload]", |
| 104 | ), |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | fn parse_name(name: Option<&str>, usage: &str) -> Result<String, String> { |
| 109 | match name { |
| 110 | Some(name) if !name.trim().is_empty() => Ok(name.to_string()), |
| 111 | _ => Err(usage.to_string()), |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | fn parse_add(parts: Vec<&str>) -> CommandResult { |
| 116 | if parts |
| 117 | .first() |
| 118 | .is_some_and(|part| part.eq_ignore_ascii_case("recommended")) |
| 119 | { |
| 120 | return match parts.as_slice() { |
| 121 | [_, id] if id.eq_ignore_ascii_case("hugging-face") || id.eq_ignore_ascii_case("hf") => { |
| 122 | CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp { |
| 123 | name: "hugging-face".to_string(), |
| 124 | url: "https://huggingface.co/mcp".to_string(), |
| 125 | transport: None, |
| 126 | })) |
| 127 | } |
| 128 | [_, _] => CommandResult::error( |
| 129 | "Unknown recommended MCP id. Run /mcp recommendations to inspect the curated list.", |
| 130 | ), |
| 131 | _ => CommandResult::error("Usage: /mcp add recommended <id>"), |
| 132 | }; |
| 133 | } |
| 134 | if parts.len() < 3 { |
| 135 | return CommandResult::error( |
| 136 | "Usage: /mcp add stdio <name> <command> [args...] OR /mcp add http <name> <url>", |
| 137 | ); |
| 138 | } |
| 139 | match parts[0].to_ascii_lowercase().as_str() { |
| 140 | "stdio" => CommandResult::action(AppAction::Mcp(McpUiAction::AddStdio { |
| 141 | name: parts[1].to_string(), |
| 142 | command: parts[2].to_string(), |
| 143 | args: parts[3..].iter().map(|s| (*s).to_string()).collect(), |
| 144 | })), |
| 145 | "http" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp { |
| 146 | name: parts[1].to_string(), |
| 147 | url: parts[2].to_string(), |
| 148 | transport: None, |
| 149 | })), |
| 150 | "sse" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp { |
| 151 | name: parts[1].to_string(), |
| 152 | url: parts[2].to_string(), |
| 153 | transport: Some("sse".to_string()), |
| 154 | })), |
| 155 | _ => CommandResult::error( |
| 156 | "Usage: /mcp add stdio <name> <command> [args...] OR /mcp add http <name> <url>", |
| 157 | ), |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | fn recommended_mcp_text() -> &'static str { |
| 162 | "Recommended MCP servers (suggestions only; nothing is installed automatically)\n\ |
| 163 | \n\ |
| 164 | • hugging-face — remote Hugging Face MCP endpoint\n\ |
| 165 | provenance: bundled Codewhale recommendation\n\ |
| 166 | add explicitly: /mcp add recommended hugging-face\n\ |
| 167 | then inspect: /mcp doctor · reload all configured servers: /mcp restart\n\ |
| 168 | \n\ |
| 169 | External sources (~/.claude.json, .mcp.json, marketplace manifests):\n\ |
| 170 | /mcp import — list candidates with provenance (keyboard/mouse status)\n\ |
| 171 | /mcp import approve <name> — create managed connector after consent\n\ |
| 172 | /mcp import decline <name> — durable decline until source content changes\n\ |
| 173 | enabled=false is a hard block and will never import. Nothing is auto-imported." |
| 174 | } |
| 175 | |
| 176 | fn parse_scopes(parts: Vec<&str>) -> Vec<String> { |
| 177 | let mut scopes = Vec::new(); |
| 178 | let mut iter = parts.into_iter(); |
| 179 | while let Some(part) = iter.next() { |
| 180 | if part == "--scope" { |
| 181 | let Some(value) = iter.next() else { |
| 182 | continue; |
| 183 | }; |
| 184 | for scope in value.split(',') { |
| 185 | let scope = scope.trim(); |
| 186 | if !scope.is_empty() { |
| 187 | scopes.push(scope.to_string()); |
| 188 | } |
| 189 | } |
| 190 | continue; |
| 191 | } |
| 192 | let value = part.strip_prefix("--scope="); |
| 193 | let Some(value) = value else { |
| 194 | for scope in part.split(',') { |
| 195 | let scope = scope.trim(); |
| 196 | if !scope.is_empty() { |
| 197 | scopes.push(scope.to_string()); |
| 198 | } |
| 199 | } |
| 200 | continue; |
| 201 | }; |
| 202 | for scope in value.split(',') { |
| 203 | let scope = scope.trim(); |
| 204 | if !scope.is_empty() { |
| 205 | scopes.push(scope.to_string()); |
| 206 | } |
| 207 | } |
| 208 | } |
| 209 | scopes |
| 210 | } |
| 211 | |
| 212 | #[cfg(test)] |
| 213 | mod tests { |
| 214 | use super::*; |
| 215 | use crate::config::Config; |
| 216 | use crate::tui::app::TuiOptions; |
| 217 | use std::path::PathBuf; |
| 218 | |
| 219 | fn app() -> App { |
| 220 | App::new( |
| 221 | TuiOptions { |
| 222 | use_alt_screen: false, |
| 223 | max_subagents: 2, |
| 224 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 225 | }, |
| 226 | &Config::default(), |
| 227 | ) |
| 228 | } |
| 229 | |
| 230 | #[test] |
| 231 | fn parses_add_and_validate() { |
| 232 | let mut app = app(); |
| 233 | let add = mcp(&mut app, Some("add stdio local node server.js")); |
| 234 | assert!(matches!( |
| 235 | add.action, |
| 236 | Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args })) |
| 237 | if name == "local" && command == "node" && args == vec!["server.js".to_string()] |
| 238 | )); |
| 239 | |
| 240 | let validate = mcp(&mut app, Some("validate")); |
| 241 | assert!(matches!( |
| 242 | validate.action, |
| 243 | Some(AppAction::Mcp(McpUiAction::Validate)) |
| 244 | )); |
| 245 | |
| 246 | let doctor = mcp(&mut app, Some("doctor")); |
| 247 | assert!(matches!( |
| 248 | doctor.action, |
| 249 | Some(AppAction::Mcp(McpUiAction::Validate)) |
| 250 | )); |
| 251 | let restart = mcp(&mut app, Some("restart")); |
| 252 | assert!(matches!( |
| 253 | restart.action, |
| 254 | Some(AppAction::Mcp(McpUiAction::Reload)) |
| 255 | )); |
| 256 | |
| 257 | let recommended = mcp(&mut app, Some("recommendations")) |
| 258 | .message |
| 259 | .expect("recommendations text"); |
| 260 | assert!(recommended.contains("nothing is installed automatically")); |
| 261 | assert!(recommended.contains("provenance:")); |
| 262 | |
| 263 | let add_recommended = mcp(&mut app, Some("add recommended hugging-face")); |
| 264 | assert!(matches!( |
| 265 | add_recommended.action, |
| 266 | Some(AppAction::Mcp(McpUiAction::AddHttp { name, url, transport: None })) |
| 267 | if name == "hugging-face" && url == "https://huggingface.co/mcp" |
| 268 | )); |
| 269 | |
| 270 | let import_list = mcp(&mut app, Some("import")); |
| 271 | assert!(matches!( |
| 272 | import_list.action, |
| 273 | Some(AppAction::Mcp(McpUiAction::ImportList)) |
| 274 | )); |
| 275 | let import_approve = mcp(&mut app, Some("import approve local-tools")); |
| 276 | assert!(matches!( |
| 277 | import_approve.action, |
| 278 | Some(AppAction::Mcp(McpUiAction::ImportApprove { name })) |
| 279 | if name == "local-tools" |
| 280 | )); |
| 281 | let import_decline = mcp(&mut app, Some("import decline local-tools")); |
| 282 | assert!(matches!( |
| 283 | import_decline.action, |
| 284 | Some(AppAction::Mcp(McpUiAction::ImportDecline { name })) |
| 285 | if name == "local-tools" |
| 286 | )); |
| 287 | let marketplace = mcp(&mut app, Some("marketplace")); |
| 288 | assert!(matches!( |
| 289 | marketplace.action, |
| 290 | Some(AppAction::Mcp(McpUiAction::ImportList)) |
| 291 | )); |
| 292 | assert!(recommended.contains("/mcp import")); |
| 293 | |
| 294 | let login = mcp( |
| 295 | &mut app, |
| 296 | Some("login remote --scope tools/read,tools/write"), |
| 297 | ); |
| 298 | assert!(matches!( |
| 299 | login.action, |
| 300 | Some(AppAction::Mcp(McpUiAction::Login { name, scopes })) |
| 301 | if name == "remote" |
| 302 | && scopes == vec!["tools/read".to_string(), "tools/write".to_string()] |
| 303 | )); |
| 304 | } |
| 305 | } |
| 306 |