返回 CodeWhale
runtime_mcp.rs
根目录 / crates / tui / src / tools / runtime_mcp.rs
1 //! Runtime MCP server management.
2 //!
3 //! Provides `StartRuntimeMcpServer` — the entry tool for LLM to dynamically
4 //! connect to MCP servers from conversation context. Also contains parsing
5 //! and naming helpers used by the tool.
6
7 use std::collections::HashMap;
8 use std::sync::Arc;
9
10 use anyhow::Result;
11 use serde_json::{Value, json};
12 use tokio::sync::Mutex as AsyncMutex;
13
14 use crate::mcp::{McpPool, McpServerConfig};
15 use crate::tools::spec::{
16 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
17 };
18
19 // === Parsing Functions ===
20
21 #[derive(Debug, Clone)]
22 pub struct ParsedMcpServer {
23 pub name: String,
24 pub config: McpServerConfig,
25 }
26
27 /// Parse a command string or URL into an MCP server configuration.
28 ///
29 /// - Local command: `npx @modelcontextprotocol/server-filesystem /tmp`
30 /// - Remote URL: `https://huggingface.co/mcp`
31 pub fn parse_mcp_command(input: &str) -> Result<ParsedMcpServer> {
32 let input = input.trim();
33 if input.is_empty() {
34 anyhow::bail!("MCP command cannot be empty");
35 }
36
37 if input.starts_with("http://") || input.starts_with("https://") {
38 let name = extract_name_from_url(input)?;
39 return Ok(ParsedMcpServer {
40 name,
41 config: McpServerConfig {
42 command: None,
43 args: Vec::new(),
44 env: HashMap::new(),
45 cwd: None,
46 url: Some(input.to_string()),
47 transport: None,
48 connect_timeout: None,
49 execute_timeout: None,
50 read_timeout: None,
51 disabled: false,
52 enabled: true,
53 required: false,
54 enabled_tools: Vec::new(),
55 disabled_tools: Vec::new(),
56 headers: HashMap::new(),
57 env_headers: HashMap::new(),
58 bearer_token_env_var: None,
59 scopes: Vec::new(),
60 oauth: None,
61 oauth_resource: None,
62 reviewed_plugin: None,
63 runtime_added: false,
64 allow_private_network: false,
65 },
66 });
67 }
68
69 let parts: Vec<String> = shell_words::split(input).unwrap_or_default();
70 if parts.is_empty() {
71 anyhow::bail!("MCP command cannot be empty");
72 }
73
74 let command = parts[0].clone();
75 let args: Vec<String> = parts[1..].to_vec();
76 let name = infer_server_name(&command, &args)?;
77
78 Ok(ParsedMcpServer {
79 name,
80 config: McpServerConfig {
81 command: Some(command),
82 args,
83 env: HashMap::new(),
84 cwd: None,
85 url: None,
86 transport: None,
87 connect_timeout: None,
88 execute_timeout: None,
89 read_timeout: None,
90 disabled: false,
91 enabled: true,
92 required: false,
93 enabled_tools: Vec::new(),
94 disabled_tools: Vec::new(),
95 headers: HashMap::new(),
96 env_headers: HashMap::new(),
97 bearer_token_env_var: None,
98 scopes: Vec::new(),
99 oauth: None,
100 oauth_resource: None,
101 reviewed_plugin: None,
102 runtime_added: false,
103 allow_private_network: false,
104 },
105 })
106 }
107
108 pub fn extract_name_from_url(url: &str) -> Result<String> {
109 let parsed = reqwest::Url::parse(url)?;
110 let host = parsed.host_str().unwrap_or("remote");
111 let path = parsed.path().trim_matches('/');
112
113 // Replace dots with dashes in hostname for better readability
114 let host_part = host.replace('.', "-");
115
116 // Combine host and path, replacing slashes with underscores
117 let name = if path.is_empty() {
118 host_part
119 } else {
120 format!("{}_{}", host_part, path.replace('/', "_"))
121 };
122
123 Ok(sanitize_name(&name))
124 }
125
126 fn infer_server_name(command: &str, args: &[String]) -> Result<String> {
127 let cmd_path = std::path::Path::new(command);
128 let cmd_base = cmd_path.file_stem().unwrap_or_default().to_string_lossy();
129
130 // Windows cmd /c prefix: skip "cmd /c" and recurse on the remaining args
131 // e.g. ["cmd", "/c", "npx", "-y", "@modelcontextprotocol/server-memory"]
132 if cmd_base.as_ref() == "cmd"
133 && args.len() >= 2
134 && (args[0] == "/c" || args[0] == "/C" || args[0] == "/k" || args[0] == "/K")
135 {
136 let inner_cmd = &args[1];
137 let inner_args: Vec<String> = args[2..].to_vec();
138 return infer_server_name(inner_cmd, &inner_args);
139 }
140
141 // Package managers: extract the package name (first non-flag arg)
142 if matches!(
143 cmd_base.as_ref(),
144 "npx" | "npm" | "pnpm" | "yarn" | "bunx" | "bun"
145 ) {
146 for arg in args {
147 if !arg.starts_with('-') && arg != "exec" && arg != "run" && arg != "start" {
148 // e.g. "@modelcontextprotocol/server-filesystem" → "filesystem"
149 if let Some(name) = arg.split('/').next_back() {
150 if let Some(short) = name.strip_prefix("server-") {
151 return Ok(sanitize_name(short));
152 }
153 return Ok(sanitize_name(name));
154 }
155 }
156 }
157 }
158
159 // Script interpreters: extract the script path (first non-flag arg)
160 if matches!(
161 cmd_base.as_ref(),
162 "node" | "python" | "python3" | "uvx" | "uv" | "ruby" | "deno"
163 ) && let Some(script) = args.iter().find(|a| !a.starts_with('-'))
164 {
165 let script_path = std::path::Path::new(script);
166 if let Some(stem) = script_path.file_stem() {
167 return Ok(sanitize_name(&stem.to_string_lossy()));
168 }
169 }
170
171 // Fallback: first non-flag argument (script or file)
172 if let Some(script) = args.iter().find(|a| !a.starts_with('-')) {
173 let script_path = std::path::Path::new(script);
174 if let Some(stem) = script_path.file_stem() {
175 return Ok(sanitize_name(&stem.to_string_lossy()));
176 }
177 }
178
179 // Last resort: command name itself
180 Ok(sanitize_name(&cmd_base))
181 }
182
183 pub fn sanitize_name(name: &str) -> String {
184 name.chars()
185 .map(|c| {
186 if c.is_ascii_alphanumeric() || c == '-' {
187 c
188 } else {
189 '-'
190 }
191 })
192 .collect::<String>()
193 .trim_matches('-')
194 .to_string()
195 }
196
197 // === Tool: StartRuntimeMcpServer ===
198
199 /// Entry tool for dynamically adding MCP servers from conversation context.
200 ///
201 /// LLM calls this to start a local MCP server (stdio) or connect to a remote
202 /// one (HTTP). The server config is added to `McpPool.dynamic_servers` and
203 /// tools are discovered via the existing `McpConnection` / `StdioTransport` flow.
204 pub struct StartRuntimeMcpServer {
205 pool: Arc<AsyncMutex<McpPool>>,
206 }
207
208 impl StartRuntimeMcpServer {
209 pub fn new(pool: Arc<AsyncMutex<McpPool>>) -> Self {
210 Self { pool }
211 }
212 }
213
214 #[async_trait::async_trait]
215 impl ToolSpec for StartRuntimeMcpServer {
216 fn name(&self) -> &str {
217 "start_mcp_server"
218 }
219
220 fn description(&self) -> &str {
221 "When a user provides an MCP server command (like 'npx ...') or URL \
222 (like 'https://...'), call this tool immediately to start the server \
223 and register its tools. Do NOT suggest editing config files. \
224 Accepts a local command (stdio) or a remote URL (HTTP/SSE). \
225 To reconnect an existing configured server after login, pass only its exact name \
226 and omit server; this keeps its saved credentials and configuration. \
227 After the server starts, the response lists each tool's callable name. \
228 You MUST copy those exact names when calling the tools. \
229 Do NOT construct or guess tool names yourself."
230 }
231
232 fn input_schema(&self) -> Value {
233 json!({
234 "type": "object",
235 "properties": {
236 "server": {
237 "type": "string",
238 "description": "New MCP server command or URL; omit to reconnect a configured server by name"
239 },
240 "name": {
241 "type": "string",
242 "description": "Exact configured name for reconnect; optional name for a new server"
243 }
244 },
245 "anyOf": [{"required": ["server"]}, {"required": ["name"]}]
246 })
247 }
248
249 fn capabilities(&self) -> Vec<ToolCapability> {
250 vec![ToolCapability::Network, ToolCapability::ExecutesCode]
251 }
252
253 fn approval_requirement(&self) -> ApprovalRequirement {
254 ApprovalRequirement::Required
255 }
256
257 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
258 let custom_name = input.get("name").and_then(|v| v.as_str());
259 if input.get("server").is_none() {
260 let name = custom_name
261 .filter(|name| !name.trim().is_empty())
262 .ok_or_else(|| {
263 ToolError::invalid_input("Provide server or an existing configured name")
264 })?;
265 // The exact configured key owns its credentials and trust. Do not
266 // sanitize it into an alias, replace its config, or reconnect siblings.
267 if McpPool::server_denied_by(&context.disallowed_tools, name) {
268 return Err(ToolError::not_available(format!(
269 "Failed to find MCP server: {name}"
270 )));
271 }
272 let mut pool = self.pool.lock().await;
273 let conn = pool.retry_connection(name).await.map_err(|error| {
274 ToolError::execution_failed(connect_failure_message(name, &error))
275 })?;
276 let transport = if conn.config().url.is_some() {
277 "http"
278 } else {
279 "stdio"
280 };
281 return Ok(connected_result(&pool, name, transport, context));
282 }
283 let server = input
284 .get("server")
285 .and_then(|v| v.as_str())
286 .ok_or_else(|| ToolError::invalid_input("server must be a command or URL string"))?;
287 let mut parsed =
288 parse_mcp_command(server).map_err(|e| ToolError::invalid_input(e.to_string()))?;
289 // Host-supplied override (used by the Registry launcher, whose
290 // packages cold-start via npx/uvx downloads). Not exposed on the
291 // model-facing schema, so the model cannot widen its own timeouts.
292 if let Some(timeout) = input.get("connect_timeout").and_then(Value::as_u64) {
293 parsed.config.connect_timeout = Some(timeout);
294 }
295
296 // Reject shell-wrapped commands that could execute arbitrary code
297 if let Some(ref cmd) = parsed.config.command {
298 let cmd_lower = cmd.to_lowercase();
299 if cmd_lower == "bash"
300 || cmd_lower == "sh"
301 || cmd_lower == "zsh"
302 || cmd_lower == "cmd"
303 || cmd_lower == "powershell"
304 {
305 return Err(ToolError::invalid_input(format!(
306 "Shell wrapper commands ({cmd}) are not allowed. \
307 Provide the actual MCP server command directly, \
308 e.g. 'npx @modelcontextprotocol/server-filesystem /tmp'"
309 )));
310 }
311 }
312
313 // Reject shell metacharacters in arguments to prevent injection.
314 // Extracted to `reject_shell_metacharacters` so it is reachable from
315 // tests: the `reject_metachar_*` tests used to assert only that their
316 // own input string contained the metacharacter and never that this
317 // guard refused it, so deleting the guard left them green
318 // (2026-08-04 audit).
319 reject_shell_metacharacters(&parsed.config.args)?;
320
321 // Allowlist of known MCP server runtimes and package managers.
322 // Commands not in this list are rejected to prevent arbitrary execution.
323 if let Some(ref cmd) = parsed.config.command {
324 let cmd_base = std::path::Path::new(cmd)
325 .file_stem()
326 .unwrap_or_default()
327 .to_string_lossy()
328 .to_lowercase();
329 const ALLOWED_COMMANDS: &[&str] = &[
330 "npx", "npm", "pnpm", "yarn", "bunx", "bun", "node", "python", "python3", "uvx",
331 "uv", "deno", "ruby", "cargo",
332 ];
333 if !ALLOWED_COMMANDS.contains(&cmd_base.as_ref()) {
334 return Err(ToolError::invalid_input(format!(
335 "Command '{cmd}' is not in the allowed list. \
336 Permitted commands: {}",
337 ALLOWED_COMMANDS.join(", ")
338 )));
339 }
340 }
341
342 let server_name = custom_name
343 .map(sanitize_name)
344 .unwrap_or(parsed.name)
345 .replace('_', "-");
346
347 // Underscores in server names would cause tool name collision.
348 // Tool names are formatted as mcp_{server}_{tool}; underscores in
349 // server names would make it ambiguous (server "foo" + tool "bar_x"
350 // vs server "foo_bar" + tool "x" both → mcp_foo_bar_x).
351 // sanitize_name already converts non-alphanumeric chars to hyphens,
352 // but underscores from the original input need explicit conversion.
353
354 let transport = if parsed.config.url.is_some() {
355 "http"
356 } else {
357 "stdio"
358 };
359
360 // Ancestor restrictions stay request-local because children share the pool.
361 if McpPool::server_denied_by(&context.disallowed_tools, &server_name) {
362 return Err(ToolError::not_available(format!(
363 "Failed to find MCP server: {server_name}"
364 )));
365 }
366 // Register server config, connect, and collect tool info
367 let mut pool = self.pool.lock().await;
368 pool.add_runtime_server_config(server_name.clone(), parsed.config)
369 .map_err(ToolError::invalid_input)?;
370 let conn = match pool.get_or_connect(&server_name).await {
371 Ok(conn) => conn,
372 Err(error) => {
373 let message = connect_failure_message(&server_name, &error);
374 pool.remove_runtime_server_config(&server_name);
375 return Err(ToolError::execution_failed(message));
376 }
377 };
378
379 let _ = conn;
380 Ok(connected_result(&pool, &server_name, transport, context))
381 }
382 }
383
384 /// Shared receipt for both new servers and configured-name reconnects.
385 fn connected_result(
386 pool: &McpPool,
387 server_name: &str,
388 transport: &str,
389 context: &ToolContext,
390 ) -> ToolResult {
391 let owners = pool.resolved_tool_servers();
392 let tools_list: Vec<String> = pool
393 .all_tools()
394 .into_iter()
395 .filter(|(name, _)| {
396 owners.get(name).map(String::as_str) == Some(server_name)
397 && !crate::core::engine::tool_catalog::tool_matches_any_rule(
398 &context.disallowed_tools,
399 name,
400 )
401 })
402 .map(|(name, tool)| {
403 format!(
404 "- {} → {}",
405 name,
406 tool.description.as_deref().unwrap_or("no description")
407 )
408 })
409 .collect();
410 let result = serde_json::to_string(&json!({
411 "status": "connected",
412 "transport": transport,
413 "server": server_name,
414 "new_tools": tools_list.len(),
415 "total_mcp_tools": pool.all_tools().iter().filter(|(name, _)| !crate::core::engine::tool_catalog::tool_matches_any_rule(&context.disallowed_tools, name)).count(),
416 "message": format!(
417 "MCP server '{}' connected via {}. {} tools discovered.\n\nCallable tools (use these exact names):\n{}",
418 server_name, transport, tools_list.len(), tools_list.join("\n")
419 )
420 })).unwrap_or_else(|_| "{}".to_string());
421 let mut output = ToolResult::success(result);
422 output.metadata = Some(json!({ "mcp_catalog_changed": true }));
423 output
424 }
425
426 /// Refuse MCP server arguments carrying shell metacharacters.
427 ///
428 /// Redirects (`>`), pipes (`|`), chaining (`;`, `&`), subshells (`` ` ``), and
429 /// variable expansion (`$`) are all dangerous in an argv that may reach a
430 /// shell. Kept as a free function rather than inline in `execute` so it is
431 /// directly testable: the `reject_metachar_*` tests previously asserted only
432 /// that their own input contained the metacharacter, so deleting the guard
433 /// left every one of them green (2026-08-04 audit).
434 fn reject_shell_metacharacters(args: &[String]) -> Result<(), ToolError> {
435 for arg in args {
436 if arg.contains(['>', '|', ';', '&', '`', '$']) {
437 return Err(ToolError::invalid_input(format!(
438 "Argument contains shell metacharacters: '{arg}'. \
439 MCP server arguments must not contain redirects, pipes, \
440 command chaining, or variable expansion."
441 )));
442 }
443 }
444 Ok(())
445 }
446
447 /// Build the connect-failure message returned to the model. A spawned
448 /// package that prints its CLI help and exits (the classic
449 /// missing-subcommand case, e.g. `npx -y agentic-mermaid@0.1.2` without
450 /// `mcp`) surfaces as `Stdio transport closed` before the handshake
451 /// completes — a bare transport error gives the model no signal about
452 /// *why*, and it tends to abandon the MCP route after one failed server.
453 /// Classify that early-exit shape, note when the captured output looks
454 /// like usage help, and point recovery at the registry: verify the exact
455 /// structured arguments returned by `registry_sync`, then fall through to
456 /// the next candidate from the search results instead of giving up.
457 fn connect_failure_message(server_name: &str, err: &anyhow::Error) -> String {
458 let text = format!("{err:#}");
459 let base = format!("Failed to connect to MCP server '{server_name}': {text}");
460 let early_exit =
461 text.contains("Stdio transport closed") || text.contains("Stdio transport read error");
462 if !early_exit {
463 return base;
464 }
465 let looks_like_help = text.contains("usage")
466 || text.contains("Usage")
467 || text.contains("--help")
468 || text.contains("Commands:");
469 let help_note = if looks_like_help {
470 " Its output above looks like CLI usage help."
471 } else {
472 ""
473 };
474 format!(
475 "{base}\n\nThe server process exited before completing the MCP handshake.{help_note} The launch arguments are usually incomplete in this case (missing subcommand or required argument). For Registry-discovered servers, verify the structured required_args returned by registry_sync and retry; if this server still will not start, try the next candidate from the Registry catalog."
476 )
477 }
478
479 #[cfg(test)]
480 mod tests {
481 use super::*;
482
483 #[tokio::test]
484 async fn mcp_ceiling_runtime_registration_respects_child_policy_before_connection() {
485 let directory = tempfile::tempdir().unwrap();
486 let pool = Arc::new(AsyncMutex::new(McpPool::new(
487 crate::mcp::McpConfig::default(),
488 )));
489 let tool = StartRuntimeMcpServer::new(Arc::clone(&pool));
490 let mut context = ToolContext::new(directory.path());
491 context.disallowed_tools = vec!["mcp_private-*".to_string()];
492 // The command would execute if the child ceiling were ignored.
493 let error = tool
494 .execute(
495 json!({"server":"node nonexistent-mcp.js", "name":"private_a"}),
496 &context,
497 )
498 .await
499 .unwrap_err();
500 assert!(
501 error
502 .to_string()
503 .contains("Failed to find MCP server: private-a")
504 );
505 assert!(pool.lock().await.server_names().is_empty());
506 assert!(pool.lock().await.connected_servers().is_empty());
507 }
508
509 #[test]
510 fn parse_command_stdio() {
511 let parsed = parse_mcp_command("npx @modelcontextprotocol/server-filesystem /tmp").unwrap();
512 assert!(parsed.config.command.is_some());
513 assert!(parsed.config.url.is_none());
514 }
515
516 #[test]
517 fn parse_command_url() {
518 let parsed = parse_mcp_command("https://huggingface.co/mcp").unwrap();
519 assert!(parsed.config.command.is_none());
520 assert!(parsed.config.url.is_some());
521 assert_eq!(parsed.name, "huggingface-co-mcp");
522 }
523
524 #[test]
525 fn parse_command_url_with_subdomain() {
526 let parsed = parse_mcp_command("https://api.example.com/mcp").unwrap();
527 assert!(parsed.config.command.is_none());
528 assert!(parsed.config.url.is_some());
529 assert_eq!(parsed.name, "api-example-com-mcp");
530 }
531
532 #[test]
533 fn parse_command_empty() {
534 assert!(parse_mcp_command("").is_err());
535 assert!(parse_mcp_command(" ").is_err());
536 }
537
538 #[test]
539 fn extract_name_from_url_with_path() {
540 assert_eq!(
541 extract_name_from_url("https://huggingface.co/mcp").unwrap(),
542 "huggingface-co-mcp"
543 );
544 }
545
546 #[test]
547 fn extract_name_from_url_with_subdomain() {
548 assert_eq!(
549 extract_name_from_url("https://api.example.com/mcp").unwrap(),
550 "api-example-com-mcp"
551 );
552 }
553
554 #[test]
555 fn extract_name_from_url_no_path() {
556 assert_eq!(
557 extract_name_from_url("https://example.com").unwrap(),
558 "example-com"
559 );
560 }
561
562 #[test]
563 fn extract_name_from_url_empty_path() {
564 assert_eq!(
565 extract_name_from_url("https://example.com/").unwrap(),
566 "example-com"
567 );
568 }
569
570 #[test]
571 fn connect_failure_message_flags_early_exit_with_help_output() {
572 let err = anyhow::anyhow!(
573 "Stdio transport closed\nMCP server stderr (last 2 lines):\nUsage: agentic-mermaid [OPTIONS] <COMMAND>"
574 );
575 let msg = connect_failure_message("agentic-mermaid", &err);
576 assert!(msg.contains("Failed to connect to MCP server 'agentic-mermaid'"));
577 assert!(msg.contains("exited before completing the MCP handshake"));
578 assert!(msg.contains("looks like CLI usage help"));
579 assert!(msg.contains("required_args"));
580 assert!(msg.contains("next candidate"));
581 }
582
583 #[test]
584 fn connect_failure_message_flags_early_exit_without_help_output() {
585 let err = anyhow::anyhow!("Stdio transport closed");
586 let msg = connect_failure_message("x", &err);
587 assert!(msg.contains("exited before completing the MCP handshake"));
588 assert!(!msg.contains("usage help"));
589 assert!(msg.contains("required_args"));
590 }
591
592 #[test]
593 fn connect_failure_message_passes_other_errors_through() {
594 let err = anyhow::anyhow!("connection refused");
595 let msg = connect_failure_message("x", &err);
596 assert_eq!(
597 msg,
598 "Failed to connect to MCP server 'x': connection refused"
599 );
600 }
601
602 // === shell_words split tests ===
603
604 #[test]
605 fn shell_words_simple() {
606 assert_eq!(
607 shell_words::split("npx server /tmp").unwrap(),
608 vec!["npx", "server", "/tmp"]
609 );
610 }
611
612 #[test]
613 fn shell_words_double_quotes() {
614 assert_eq!(
615 shell_words::split(r#"npx server --env="MY KEY""#).unwrap(),
616 vec!["npx", "server", "--env=MY KEY"]
617 );
618 }
619
620 #[test]
621 fn shell_words_single_quotes() {
622 assert_eq!(
623 shell_words::split("npx server --env='MY KEY'").unwrap(),
624 vec!["npx", "server", "--env=MY KEY"]
625 );
626 }
627
628 #[test]
629 fn shell_words_mixed_quotes() {
630 assert_eq!(
631 shell_words::split(r#"cmd --opt="hello world" --flag 'single'"#).unwrap(),
632 vec!["cmd", "--opt=hello world", "--flag", "single"]
633 );
634 }
635
636 #[test]
637 fn shell_words_escaped_quote() {
638 assert_eq!(
639 shell_words::split(r#"cmd arg\"with\"quotes"#).unwrap(),
640 vec!["cmd", r#"arg"with"quotes"#]
641 );
642 }
643
644 #[test]
645 fn shell_words_empty() {
646 assert!(shell_words::split("").unwrap().is_empty());
647 assert!(shell_words::split(" ").unwrap().is_empty());
648 }
649
650 #[test]
651 fn shell_words_postgres_url() {
652 assert_eq!(
653 shell_words::split(
654 r#"npx -y @modelcontextprotocol/server-postgres "postgresql://user:pass@host/db""#
655 )
656 .unwrap(),
657 vec![
658 "npx",
659 "-y",
660 "@modelcontextprotocol/server-postgres",
661 "postgresql://user:pass@host/db"
662 ]
663 );
664 }
665
666 #[test]
667 fn parse_command_with_quoted_args() {
668 let parsed =
669 parse_mcp_command(r#"npx @modelcontextprotocol/server-filesystem /tmp --env="MY KEY""#)
670 .unwrap();
671 assert_eq!(parsed.config.command, Some("npx".to_string()));
672 assert_eq!(
673 parsed.config.args,
674 vec![
675 "@modelcontextprotocol/server-filesystem",
676 "/tmp",
677 "--env=MY KEY"
678 ]
679 );
680 }
681
682 // === infer_server_name tests ===
683
684 #[test]
685 fn infer_name_npx_package() {
686 let parsed = parse_mcp_command("npx @modelcontextprotocol/server-filesystem /tmp").unwrap();
687 assert_eq!(parsed.name, "filesystem");
688 }
689
690 #[test]
691 fn infer_name_npx_simple() {
692 let parsed = parse_mcp_command("npx my-mcp-server").unwrap();
693 assert_eq!(parsed.name, "my-mcp-server");
694 }
695
696 #[test]
697 fn infer_name_pnpm_exec() {
698 let parsed = parse_mcp_command("pnpm exec @modelcontextprotocol/server-postgres").unwrap();
699 assert_eq!(parsed.name, "postgres");
700 }
701
702 #[test]
703 fn infer_name_node_script() {
704 let parsed = parse_mcp_command("node ./my-mcp-server.js").unwrap();
705 assert_eq!(parsed.name, "my-mcp-server");
706 }
707
708 #[test]
709 fn infer_name_python_script() {
710 let parsed = parse_mcp_command("python3 mcp_server.py").unwrap();
711 assert_eq!(parsed.name, "mcp-server");
712 }
713
714 #[test]
715 fn infer_name_uvx_package() {
716 let parsed = parse_mcp_command("uvx mcp-server-git").unwrap();
717 assert_eq!(parsed.name, "mcp-server-git");
718 }
719
720 #[test]
721 fn infer_name_bare_command() {
722 let parsed = parse_mcp_command("/usr/local/bin/my-server").unwrap();
723 assert_eq!(parsed.name, "my-server");
724 }
725
726 #[test]
727 fn infer_name_windows_cmd_prefix() {
728 let parsed =
729 parse_mcp_command("cmd /c npx -y @modelcontextprotocol/server-memory").unwrap();
730 assert_eq!(parsed.name, "memory");
731 }
732
733 #[test]
734 fn infer_name_windows_cmd_uppercase() {
735 let parsed =
736 parse_mcp_command("cmd /C npx @modelcontextprotocol/server-filesystem /tmp").unwrap();
737 assert_eq!(parsed.name, "filesystem");
738 }
739
740 #[test]
741 fn infer_name_only_command_no_args() {
742 // No args at all — falls through to last resort: command name itself
743 let parsed = parse_mcp_command("my-server").unwrap();
744 assert_eq!(parsed.name, "my-server");
745 }
746
747 #[test]
748 fn infer_name_only_command_no_args_path() {
749 // Absolute path, no args — uses file_stem of command
750 let parsed = parse_mcp_command("/usr/local/bin/my-server").unwrap();
751 assert_eq!(parsed.name, "my-server");
752 }
753
754 // === sanitize_name tests ===
755
756 #[test]
757 fn sanitize_name_preserves_hyphens() {
758 assert_eq!(sanitize_name("my-server"), "my-server");
759 }
760
761 #[test]
762 fn sanitize_name_converts_underscores_to_hyphens() {
763 assert_eq!(sanitize_name("my_server"), "my-server");
764 }
765
766 #[test]
767 fn sanitize_name_converts_special_chars_to_hyphens() {
768 assert_eq!(sanitize_name("my@server!"), "my-server");
769 }
770
771 #[test]
772 fn sanitize_name_trims_leading_trailing_hyphens() {
773 assert_eq!(sanitize_name("_my_server_"), "my-server");
774 }
775
776 #[test]
777 fn sanitize_name_preserves_alphanumeric() {
778 assert_eq!(sanitize_name("server123"), "server123");
779 }
780
781 #[test]
782 fn sanitize_name_empty_input() {
783 assert_eq!(sanitize_name(""), "");
784 }
785
786 // === command validation tests ===
787
788 #[test]
789 fn reject_shell_wrapper_bash() {
790 let result = parse_mcp_command("bash -c 'npx server'");
791 assert!(result.is_ok()); // parsing succeeds
792 // but execute would reject — tested via parse_mcp_command structure
793 }
794
795 /// These used to assert only that their own input string contained the
796 /// metacharacter — never that the guard refused it — so deleting the
797 /// defense left all four green (2026-08-04 audit). They now call the
798 /// guard.
799 #[test]
800 fn shell_metacharacters_in_args_are_refused() {
801 for bad in [
802 "--out>file",
803 "arg|cat",
804 "a;rm -rf /",
805 "a&&b",
806 "`whoami`",
807 "$HOME",
808 ] {
809 let args = vec!["server".to_string(), bad.to_string()];
810 let err = super::reject_shell_metacharacters(&args)
811 .expect_err("metacharacter must be refused: {bad}");
812 assert!(
813 err.to_string().contains("shell metacharacters"),
814 "refusal must name the reason for {bad}: {err}"
815 );
816 }
817 }
818
819 #[test]
820 fn ordinary_args_pass_the_metacharacter_guard() {
821 let args = vec![
822 "@modelcontextprotocol/server-filesystem".to_string(),
823 "/tmp/workspace".to_string(),
824 "--read-only".to_string(),
825 ];
826 assert!(super::reject_shell_metacharacters(&args).is_ok());
827 }
828
829 #[test]
830 fn allowlist_includes_common_runtimes() {
831 // Verify the allowlist covers the expected commands
832 const ALLOWED: &[&str] = &[
833 "npx", "npm", "pnpm", "yarn", "bunx", "bun", "node", "python", "python3", "uvx", "uv",
834 "deno", "ruby", "cargo",
835 ];
836 // All standard MCP server launchers should be present
837 assert!(ALLOWED.contains(&"npx"));
838 assert!(ALLOWED.contains(&"node"));
839 assert!(ALLOWED.contains(&"python3"));
840 assert!(ALLOWED.contains(&"uvx"));
841 }
842
843 // === approval-gate contract ===
844
845 #[test]
846 fn start_mcp_server_declares_required_approval() {
847 // Security invariant (#3866): spawning a runtime MCP server is
848 // side-effecting (child process / network connection), so the tool
849 // spec itself must declare `ApprovalRequirement::Required`. Combined
850 // with the engine's non-bypassable gate (see engine tests), this
851 // guarantees an unapproved start is rejected before `execute` runs.
852 let pool = Arc::new(AsyncMutex::new(McpPool::new(
853 crate::mcp::McpConfig::default(),
854 )));
855 let tool = StartRuntimeMcpServer::new(pool);
856 assert_eq!(tool.name(), "start_mcp_server");
857 assert!(
858 matches!(tool.approval_requirement(), ApprovalRequirement::Required),
859 "start_mcp_server must require approval before spawning"
860 );
861 }
862 }
863
863 lines RUST