返回 CodeWhale
mcp.rs
根目录 / crates / tui / src / commands / groups / utility / mcp.rs
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
632 lines RUST