| 1 | //! In-TUI MCP manager command parser. |
| 2 | |
| 3 | use codewhale_command_contract::facets::CommandPresentationContext; |
| 4 | use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; |
| 5 | use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; |
| 6 | |
| 7 | use crate::commands::CommandResult; |
| 8 | use crate::tui::app::{AppAction, McpUiAction}; |
| 9 | |
| 10 | const GITHUB_MCP_URL: &str = "https://api.githubcopilot.com/mcp/"; |
| 11 | const CHROME_DEVTOOLS_MCP_PACKAGE: &str = "chrome-devtools-mcp@1.7.0"; |
| 12 | const PLAYWRIGHT_MCP_PACKAGE: &str = "@playwright/mcp@0.0.79"; |
| 13 | const PLAYWRIGHT_MCP_SOURCE: &str = "https://github.com/microsoft/playwright-mcp"; |
| 14 | const CONTAINER_USE_SOURCE: &str = "https://github.com/dagger/container-use"; |
| 15 | |
| 16 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 17 | name: "mcp", |
| 18 | aliases: &["mcps"], |
| 19 | 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>|retry <name>|doctor|validate|restart|reload]", |
| 20 | description_key: "cmd_mcp_description", |
| 21 | }; |
| 22 | |
| 23 | pub(in crate::commands) struct McpCmd; |
| 24 | |
| 25 | impl RegisterCommand<CommandResult> for McpCmd { |
| 26 | fn info() -> &'static CommandInfo { |
| 27 | &COMMAND_INFO |
| 28 | } |
| 29 | |
| 30 | fn handler() -> CommandHandler<CommandResult> { |
| 31 | CommandHandler::Contextual { |
| 32 | capabilities: CommandCapabilities::PRESENTATION, |
| 33 | handler: mcp_contextual, |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | fn mcp_contextual(contexts: CommandContexts<'_>, args: Option<&str>) -> CommandResult { |
| 39 | let mut parts = contexts.into_parts(); |
| 40 | let Some(presentation) = parts.presentation.as_deref_mut() else { |
| 41 | return CommandResult::error("Command capability unavailable: presentation"); |
| 42 | }; |
| 43 | mcp(presentation, args) |
| 44 | } |
| 45 | |
| 46 | fn mcp(presentation: &mut dyn CommandPresentationContext, args: Option<&str>) -> CommandResult { |
| 47 | let raw = args.unwrap_or("").trim(); |
| 48 | if raw.is_empty() { |
| 49 | return CommandResult::action(AppAction::OpenExtensions { |
| 50 | tab: crate::tui::views::extensions::ExtensionsTab::Mcp, |
| 51 | }); |
| 52 | } |
| 53 | if raw.eq_ignore_ascii_case("status") || raw.eq_ignore_ascii_case("list") { |
| 54 | return CommandResult::action(AppAction::Mcp(McpUiAction::Show)); |
| 55 | } |
| 56 | |
| 57 | let mut parts = raw.split_whitespace(); |
| 58 | let action = parts.next().unwrap_or("").to_ascii_lowercase(); |
| 59 | match action.as_str() { |
| 60 | "init" => CommandResult::action(AppAction::Mcp(McpUiAction::Init { |
| 61 | force: parts.any(|part| part == "--force" || part == "-f"), |
| 62 | })), |
| 63 | "recommend" | "recommended" | "recommendations" => { |
| 64 | CommandResult::message(recommended_mcp_text(presentation)) |
| 65 | } |
| 66 | "add" => parse_add(presentation, parts.collect()), |
| 67 | "enable" => match parse_name(parts.next(), "Usage: /mcp enable <name>") { |
| 68 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Enable { name })), |
| 69 | Err(msg) => CommandResult::error(msg), |
| 70 | }, |
| 71 | "disable" => match parse_name(parts.next(), "Usage: /mcp disable <name>") { |
| 72 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Disable { name })), |
| 73 | Err(msg) => CommandResult::error(msg), |
| 74 | }, |
| 75 | "remove" | "rm" => match parse_name(parts.next(), "Usage: /mcp remove <name>") { |
| 76 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Remove { name })), |
| 77 | Err(msg) => CommandResult::error(msg), |
| 78 | }, |
| 79 | "login" => match parse_name(parts.next(), "Usage: /mcp login <name> [--scope scope]") { |
| 80 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Login { |
| 81 | name, |
| 82 | scopes: parse_scopes(parts.collect()), |
| 83 | })), |
| 84 | Err(msg) => CommandResult::error(msg), |
| 85 | }, |
| 86 | "logout" => match parse_name(parts.next(), "Usage: /mcp logout <name>") { |
| 87 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Logout { name })), |
| 88 | Err(msg) => CommandResult::error(msg), |
| 89 | }, |
| 90 | "retry" => match parse_name(parts.next(), "Usage: /mcp retry <name>") { |
| 91 | Ok(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Retry { name })), |
| 92 | Err(msg) => CommandResult::error(msg), |
| 93 | }, |
| 94 | "import" | "marketplace" | "sources" => { |
| 95 | let sub = parts.next().unwrap_or("").to_ascii_lowercase(); |
| 96 | match sub.as_str() { |
| 97 | "" | "list" | "status" => { |
| 98 | CommandResult::action(AppAction::Mcp(McpUiAction::ImportList)) |
| 99 | } |
| 100 | "approve" | "add" => { |
| 101 | match parse_name(parts.next(), "Usage: /mcp import approve <name>") { |
| 102 | Ok(name) => { |
| 103 | CommandResult::action(AppAction::Mcp(McpUiAction::ImportApprove { |
| 104 | name, |
| 105 | })) |
| 106 | } |
| 107 | Err(msg) => CommandResult::error(msg), |
| 108 | } |
| 109 | } |
| 110 | "decline" | "deny" | "reject" => { |
| 111 | match parse_name(parts.next(), "Usage: /mcp import decline <name>") { |
| 112 | Ok(name) => { |
| 113 | CommandResult::action(AppAction::Mcp(McpUiAction::ImportDecline { |
| 114 | name, |
| 115 | })) |
| 116 | } |
| 117 | Err(msg) => CommandResult::error(msg), |
| 118 | } |
| 119 | } |
| 120 | _ => { |
| 121 | CommandResult::error("Usage: /mcp import [list|approve <name>|decline <name>]") |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | "validate" | "doctor" => match parts.next() { |
| 126 | Some(name) => CommandResult::action(AppAction::Mcp(McpUiAction::Diagnose { |
| 127 | name: name.to_string(), |
| 128 | })), |
| 129 | None => CommandResult::action(AppAction::Mcp(McpUiAction::Validate)), |
| 130 | }, |
| 131 | "reload" | "reconnect" | "restart" => { |
| 132 | CommandResult::action(AppAction::Mcp(McpUiAction::Reload)) |
| 133 | } |
| 134 | _ => CommandResult::error( |
| 135 | "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>|retry <name>|doctor|validate|restart|reload]", |
| 136 | ), |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | fn parse_name(name: Option<&str>, usage: &str) -> Result<String, String> { |
| 141 | match name { |
| 142 | Some(name) if !name.trim().is_empty() => Ok(name.to_string()), |
| 143 | _ => Err(usage.to_string()), |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | fn parse_add(presentation: &mut dyn CommandPresentationContext, parts: Vec<&str>) -> CommandResult { |
| 148 | parse_add_for_platform(presentation, parts, cfg!(windows)) |
| 149 | } |
| 150 | |
| 151 | fn parse_add_for_platform( |
| 152 | presentation: &mut dyn CommandPresentationContext, |
| 153 | parts: Vec<&str>, |
| 154 | windows: bool, |
| 155 | ) -> CommandResult { |
| 156 | if parts |
| 157 | .first() |
| 158 | .is_some_and(|part| part.eq_ignore_ascii_case("recommended")) |
| 159 | { |
| 160 | return match parts.as_slice() { |
| 161 | [_, id] if id.eq_ignore_ascii_case("hugging-face") || id.eq_ignore_ascii_case("hf") => { |
| 162 | CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp { |
| 163 | name: "hugging-face".to_string(), |
| 164 | url: "https://huggingface.co/mcp".to_string(), |
| 165 | transport: None, |
| 166 | })) |
| 167 | } |
| 168 | [_, id] if id.eq_ignore_ascii_case("github") || id.eq_ignore_ascii_case("gh") => { |
| 169 | CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp { |
| 170 | name: "github".to_string(), |
| 171 | url: GITHUB_MCP_URL.to_string(), |
| 172 | transport: None, |
| 173 | })) |
| 174 | } |
| 175 | [_, id] |
| 176 | if id.eq_ignore_ascii_case("chrome-devtools") |
| 177 | || id.eq_ignore_ascii_case("chrome") => |
| 178 | { |
| 179 | CommandResult::action(AppAction::Mcp(McpUiAction::AddStdio { |
| 180 | name: "chrome-devtools".to_string(), |
| 181 | command: recommended_npx_command_for(windows).to_string(), |
| 182 | args: vec!["-y".to_string(), CHROME_DEVTOOLS_MCP_PACKAGE.to_string()], |
| 183 | })) |
| 184 | } |
| 185 | [_, id] if id.eq_ignore_ascii_case("playwright") => { |
| 186 | CommandResult::action(AppAction::Mcp(McpUiAction::AddStdio { |
| 187 | name: "playwright".to_string(), |
| 188 | command: recommended_npx_command_for(windows).to_string(), |
| 189 | args: vec![ |
| 190 | "-y".to_string(), |
| 191 | PLAYWRIGHT_MCP_PACKAGE.to_string(), |
| 192 | "--isolated".to_string(), |
| 193 | ], |
| 194 | })) |
| 195 | } |
| 196 | [_, id] |
| 197 | if id.eq_ignore_ascii_case("container-use") |
| 198 | || id.eq_ignore_ascii_case("container") => |
| 199 | { |
| 200 | CommandResult::action(AppAction::Mcp(McpUiAction::AddStdio { |
| 201 | name: "container-use".to_string(), |
| 202 | command: "container-use".to_string(), |
| 203 | args: vec!["stdio".to_string()], |
| 204 | })) |
| 205 | } |
| 206 | [_, _] => CommandResult::error(mcp_unknown_id(presentation)), |
| 207 | _ => CommandResult::error("Usage: /mcp add recommended <id>"), |
| 208 | }; |
| 209 | } |
| 210 | if parts.len() < 3 { |
| 211 | return CommandResult::error( |
| 212 | "Usage: /mcp add stdio <name> <command> [args...] OR /mcp add http <name> <url>", |
| 213 | ); |
| 214 | } |
| 215 | match parts[0].to_ascii_lowercase().as_str() { |
| 216 | "stdio" => CommandResult::action(AppAction::Mcp(McpUiAction::AddStdio { |
| 217 | name: parts[1].to_string(), |
| 218 | command: parts[2].to_string(), |
| 219 | args: parts[3..].iter().map(|s| (*s).to_string()).collect(), |
| 220 | })), |
| 221 | "http" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp { |
| 222 | name: parts[1].to_string(), |
| 223 | url: parts[2].to_string(), |
| 224 | transport: None, |
| 225 | })), |
| 226 | "sse" => CommandResult::action(AppAction::Mcp(McpUiAction::AddHttp { |
| 227 | name: parts[1].to_string(), |
| 228 | url: parts[2].to_string(), |
| 229 | transport: Some("sse".to_string()), |
| 230 | })), |
| 231 | _ => CommandResult::error( |
| 232 | "Usage: /mcp add stdio <name> <command> [args...] OR /mcp add http <name> <url>", |
| 233 | ), |
| 234 | } |
| 235 | } |
| 236 | |
| 237 | /// Localized unknown-recommendation error through the presentation facet (D3). |
| 238 | fn mcp_unknown_id(presentation: &mut dyn CommandPresentationContext) -> String { |
| 239 | presentation |
| 240 | .translate( |
| 241 | "mcp_recommended_unknown_id", |
| 242 | &[("recommendations_command", "/mcp recommendations")], |
| 243 | ) |
| 244 | .unwrap_or_else(|_| { |
| 245 | "Unknown MCP suggestion. Run /mcp recommendations to see the list.".to_string() |
| 246 | }) |
| 247 | } |
| 248 | |
| 249 | fn recommended_mcp_text(presentation: &mut dyn CommandPresentationContext) -> String { |
| 250 | let heading = presentation |
| 251 | .translate("mcp_recommendations_heading", &[]) |
| 252 | .unwrap_or_else(|_| { |
| 253 | "Suggested Codewhale plugins (MCP components; nothing installs automatically)" |
| 254 | .to_string() |
| 255 | }); |
| 256 | let safety = presentation |
| 257 | .translate( |
| 258 | "mcp_recommendations_safety", |
| 259 | &[("restart_command", "/mcp restart")], |
| 260 | ) |
| 261 | .unwrap_or_else(|_| "Viewing this list adds or enables nothing.".to_string()); |
| 262 | let github = presentation |
| 263 | .translate( |
| 264 | "mcp_recommendation_github", |
| 265 | &[ |
| 266 | ("endpoint", GITHUB_MCP_URL), |
| 267 | ("login_command", "/mcp login github"), |
| 268 | ("add_command", "/mcp add recommended github"), |
| 269 | ], |
| 270 | ) |
| 271 | .unwrap_or_else(|_| { |
| 272 | format!( |
| 273 | "• github — GitHub's official remote MCP endpoint\n endpoint: {GITHUB_MCP_URL}" |
| 274 | ) |
| 275 | }); |
| 276 | let chrome = presentation |
| 277 | .translate( |
| 278 | "mcp_recommendation_chrome", |
| 279 | &[ |
| 280 | ("package", CHROME_DEVTOOLS_MCP_PACKAGE), |
| 281 | ("launcher", "npx/npx.cmd"), |
| 282 | ("restart_command", "/mcp restart"), |
| 283 | ("add_command", "/mcp add recommended chrome-devtools"), |
| 284 | ], |
| 285 | ) |
| 286 | .unwrap_or_else(|_| { |
| 287 | format!("• chrome-devtools — official Chrome DevTools MCP via pinned npm package\n package: {CHROME_DEVTOOLS_MCP_PACKAGE}") |
| 288 | }); |
| 289 | let playwright = presentation |
| 290 | .translate( |
| 291 | "mcp_recommendation_playwright", |
| 292 | &[ |
| 293 | ("package", PLAYWRIGHT_MCP_PACKAGE), |
| 294 | ("source", PLAYWRIGHT_MCP_SOURCE), |
| 295 | ("launcher", "npx/npx.cmd"), |
| 296 | ("restart_command", "/mcp restart"), |
| 297 | ("add_command", "/mcp add recommended playwright"), |
| 298 | ], |
| 299 | ) |
| 300 | .unwrap_or_else(|_| { |
| 301 | format!("• playwright — Microsoft's official Playwright MCP via pinned npm package\n package: {PLAYWRIGHT_MCP_PACKAGE}") |
| 302 | }); |
| 303 | let container_use = presentation |
| 304 | .translate( |
| 305 | "mcp_recommendation_container_use", |
| 306 | &[ |
| 307 | ("source", CONTAINER_USE_SOURCE), |
| 308 | ("restart_command", "/mcp restart"), |
| 309 | ("add_command", "/mcp add recommended container-use"), |
| 310 | ], |
| 311 | ) |
| 312 | .unwrap_or_else(|_| format!("• container-use — Dagger's experimental container-use MCP\n source: {CONTAINER_USE_SOURCE}")); |
| 313 | format!( |
| 314 | "{heading}\n\ |
| 315 | {safety}\n\ |
| 316 | \n\ |
| 317 | • hugging-face — remote Hugging Face MCP endpoint\n\ |
| 318 | provenance: bundled Codewhale recommendation\n\ |
| 319 | add explicitly: /mcp add recommended hugging-face\n\ |
| 320 | then inspect: /mcp doctor · reload all configured servers: /mcp restart\n\ |
| 321 | \n\ |
| 322 | {github}\n\ |
| 323 | \n\ |
| 324 | {chrome}\n\ |
| 325 | \n\ |
| 326 | {playwright}\n\ |
| 327 | \n\ |
| 328 | {container_use}\n\ |
| 329 | \n\ |
| 330 | External sources (~/.claude.json, .mcp.json, marketplace manifests):\n\ |
| 331 | /mcp import — list candidates with provenance (keyboard/mouse status)\n\ |
| 332 | /mcp import approve <name> — create managed connector after consent\n\ |
| 333 | /mcp import decline <name> — durable decline until source content changes\n\ |
| 334 | enabled=false is a hard block and will never import. Nothing is auto-imported." |
| 335 | ) |
| 336 | } |
| 337 | |
| 338 | fn recommended_npx_command_for(windows: bool) -> &'static str { |
| 339 | if windows { "npx.cmd" } else { "npx" } |
| 340 | } |
| 341 | |
| 342 | fn parse_scopes(parts: Vec<&str>) -> Vec<String> { |
| 343 | let mut scopes = Vec::new(); |
| 344 | let mut iter = parts.into_iter(); |
| 345 | while let Some(part) = iter.next() { |
| 346 | if part == "--scope" { |
| 347 | let Some(value) = iter.next() else { |
| 348 | continue; |
| 349 | }; |
| 350 | for scope in value.split(',') { |
| 351 | let scope = scope.trim(); |
| 352 | if !scope.is_empty() { |
| 353 | scopes.push(scope.to_string()); |
| 354 | } |
| 355 | } |
| 356 | continue; |
| 357 | } |
| 358 | let value = part.strip_prefix("--scope="); |
| 359 | let Some(value) = value else { |
| 360 | for scope in part.split(',') { |
| 361 | let scope = scope.trim(); |
| 362 | if !scope.is_empty() { |
| 363 | scopes.push(scope.to_string()); |
| 364 | } |
| 365 | } |
| 366 | continue; |
| 367 | }; |
| 368 | for scope in value.split(',') { |
| 369 | let scope = scope.trim(); |
| 370 | if !scope.is_empty() { |
| 371 | scopes.push(scope.to_string()); |
| 372 | } |
| 373 | } |
| 374 | } |
| 375 | scopes |
| 376 | } |
| 377 | |
| 378 | #[cfg(test)] |
| 379 | mod tests { |
| 380 | use super::*; |
| 381 | |
| 382 | struct FakePresentation; |
| 383 | impl CommandPresentationContext for FakePresentation { |
| 384 | fn translate(&self, key: &str, r: &[(&str, &str)]) -> Result<String, String> { |
| 385 | let mut out = match key { |
| 386 | "mcp_recommended_unknown_id" => { |
| 387 | "Unknown MCP suggestion. Run {recommendations_command} to see the list.".to_string() |
| 388 | } |
| 389 | "mcp_recommendations_heading" => { |
| 390 | "Suggested Codewhale plugins (MCP components; nothing installs automatically)" |
| 391 | .to_string() |
| 392 | } |
| 393 | "mcp_recommendations_safety" => { |
| 394 | "Looking adds nothing. Adding writes config only — review it before {restart_command} connects anything." |
| 395 | .to_string() |
| 396 | } |
| 397 | "mcp_recommendation_github" => { |
| 398 | "• github — GitHub's official remote MCP endpoint\n endpoint: {endpoint}\n auth is separate: use {login_command} only when the server advertises OAuth;\n otherwise configure a least-privilege PAT outside command history.\n add: {add_command}".to_string() |
| 399 | } |
| 400 | "mcp_recommendation_chrome" => { |
| 401 | "• chrome-devtools — official Chrome DevTools MCP via pinned npm package\n package: {package} ({launcher})\n it can inspect/control Chrome and read authenticated pages.\n add: {add_command}".to_string() |
| 402 | } |
| 403 | "mcp_recommendation_playwright" => { |
| 404 | "• playwright — Microsoft's official Playwright MCP via pinned npm package\n package: {package} ({launcher})\n source: {source}\n --isolated starts a fresh browser profile. It can browse/control pages and read authenticated pages.\n add: {add_command}".to_string() |
| 405 | } |
| 406 | "mcp_recommendation_container_use" => { |
| 407 | "• container-use — Dagger's experimental container-use MCP\n command: container-use stdio\n source: {source}\n requires the separately installed container-use binary; Codewhale never downloads or installs this binary.\n add: {add_command}".to_string() |
| 408 | } |
| 409 | _ => return Err("unknown translation key".to_string()), |
| 410 | }; |
| 411 | for (name, value) in r { |
| 412 | out = out.replace(&format!("{{{name}}}"), value); |
| 413 | } |
| 414 | Ok(out) |
| 415 | } |
| 416 | } |
| 417 | |
| 418 | #[test] |
| 419 | fn parses_add_and_validate() { |
| 420 | let add = mcp( |
| 421 | &mut FakePresentation, |
| 422 | Some("add stdio local node server.js"), |
| 423 | ); |
| 424 | assert!(matches!( |
| 425 | add.action, |
| 426 | Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args })) |
| 427 | if name == "local" && command == "node" && args == vec!["server.js".to_string()] |
| 428 | )); |
| 429 | |
| 430 | let validate = mcp(&mut FakePresentation, Some("validate")); |
| 431 | assert!(matches!( |
| 432 | validate.action, |
| 433 | Some(AppAction::Mcp(McpUiAction::Validate)) |
| 434 | )); |
| 435 | |
| 436 | let doctor = mcp(&mut FakePresentation, Some("doctor")); |
| 437 | assert!(matches!( |
| 438 | doctor.action, |
| 439 | Some(AppAction::Mcp(McpUiAction::Validate)) |
| 440 | )); |
| 441 | for command in ["validate github", "doctor github"] { |
| 442 | assert!(matches!( |
| 443 | mcp(&mut FakePresentation, Some(command)).action, |
| 444 | Some(AppAction::Mcp(McpUiAction::Diagnose { name })) if name == "github" |
| 445 | )); |
| 446 | } |
| 447 | let restart = mcp(&mut FakePresentation, Some("restart")); |
| 448 | assert!(matches!( |
| 449 | restart.action, |
| 450 | Some(AppAction::Mcp(McpUiAction::Reload)) |
| 451 | )); |
| 452 | |
| 453 | let recommended = mcp(&mut FakePresentation, Some("recommendations")) |
| 454 | .message |
| 455 | .expect("recommendations text"); |
| 456 | assert!(recommended.contains("nothing installs automatically")); |
| 457 | assert!(recommended.contains("provenance:")); |
| 458 | assert!(recommended.contains("https://api.githubcopilot.com/mcp/")); |
| 459 | assert!(recommended.contains("chrome-devtools-mcp@1.7.0")); |
| 460 | assert!(recommended.contains("@playwright/mcp@0.0.79")); |
| 461 | assert!(recommended.contains("https://github.com/microsoft/playwright-mcp")); |
| 462 | assert!(recommended.contains("https://github.com/dagger/container-use")); |
| 463 | assert!(!recommended.contains("https://github.com/trycua/cua")); |
| 464 | assert!(!recommended.to_ascii_lowercase().contains("cua-driver")); |
| 465 | assert!(recommended.contains("least-privilege PAT outside command history")); |
| 466 | assert!(recommended.contains("read authenticated pages")); |
| 467 | |
| 468 | let unknown = mcp(&mut FakePresentation, Some("add recommended unknown")) |
| 469 | .message |
| 470 | .expect("localized unknown recommendation error"); |
| 471 | assert!(unknown.contains("Unknown MCP suggestion"), "{unknown}"); |
| 472 | |
| 473 | let add_recommended = mcp(&mut FakePresentation, Some("add recommended hugging-face")); |
| 474 | assert!(matches!( |
| 475 | add_recommended.action, |
| 476 | Some(AppAction::Mcp(McpUiAction::AddHttp { name, url, transport: None })) |
| 477 | if name == "hugging-face" && url == "https://huggingface.co/mcp" |
| 478 | )); |
| 479 | |
| 480 | let add_github = mcp(&mut FakePresentation, Some("add recommended github")); |
| 481 | assert!(matches!( |
| 482 | add_github.action, |
| 483 | Some(AppAction::Mcp(McpUiAction::AddHttp { name, url, transport: None })) |
| 484 | if name == "github" && url == GITHUB_MCP_URL |
| 485 | )); |
| 486 | |
| 487 | let add_chrome = mcp( |
| 488 | &mut FakePresentation, |
| 489 | Some("add recommended chrome-devtools"), |
| 490 | ); |
| 491 | assert!(matches!( |
| 492 | add_chrome.action, |
| 493 | Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args })) |
| 494 | if name == "chrome-devtools" |
| 495 | && command == recommended_npx_command_for(cfg!(windows)) |
| 496 | && args == vec!["-y".to_string(), CHROME_DEVTOOLS_MCP_PACKAGE.to_string()] |
| 497 | )); |
| 498 | |
| 499 | let add_playwright = mcp(&mut FakePresentation, Some("add recommended playwright")); |
| 500 | assert!(matches!( |
| 501 | add_playwright.action, |
| 502 | Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args })) |
| 503 | if name == "playwright" |
| 504 | && command == recommended_npx_command_for(cfg!(windows)) |
| 505 | && args == vec![ |
| 506 | "-y".to_string(), |
| 507 | PLAYWRIGHT_MCP_PACKAGE.to_string(), |
| 508 | "--isolated".to_string(), |
| 509 | ] |
| 510 | )); |
| 511 | |
| 512 | let add_container = mcp(&mut FakePresentation, Some("add recommended container-use")); |
| 513 | assert!(matches!( |
| 514 | add_container.action, |
| 515 | Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args })) |
| 516 | if name == "container-use" |
| 517 | && command == "container-use" |
| 518 | && args == vec!["stdio".to_string()] |
| 519 | )); |
| 520 | |
| 521 | let add_cua = mcp(&mut FakePresentation, Some("add recommended cua")); |
| 522 | assert!( |
| 523 | add_cua.is_error, |
| 524 | "Cua is not a Codewhale computer-use recommendation" |
| 525 | ); |
| 526 | assert!( |
| 527 | add_cua |
| 528 | .message |
| 529 | .as_deref() |
| 530 | .is_some_and(|message| message.contains("Unknown MCP suggestion")), |
| 531 | "{:?}", |
| 532 | add_cua.message |
| 533 | ); |
| 534 | |
| 535 | let import_list = mcp(&mut FakePresentation, Some("import")); |
| 536 | assert!(matches!( |
| 537 | import_list.action, |
| 538 | Some(AppAction::Mcp(McpUiAction::ImportList)) |
| 539 | )); |
| 540 | let import_approve = mcp(&mut FakePresentation, Some("import approve local-tools")); |
| 541 | assert!(matches!( |
| 542 | import_approve.action, |
| 543 | Some(AppAction::Mcp(McpUiAction::ImportApprove { name })) |
| 544 | if name == "local-tools" |
| 545 | )); |
| 546 | let import_decline = mcp(&mut FakePresentation, Some("import decline local-tools")); |
| 547 | assert!(matches!( |
| 548 | import_decline.action, |
| 549 | Some(AppAction::Mcp(McpUiAction::ImportDecline { name })) |
| 550 | if name == "local-tools" |
| 551 | )); |
| 552 | let marketplace = mcp(&mut FakePresentation, Some("marketplace")); |
| 553 | assert!(matches!( |
| 554 | marketplace.action, |
| 555 | Some(AppAction::Mcp(McpUiAction::ImportList)) |
| 556 | )); |
| 557 | |
| 558 | let login = mcp( |
| 559 | &mut FakePresentation, |
| 560 | Some("login remote --scope tools/read,tools/write"), |
| 561 | ); |
| 562 | assert!(matches!( |
| 563 | login.action, |
| 564 | Some(AppAction::Mcp(McpUiAction::Login { name, scopes })) |
| 565 | if name == "remote" |
| 566 | && scopes == vec!["tools/read".to_string(), "tools/write".to_string()] |
| 567 | )); |
| 568 | |
| 569 | let retry = mcp(&mut FakePresentation, Some("retry remote")); |
| 570 | assert!(matches!( |
| 571 | retry.action, |
| 572 | Some(AppAction::Mcp(McpUiAction::Retry { name })) if name == "remote" |
| 573 | )); |
| 574 | } |
| 575 | |
| 576 | #[test] |
| 577 | fn recommended_chrome_launcher_is_native_on_unix_and_windows() { |
| 578 | assert_eq!(recommended_npx_command_for(false), "npx"); |
| 579 | assert_eq!(recommended_npx_command_for(true), "npx.cmd"); |
| 580 | |
| 581 | let windows = parse_add_for_platform( |
| 582 | &mut FakePresentation, |
| 583 | vec!["recommended", "playwright"], |
| 584 | true, |
| 585 | ); |
| 586 | assert!(matches!( |
| 587 | windows.action, |
| 588 | Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args })) |
| 589 | if name == "playwright" |
| 590 | && command == "npx.cmd" |
| 591 | && args == vec![ |
| 592 | "-y".to_string(), |
| 593 | PLAYWRIGHT_MCP_PACKAGE.to_string(), |
| 594 | "--isolated".to_string(), |
| 595 | ] |
| 596 | )); |
| 597 | } |
| 598 | |
| 599 | #[test] |
| 600 | fn recommendations_state_execution_and_install_boundaries() { |
| 601 | let text = recommended_mcp_text(&mut FakePresentation); |
| 602 | assert!(text.contains("nothing installs automatically")); |
| 603 | assert!(text.contains("Suggested Codewhale plugins")); |
| 604 | assert!(text.contains("never downloads or")); |
| 605 | assert!(text.contains("installs this binary")); |
| 606 | assert!(text.contains("experimental")); |
| 607 | assert!(text.contains("--isolated")); |
| 608 | assert!(!text.contains("operating-system permissions")); |
| 609 | assert!(!text.to_ascii_lowercase().contains("cua")); |
| 610 | } |
| 611 | |
| 612 | #[test] |
| 613 | fn handler_is_contextual_and_requests_presentation_facet() { |
| 614 | let CommandHandler::Contextual { |
| 615 | capabilities, |
| 616 | handler, |
| 617 | } = McpCmd::handler() |
| 618 | else { |
| 619 | panic!("mcp must be contextual"); |
| 620 | }; |
| 621 | assert_eq!(capabilities, CommandCapabilities::PRESENTATION); |
| 622 | let missing = handler(CommandContexts::empty(), Some("list")); |
| 623 | assert!(missing.is_error); |
| 624 | assert_eq!( |
| 625 | missing.message.as_deref(), |
| 626 | Some("Error: Command capability unavailable: presentation") |
| 627 | ); |
| 628 | assert_eq!(McpCmd::info().description_key, "cmd_mcp_description"); |
| 629 | assert_eq!(McpCmd::info().aliases, &["mcps"]); |
| 630 | } |
| 631 | } |
| 632 |