返回 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(|_| "Suggested MCP servers (nothing installs automatically)".to_string());
253 let safety = presentation
254 .translate(
255 "mcp_recommendations_safety",
256 &[("restart_command", "/mcp restart")],
257 )
258 .unwrap_or_else(|_| "Viewing this list adds or enables nothing.".to_string());
259 let github = presentation
260 .translate(
261 "mcp_recommendation_github",
262 &[
263 ("endpoint", GITHUB_MCP_URL),
264 ("login_command", "/mcp login github"),
265 ("add_command", "/mcp add recommended github"),
266 ],
267 )
268 .unwrap_or_else(|_| {
269 format!(
270 "• github — GitHub's official remote MCP endpoint\n endpoint: {GITHUB_MCP_URL}"
271 )
272 });
273 let chrome = presentation
274 .translate(
275 "mcp_recommendation_chrome",
276 &[
277 ("package", CHROME_DEVTOOLS_MCP_PACKAGE),
278 ("launcher", "npx/npx.cmd"),
279 ("restart_command", "/mcp restart"),
280 ("add_command", "/mcp add recommended chrome-devtools"),
281 ],
282 )
283 .unwrap_or_else(|_| {
284 format!("• chrome-devtools — official Chrome DevTools MCP via pinned npm package\n package: {CHROME_DEVTOOLS_MCP_PACKAGE}")
285 });
286 let playwright = presentation
287 .translate(
288 "mcp_recommendation_playwright",
289 &[
290 ("package", PLAYWRIGHT_MCP_PACKAGE),
291 ("source", PLAYWRIGHT_MCP_SOURCE),
292 ("launcher", "npx/npx.cmd"),
293 ("restart_command", "/mcp restart"),
294 ("add_command", "/mcp add recommended playwright"),
295 ],
296 )
297 .unwrap_or_else(|_| {
298 format!("• playwright — Microsoft's official Playwright MCP via pinned npm package\n package: {PLAYWRIGHT_MCP_PACKAGE}")
299 });
300 let container_use = presentation
301 .translate(
302 "mcp_recommendation_container_use",
303 &[
304 ("source", CONTAINER_USE_SOURCE),
305 ("restart_command", "/mcp restart"),
306 ("add_command", "/mcp add recommended container-use"),
307 ],
308 )
309 .unwrap_or_else(|_| format!("• container-use — Dagger's experimental container-use MCP\n source: {CONTAINER_USE_SOURCE}"));
310 format!(
311 "{heading}\n\
312 {safety}\n\
313 \n\
314 • hugging-face — remote Hugging Face MCP endpoint\n\
315 provenance: bundled Codewhale recommendation\n\
316 add explicitly: /mcp add recommended hugging-face\n\
317 then inspect: /mcp doctor · reload all configured servers: /mcp restart\n\
318 \n\
319 {github}\n\
320 \n\
321 {chrome}\n\
322 \n\
323 {playwright}\n\
324 \n\
325 {container_use}\n\
326 \n\
327 External sources (~/.claude.json, .mcp.json, marketplace manifests):\n\
328 /mcp import — list candidates with provenance (keyboard/mouse status)\n\
329 /mcp import approve <name> — create managed connector after consent\n\
330 /mcp import decline <name> — durable decline until source content changes\n\
331 enabled=false is a hard block and will never import. Nothing is auto-imported."
332 )
333 }
334
335 fn recommended_npx_command_for(windows: bool) -> &'static str {
336 if windows { "npx.cmd" } else { "npx" }
337 }
338
339 fn parse_scopes(parts: Vec<&str>) -> Vec<String> {
340 let mut scopes = Vec::new();
341 let mut iter = parts.into_iter();
342 while let Some(part) = iter.next() {
343 if part == "--scope" {
344 let Some(value) = iter.next() else {
345 continue;
346 };
347 for scope in value.split(',') {
348 let scope = scope.trim();
349 if !scope.is_empty() {
350 scopes.push(scope.to_string());
351 }
352 }
353 continue;
354 }
355 let value = part.strip_prefix("--scope=");
356 let Some(value) = value else {
357 for scope in part.split(',') {
358 let scope = scope.trim();
359 if !scope.is_empty() {
360 scopes.push(scope.to_string());
361 }
362 }
363 continue;
364 };
365 for scope in value.split(',') {
366 let scope = scope.trim();
367 if !scope.is_empty() {
368 scopes.push(scope.to_string());
369 }
370 }
371 }
372 scopes
373 }
374
375 #[cfg(test)]
376 mod tests {
377 use super::*;
378
379 struct FakePresentation;
380 impl CommandPresentationContext for FakePresentation {
381 fn translate(&self, key: &str, r: &[(&str, &str)]) -> Result<String, String> {
382 let mut out = match key {
383 "mcp_recommended_unknown_id" => {
384 "Unknown MCP suggestion. Run {recommendations_command} to see the list.".to_string()
385 }
386 "mcp_recommendations_heading" => {
387 "Suggested MCP servers (nothing installs automatically)".to_string()
388 }
389 "mcp_recommendations_safety" => {
390 "Looking adds nothing. Adding writes config only — review it before {restart_command} connects anything."
391 .to_string()
392 }
393 "mcp_recommendation_github" => {
394 "• 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()
395 }
396 "mcp_recommendation_chrome" => {
397 "• 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()
398 }
399 "mcp_recommendation_playwright" => {
400 "• 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()
401 }
402 "mcp_recommendation_container_use" => {
403 "• 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()
404 }
405 _ => return Err("unknown translation key".to_string()),
406 };
407 for (name, value) in r {
408 out = out.replace(&format!("{{{name}}}"), value);
409 }
410 Ok(out)
411 }
412 }
413
414 #[test]
415 fn parses_add_and_validate() {
416 let add = mcp(
417 &mut FakePresentation,
418 Some("add stdio local node server.js"),
419 );
420 assert!(matches!(
421 add.action,
422 Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args }))
423 if name == "local" && command == "node" && args == vec!["server.js".to_string()]
424 ));
425
426 let validate = mcp(&mut FakePresentation, Some("validate"));
427 assert!(matches!(
428 validate.action,
429 Some(AppAction::Mcp(McpUiAction::Validate))
430 ));
431
432 let doctor = mcp(&mut FakePresentation, Some("doctor"));
433 assert!(matches!(
434 doctor.action,
435 Some(AppAction::Mcp(McpUiAction::Validate))
436 ));
437 for command in ["validate github", "doctor github"] {
438 assert!(matches!(
439 mcp(&mut FakePresentation, Some(command)).action,
440 Some(AppAction::Mcp(McpUiAction::Diagnose { name })) if name == "github"
441 ));
442 }
443 let restart = mcp(&mut FakePresentation, Some("restart"));
444 assert!(matches!(
445 restart.action,
446 Some(AppAction::Mcp(McpUiAction::Reload))
447 ));
448
449 let recommended = mcp(&mut FakePresentation, Some("recommendations"))
450 .message
451 .expect("recommendations text");
452 assert!(recommended.contains("nothing installs automatically"));
453 assert!(recommended.contains("provenance:"));
454 assert!(recommended.contains("https://api.githubcopilot.com/mcp/"));
455 assert!(recommended.contains("chrome-devtools-mcp@1.7.0"));
456 assert!(recommended.contains("@playwright/mcp@0.0.79"));
457 assert!(recommended.contains("https://github.com/microsoft/playwright-mcp"));
458 assert!(recommended.contains("https://github.com/dagger/container-use"));
459 assert!(!recommended.contains("https://github.com/trycua/cua"));
460 assert!(!recommended.to_ascii_lowercase().contains("cua-driver"));
461 assert!(recommended.contains("least-privilege PAT outside command history"));
462 assert!(recommended.contains("read authenticated pages"));
463
464 let unknown = mcp(&mut FakePresentation, Some("add recommended unknown"))
465 .message
466 .expect("localized unknown recommendation error");
467 assert!(unknown.contains("Unknown MCP suggestion"), "{unknown}");
468
469 let add_recommended = mcp(&mut FakePresentation, Some("add recommended hugging-face"));
470 assert!(matches!(
471 add_recommended.action,
472 Some(AppAction::Mcp(McpUiAction::AddHttp { name, url, transport: None }))
473 if name == "hugging-face" && url == "https://huggingface.co/mcp"
474 ));
475
476 let add_github = mcp(&mut FakePresentation, Some("add recommended github"));
477 assert!(matches!(
478 add_github.action,
479 Some(AppAction::Mcp(McpUiAction::AddHttp { name, url, transport: None }))
480 if name == "github" && url == GITHUB_MCP_URL
481 ));
482
483 let add_chrome = mcp(
484 &mut FakePresentation,
485 Some("add recommended chrome-devtools"),
486 );
487 assert!(matches!(
488 add_chrome.action,
489 Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args }))
490 if name == "chrome-devtools"
491 && command == recommended_npx_command_for(cfg!(windows))
492 && args == vec!["-y".to_string(), CHROME_DEVTOOLS_MCP_PACKAGE.to_string()]
493 ));
494
495 let add_playwright = mcp(&mut FakePresentation, Some("add recommended playwright"));
496 assert!(matches!(
497 add_playwright.action,
498 Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args }))
499 if name == "playwright"
500 && command == recommended_npx_command_for(cfg!(windows))
501 && args == vec![
502 "-y".to_string(),
503 PLAYWRIGHT_MCP_PACKAGE.to_string(),
504 "--isolated".to_string(),
505 ]
506 ));
507
508 let add_container = mcp(&mut FakePresentation, Some("add recommended container-use"));
509 assert!(matches!(
510 add_container.action,
511 Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args }))
512 if name == "container-use"
513 && command == "container-use"
514 && args == vec!["stdio".to_string()]
515 ));
516
517 let add_cua = mcp(&mut FakePresentation, Some("add recommended cua"));
518 assert!(
519 add_cua.is_error,
520 "Cua is not a Codewhale computer-use recommendation"
521 );
522 assert!(
523 add_cua
524 .message
525 .as_deref()
526 .is_some_and(|message| message.contains("Unknown MCP suggestion")),
527 "{:?}",
528 add_cua.message
529 );
530
531 let import_list = mcp(&mut FakePresentation, Some("import"));
532 assert!(matches!(
533 import_list.action,
534 Some(AppAction::Mcp(McpUiAction::ImportList))
535 ));
536 let import_approve = mcp(&mut FakePresentation, Some("import approve local-tools"));
537 assert!(matches!(
538 import_approve.action,
539 Some(AppAction::Mcp(McpUiAction::ImportApprove { name }))
540 if name == "local-tools"
541 ));
542 let import_decline = mcp(&mut FakePresentation, Some("import decline local-tools"));
543 assert!(matches!(
544 import_decline.action,
545 Some(AppAction::Mcp(McpUiAction::ImportDecline { name }))
546 if name == "local-tools"
547 ));
548 let marketplace = mcp(&mut FakePresentation, Some("marketplace"));
549 assert!(matches!(
550 marketplace.action,
551 Some(AppAction::Mcp(McpUiAction::ImportList))
552 ));
553
554 let login = mcp(
555 &mut FakePresentation,
556 Some("login remote --scope tools/read,tools/write"),
557 );
558 assert!(matches!(
559 login.action,
560 Some(AppAction::Mcp(McpUiAction::Login { name, scopes }))
561 if name == "remote"
562 && scopes == vec!["tools/read".to_string(), "tools/write".to_string()]
563 ));
564
565 let retry = mcp(&mut FakePresentation, Some("retry remote"));
566 assert!(matches!(
567 retry.action,
568 Some(AppAction::Mcp(McpUiAction::Retry { name })) if name == "remote"
569 ));
570 }
571
572 #[test]
573 fn recommended_chrome_launcher_is_native_on_unix_and_windows() {
574 assert_eq!(recommended_npx_command_for(false), "npx");
575 assert_eq!(recommended_npx_command_for(true), "npx.cmd");
576
577 let windows = parse_add_for_platform(
578 &mut FakePresentation,
579 vec!["recommended", "playwright"],
580 true,
581 );
582 assert!(matches!(
583 windows.action,
584 Some(AppAction::Mcp(McpUiAction::AddStdio { name, command, args }))
585 if name == "playwright"
586 && command == "npx.cmd"
587 && args == vec![
588 "-y".to_string(),
589 PLAYWRIGHT_MCP_PACKAGE.to_string(),
590 "--isolated".to_string(),
591 ]
592 ));
593 }
594
595 #[test]
596 fn recommendations_state_execution_and_install_boundaries() {
597 let text = recommended_mcp_text(&mut FakePresentation);
598 assert!(text.contains("nothing installs automatically"));
599 assert!(text.contains("Suggested MCP servers"));
600 assert!(text.contains("never downloads or"));
601 assert!(text.contains("installs this binary"));
602 assert!(text.contains("experimental"));
603 assert!(text.contains("--isolated"));
604 assert!(!text.contains("operating-system permissions"));
605 assert!(!text.to_ascii_lowercase().contains("cua"));
606 }
607
608 #[test]
609 fn handler_is_contextual_and_requests_presentation_facet() {
610 let CommandHandler::Contextual {
611 capabilities,
612 handler,
613 } = McpCmd::handler()
614 else {
615 panic!("mcp must be contextual");
616 };
617 assert_eq!(capabilities, CommandCapabilities::PRESENTATION);
618 let missing = handler(CommandContexts::empty(), Some("list"));
619 assert!(missing.is_error);
620 assert_eq!(
621 missing.message.as_deref(),
622 Some("Error: Command capability unavailable: presentation")
623 );
624 assert_eq!(McpCmd::info().description_key, "cmd_mcp_description");
625 assert_eq!(McpCmd::info().aliases, &["mcps"]);
626 }
627 }
628
628 lines RUST