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