返回 CodeWhale
lib.rs
根目录 / crates / mcp / src / lib.rs
1 use std::collections::HashMap;
2 use std::collections::hash_map::DefaultHasher;
3 use std::hash::{Hash, Hasher};
4
5 use anyhow::{Context, Result, bail};
6 use serde::de::DeserializeOwned;
7 use serde::{Deserialize, Serialize};
8 use serde_json::{Value, json};
9
10 mod stdio_client;
11 // Unix-gated as well as test-gated: every helper in here builds and spawns a
12 // POSIX-sh script, so the tests that use it are `#[cfg(unix)]` and on Windows
13 // the whole module compiles to dead code, which `-D warnings` rejects.
14 #[cfg(all(test, unix))]
15 mod test_support;
16
17 pub use stdio_client::ChildProcessMcpClient;
18
19 /// Configuration for a single MCP server process.
20 #[derive(Debug, Clone, Serialize, Deserialize)]
21 pub struct McpServerConfig {
22 /// Unique server identifier used for tool name qualification.
23 pub name: String,
24 /// Path or name of the server executable.
25 pub command: String,
26 /// Command-line arguments passed to the server process.
27 #[serde(default)]
28 pub args: Vec<String>,
29 /// Environment variables set for the server process.
30 #[serde(default)]
31 pub env: HashMap<String, String>,
32 /// Whether this server should be started. Disabled servers are skipped.
33 #[serde(default = "default_true")]
34 pub enabled: bool,
35 }
36
37 /// Filter controlling which tools from an MCP server are exposed.
38 ///
39 /// When `allow` is empty, all tools are permitted (unless denied).
40 /// `deny` takes precedence over `allow`.
41 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
42 pub struct ToolFilter {
43 /// Tool names to expose. Empty means expose all.
44 #[serde(default)]
45 pub allow: Vec<String>,
46 /// Tool names to exclude. Takes precedence over `allow`.
47 #[serde(default)]
48 pub deny: Vec<String>,
49 }
50
51 /// A complete MCP server definition including config and tool filter.
52 #[derive(Debug, Clone, Serialize, Deserialize)]
53 pub struct McpServerDefinition {
54 /// Server process configuration.
55 pub config: McpServerConfig,
56 /// Tool filter controlling which tools are exposed.
57 #[serde(default)]
58 pub filter: ToolFilter,
59 }
60
61 /// Status of an individual MCP server during startup.
62 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
63 #[serde(rename_all = "snake_case")]
64 pub enum McpStartupStatus {
65 /// Server process is starting.
66 Starting,
67 /// Server is ready to accept tool calls.
68 Ready,
69 /// Server failed to start.
70 Failed { error: String },
71 /// Server startup was cancelled (e.g., disabled in config).
72 Cancelled,
73 }
74
75 /// Status update for a single MCP server during startup.
76 #[derive(Debug, Clone, Serialize, Deserialize)]
77 pub struct McpStartupUpdateEvent {
78 /// Name of the server this update pertains to.
79 pub server_name: String,
80 /// Current startup status.
81 pub status: McpStartupStatus,
82 }
83
84 /// Record of an MCP server that failed to start.
85 #[derive(Debug, Clone, Serialize, Deserialize)]
86 pub struct McpStartupFailure {
87 /// Name of the server that failed.
88 pub server_name: String,
89 /// Error message describing the failure.
90 pub error: String,
91 }
92
93 /// Summary emitted after all MCP servers have completed startup.
94 #[derive(Debug, Clone, Serialize, Deserialize)]
95 pub struct McpStartupCompleteEvent {
96 /// Names of servers that started successfully.
97 pub ready: Vec<String>,
98 /// Servers that failed with error details.
99 pub failed: Vec<McpStartupFailure>,
100 /// Names of servers that were skipped (disabled).
101 pub cancelled: Vec<String>,
102 }
103
104 /// Describes a single tool provided by an MCP server.
105 #[derive(Debug, Clone, Serialize, Deserialize)]
106 pub struct McpToolDescriptor {
107 /// Name of the server providing this tool.
108 pub server_name: String,
109 /// Original tool name as reported by the server.
110 pub tool_name: String,
111 /// Fully qualified name (e.g., `mcp__server__tool`).
112 pub qualified_name: String,
113 /// Human-readable description of what the tool does.
114 pub description: Option<String>,
115 }
116
117 /// Describes a resource provided by an MCP server.
118 #[derive(Debug, Clone, Serialize, Deserialize)]
119 pub struct McpResourceDescriptor {
120 /// Name of the server providing this resource.
121 pub server_name: String,
122 /// URI identifying the resource.
123 pub uri: String,
124 /// Human-readable description.
125 pub description: Option<String>,
126 }
127
128 /// Trait abstracting an MCP client connection.
129 ///
130 /// Implementations handle communication with a single MCP server process.
131 pub trait McpManagedClient: Send + Sync {
132 /// List all tools provided by this server.
133 fn list_tools(&self) -> Result<Vec<McpToolDescriptor>>;
134 /// Invoke a tool by name with the given arguments.
135 fn call_tool(&self, tool_name: &str, arguments: Value) -> Result<Value>;
136 /// List all resources provided by this server.
137 fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>>;
138 /// Read a resource by URI.
139 fn read_resource(&self, uri: &str) -> Result<Value>;
140 }
141
142 /// A simple in-memory MCP client for tests and embedding callers.
143 ///
144 /// This is **not** wired into `codewhale mcp-server`: that path spawns
145 /// [`ChildProcessMcpClient`] and reports a typed error when the configured
146 /// command cannot be run. Serving canned values there made a broken
147 /// integration look identical to a working one (#4727).
148 #[derive(Debug, Default)]
149 pub struct InMemoryMcpClient {
150 tools: HashMap<String, Value>,
151 resources: HashMap<String, Value>,
152 }
153
154 impl InMemoryMcpClient {
155 /// Register a tool with a fixed response value.
156 pub fn with_tool(mut self, name: &str, sample_result: Value) -> Self {
157 self.tools.insert(name.to_string(), sample_result);
158 self
159 }
160
161 /// Register a resource with a fixed data value.
162 pub fn with_resource(mut self, uri: &str, data: Value) -> Self {
163 self.resources.insert(uri.to_string(), data);
164 self
165 }
166 }
167
168 impl McpManagedClient for InMemoryMcpClient {
169 fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
170 Ok(self
171 .tools
172 .keys()
173 .map(|name| McpToolDescriptor {
174 server_name: "in-memory".to_string(),
175 tool_name: name.clone(),
176 qualified_name: name.clone(),
177 description: None,
178 })
179 .collect())
180 }
181
182 fn call_tool(&self, tool_name: &str, _arguments: Value) -> Result<Value> {
183 self.tools
184 .get(tool_name)
185 .cloned()
186 .with_context(|| format!("tool '{tool_name}' not found"))
187 }
188
189 fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
190 Ok(self
191 .resources
192 .keys()
193 .map(|uri| McpResourceDescriptor {
194 server_name: "in-memory".to_string(),
195 uri: uri.clone(),
196 description: None,
197 })
198 .collect())
199 }
200
201 fn read_resource(&self, uri: &str) -> Result<Value> {
202 self.resources
203 .get(uri)
204 .cloned()
205 .with_context(|| format!("resource '{uri}' not found"))
206 }
207 }
208
209 /// Manages multiple MCP server connections and their tool/resource registrations.
210 #[derive(Default)]
211 pub struct McpManager {
212 configs: HashMap<String, (McpServerConfig, ToolFilter)>,
213 clients: HashMap<String, Box<dyn McpManagedClient>>,
214 }
215
216 impl McpManager {
217 /// Register an MCP server with its config, tool filter, and client implementation.
218 ///
219 /// Fails when the server's name collides with an already-registered server
220 /// after `sanitize_component` folding. Qualified tool names are built
221 /// from the sanitized name, so `my-server`, `my_server`, and `My.Server`
222 /// all produce `mcp__my_server__*`: registering two of them would let
223 /// either server answer a qualified name meant for the other. Re-registering
224 /// the same name replaces it, which is how restart works.
225 pub fn register_server(
226 &mut self,
227 config: McpServerConfig,
228 filter: ToolFilter,
229 client: Box<dyn McpManagedClient>,
230 ) -> Result<()> {
231 if let Some(existing) = self.colliding_server_name(&config.name) {
232 bail!(
233 "MCP server '{}' collides with already-registered server '{existing}': \
234 both qualify tools as 'mcp__{}__*'",
235 config.name,
236 sanitize_component(&config.name)
237 );
238 }
239 self.clients.insert(config.name.clone(), client);
240 self.configs.insert(config.name.clone(), (config, filter));
241 Ok(())
242 }
243
244 /// Returns a registered server whose sanitized name matches `name`'s but
245 /// which is not `name` itself.
246 fn colliding_server_name(&self, name: &str) -> Option<&str> {
247 let sanitized = sanitize_component(name);
248 self.configs
249 .keys()
250 .find(|existing| existing.as_str() != name && sanitize_component(existing) == sanitized)
251 .map(String::as_str)
252 }
253
254 /// Resolve a sanitized tool segment from a qualified name back to the
255 /// server's original tool name.
256 ///
257 /// `qualify_tool_name` folds `-`, `.`, and case into `_`, so the segment
258 /// carried by `mcp__server__segment` is not necessarily the name the
259 /// server expects. A literal match wins outright; otherwise, when exactly
260 /// one listed tool sanitizes to the segment, its original name is used.
261 /// When the lookup is impossible or ambiguous the segment is passed
262 /// through unchanged, preserving behavior for clients whose `list_tools`
263 /// does not enumerate every callable tool.
264 fn resolve_original_tool_name(&self, server_name: &str, tool_segment: &str) -> String {
265 let Some(client) = self.clients.get(server_name) else {
266 return tool_segment.to_string();
267 };
268 let Ok(tools) = client.list_tools() else {
269 return tool_segment.to_string();
270 };
271 if tools.iter().any(|tool| tool.tool_name == tool_segment) {
272 return tool_segment.to_string();
273 }
274 let mut matches = tools
275 .iter()
276 .filter(|tool| sanitize_component(&tool.tool_name) == tool_segment);
277 match (matches.next(), matches.next()) {
278 (Some(tool), None) => tool.tool_name.clone(),
279 _ => tool_segment.to_string(),
280 }
281 }
282
283 /// Start all registered servers, emitting status updates via the callback.
284 ///
285 /// Returns a summary of which servers are ready, failed, or cancelled.
286 pub fn start_all<F>(&self, mut emit: F) -> McpStartupCompleteEvent
287 where
288 F: FnMut(McpStartupUpdateEvent),
289 {
290 let mut ready = Vec::new();
291 let mut failed = Vec::new();
292 let mut cancelled = Vec::new();
293 for (server_name, (cfg, _)) in &self.configs {
294 if !cfg.enabled {
295 emit(McpStartupUpdateEvent {
296 server_name: server_name.clone(),
297 status: McpStartupStatus::Cancelled,
298 });
299 cancelled.push(server_name.clone());
300 continue;
301 }
302 emit(McpStartupUpdateEvent {
303 server_name: server_name.clone(),
304 status: McpStartupStatus::Starting,
305 });
306 if self.clients.contains_key(server_name) {
307 emit(McpStartupUpdateEvent {
308 server_name: server_name.clone(),
309 status: McpStartupStatus::Ready,
310 });
311 ready.push(server_name.clone());
312 } else {
313 let error = "client not registered".to_string();
314 emit(McpStartupUpdateEvent {
315 server_name: server_name.clone(),
316 status: McpStartupStatus::Failed {
317 error: error.clone(),
318 },
319 });
320 failed.push(McpStartupFailure {
321 server_name: server_name.clone(),
322 error,
323 });
324 }
325 }
326 McpStartupCompleteEvent {
327 ready,
328 failed,
329 cancelled,
330 }
331 }
332
333 /// Stop a running server by removing its client.
334 pub fn stop_server(&mut self, server_name: &str) -> Result<()> {
335 self.clients
336 .remove(server_name)
337 .with_context(|| format!("server '{server_name}' is not running"))?;
338 Ok(())
339 }
340
341 /// Remove a server entirely (config and client).
342 pub fn unregister_server(&mut self, server_name: &str) -> Result<()> {
343 let had_config = self.configs.remove(server_name).is_some();
344 self.clients.remove(server_name);
345 if !had_config {
346 bail!("server '{server_name}' is not registered");
347 }
348 Ok(())
349 }
350
351 /// List all tools from all running servers, applying tool filters.
352 pub fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
353 let mut out = Vec::new();
354 for (server_name, (_, filter)) in &self.configs {
355 let Some(client) = self.clients.get(server_name) else {
356 continue;
357 };
358 let tools = client.list_tools()?;
359 for tool in tools {
360 if !allowed_by_filter(&tool.tool_name, filter) {
361 continue;
362 }
363 let qualified_name = qualify_tool_name(server_name, &tool.tool_name);
364 out.push(McpToolDescriptor {
365 server_name: server_name.clone(),
366 tool_name: tool.tool_name,
367 qualified_name,
368 description: tool.description,
369 });
370 }
371 }
372 Ok(out)
373 }
374
375 /// Call a tool on a specific server by name.
376 ///
377 /// The server's [`ToolFilter`] is enforced on invocation, not just at
378 /// listing time: a denied (or not-allowed) tool cannot be executed by
379 /// addressing the server directly, whether by bare or qualified name.
380 pub fn call_tool(&self, server_name: &str, tool_name: &str, arguments: Value) -> Result<Value> {
381 let client = self
382 .clients
383 .get(server_name)
384 .with_context(|| format!("MCP server '{server_name}' not available"))?;
385 if let Some((_, filter)) = self.configs.get(server_name)
386 && !allowed_by_filter(tool_name, filter)
387 {
388 bail!("tool '{tool_name}' on MCP server '{server_name}' is blocked by the tool filter");
389 }
390 client.call_tool(tool_name, arguments)
391 }
392
393 /// Call a tool using its fully qualified name (e.g., `mcp__server__tool`).
394 pub fn call_qualified_tool(
395 &self,
396 qualified_tool_name: &str,
397 arguments: Value,
398 ) -> Result<Value> {
399 let parsed = parse_qualified_tool_name(qualified_tool_name)
400 .with_context(|| format!("invalid qualified MCP tool name: {qualified_tool_name}"));
401
402 // An exact registration is the answer. Whatever the tool returns —
403 // including an error — is returned as-is: falling through to the scan
404 // below on a *call* failure would re-execute the same tool, and for a
405 // file write, a commit, or a paid API call that second invocation is a
406 // second real side effect. Only a failed *lookup* falls through.
407 //
408 // The parsed tool segment is the *sanitized* name (qualify_tool_name
409 // folds `-`, `.`, and case into `_`), so resolve it back to the
410 // server's original tool name before dispatching — otherwise tools
411 // like `my-tool` are un-callable through their advertised qualified
412 // name `mcp__server__my_tool`.
413 if let Ok((server_name, tool_name)) = &parsed
414 && self.clients.contains_key(server_name)
415 {
416 let resolved = self.resolve_original_tool_name(server_name, tool_name);
417 return self.call_tool(server_name, &resolved, arguments);
418 }
419
420 // No exact registration: resolve by scanning qualified names. Collect
421 // every match rather than returning the first, because `configs` is a
422 // HashMap — returning early would make the choice depend on iteration
423 // order when two servers' names collide after sanitizing.
424 let mut matches: Vec<(&String, String)> = Vec::new();
425 for (server_name, (_, filter)) in &self.configs {
426 let Some(client) = self.clients.get(server_name) else {
427 continue;
428 };
429 for tool in client.list_tools()? {
430 if !allowed_by_filter(&tool.tool_name, filter) {
431 continue;
432 }
433 if qualify_tool_name(server_name, &tool.tool_name) == qualified_tool_name {
434 matches.push((server_name, tool.tool_name));
435 }
436 }
437 }
438 match matches.len() {
439 0 => {}
440 1 => {
441 let (server_name, tool_name) = &matches[0];
442 let client = self
443 .clients
444 .get(*server_name)
445 .with_context(|| format!("MCP server '{server_name}' not available"))?;
446 return client.call_tool(tool_name, arguments);
447 }
448 _ => {
449 matches.sort();
450 let servers: Vec<&str> = matches
451 .iter()
452 .map(|(server_name, _)| server_name.as_str())
453 .collect();
454 bail!(
455 "qualified MCP tool name '{qualified_tool_name}' is ambiguous across servers: \
456 {}",
457 servers.join(", ")
458 );
459 }
460 }
461
462 let (server_name, tool_name) = parsed?;
463 self.call_tool(&server_name, &tool_name, arguments)
464 }
465
466 /// List all resources from all running servers.
467 pub fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
468 let mut out = Vec::new();
469 for server_name in self.configs.keys() {
470 let Some(client) = self.clients.get(server_name) else {
471 continue;
472 };
473 for mut resource in client.list_resources()? {
474 resource.server_name = server_name.clone();
475 out.push(resource);
476 }
477 }
478 Ok(out)
479 }
480
481 /// Read a resource from a specific server.
482 pub fn read_resource(&self, server_name: &str, uri: &str) -> Result<Value> {
483 let client = self
484 .clients
485 .get(server_name)
486 .with_context(|| format!("MCP server '{server_name}' not available"))?;
487 client.read_resource(uri)
488 }
489
490 /// Generate sandbox state update notices for all registered servers.
491 pub fn update_sandbox_state(&self, sandbox_mode: &str, cwd: &str) -> Result<Vec<Value>> {
492 let mut notices = Vec::new();
493 for server_name in self.configs.keys() {
494 notices.push(json!({
495 "server_name": server_name,
496 "method": "codex/sandbox-state/update",
497 "params": {
498 "sandbox_mode": sandbox_mode,
499 "cwd": cwd
500 }
501 }));
502 }
503 Ok(notices)
504 }
505 }
506
507 fn default_true() -> bool {
508 true
509 }
510
511 fn allowed_by_filter(name: &str, filter: &ToolFilter) -> bool {
512 if filter.deny.iter().any(|pattern| pattern == name) {
513 return false;
514 }
515 if filter.allow.is_empty() {
516 return true;
517 }
518 filter.allow.iter().any(|pattern| pattern == name)
519 }
520
521 fn sanitize_component(value: &str) -> String {
522 value
523 .chars()
524 .map(|ch| {
525 if ch.is_ascii_alphanumeric() || ch == '_' {
526 ch.to_ascii_lowercase()
527 } else {
528 '_'
529 }
530 })
531 .collect()
532 }
533
534 fn qualify_tool_name(server: &str, tool: &str) -> String {
535 let server = sanitize_component(server);
536 let tool = sanitize_component(tool);
537 let mut name = format!("mcp__{server}__{tool}");
538 if name.len() > 64 {
539 let mut hasher = DefaultHasher::new();
540 name.hash(&mut hasher);
541 let hash = format!("{:x}", hasher.finish());
542 let suffix = format!("_{}", &hash[..12]);
543 let component_budget = 64 - "mcp__".len() - "__".len() - suffix.len();
544 let mut server_len = server.len().min(component_budget / 2);
545 let mut tool_len = tool.len().min(component_budget - server_len);
546 let remaining = component_budget - server_len - tool_len;
547 if remaining > 0 {
548 let server_extra = (server.len() - server_len).min(remaining);
549 server_len += server_extra;
550 tool_len += (tool.len() - tool_len).min(remaining - server_extra);
551 }
552 name = format!(
553 "mcp__{}__{}{}",
554 &server[..server_len],
555 &tool[..tool_len],
556 suffix
557 );
558 }
559 name
560 }
561
562 fn parse_qualified_tool_name(value: &str) -> Result<(String, String)> {
563 let Some(stripped) = value.strip_prefix("mcp__") else {
564 bail!("missing mcp__ prefix");
565 };
566 let mut split = stripped.splitn(2, "__");
567 let server = split
568 .next()
569 .filter(|s| !s.is_empty())
570 .map(ToOwned::to_owned)
571 .context("missing server segment")?;
572 let tool = split
573 .next()
574 .filter(|s| !s.is_empty())
575 .map(ToOwned::to_owned)
576 .context("missing tool segment")?;
577 Ok((server, tool))
578 }
579
580 #[derive(Debug, Deserialize)]
581 struct JsonRpcRequest {
582 #[serde(default)]
583 jsonrpc: Option<String>,
584 #[serde(default)]
585 id: Option<Value>,
586 method: String,
587 #[serde(default)]
588 params: Value,
589 }
590
591 #[derive(Debug)]
592 struct JsonRpcError {
593 code: i64,
594 message: String,
595 data: Option<Value>,
596 }
597
598 #[derive(Debug, Deserialize)]
599 struct ToolsListParams {
600 #[serde(default)]
601 server: Option<String>,
602 }
603
604 #[derive(Debug, Deserialize)]
605 struct ToolsCallParams {
606 #[serde(default)]
607 name: Option<String>,
608 #[serde(default)]
609 tool: Option<String>,
610 #[serde(default)]
611 server: Option<String>,
612 #[serde(default)]
613 arguments: Value,
614 }
615
616 #[derive(Debug, Deserialize)]
617 struct ResourcesListParams {
618 #[serde(default)]
619 server: Option<String>,
620 }
621
622 #[derive(Debug, Deserialize)]
623 struct ResourcesReadParams {
624 #[serde(default)]
625 server: Option<String>,
626 uri: String,
627 }
628
629 #[derive(Debug, Deserialize)]
630 struct ServerRegisterParams {
631 server: McpServerConfig,
632 #[serde(default)]
633 filter: ToolFilter,
634 #[serde(default = "default_true")]
635 start: bool,
636 }
637
638 #[derive(Debug, Deserialize)]
639 struct ServerNameParams {
640 name: String,
641 }
642
643 struct StdioMcpState {
644 manager: McpManager,
645 definitions: HashMap<String, McpServerDefinition>,
646 running: HashMap<String, bool>,
647 /// Why a defined server is not running, surfaced in every lifecycle
648 /// snapshot so a failed spawn cannot be mistaken for a healthy server.
649 errors: HashMap<String, String>,
650 lifecycle_state: String,
651 }
652
653 impl StdioMcpState {
654 /// Spawn `definition`'s configured command and register the resulting
655 /// connection, recording the failure reason when the server cannot be
656 /// brought up.
657 ///
658 /// This is the only way a server enters `manager`, and it has no stub
659 /// branch: there is no configuration under which a registered server
660 /// answers from anything but its own process.
661 fn start_definition(&mut self, definition: &McpServerDefinition) -> Result<()> {
662 let name = definition.config.name.clone();
663 let outcome = ChildProcessMcpClient::spawn(&definition.config).and_then(|client| {
664 self.manager.register_server(
665 definition.config.clone(),
666 definition.filter.clone(),
667 Box::new(client),
668 )
669 });
670 match outcome {
671 Ok(()) => {
672 self.errors.remove(&name);
673 self.running.insert(name, true);
674 Ok(())
675 }
676 Err(err) => {
677 let message = format!("{err:#}");
678 self.errors.insert(name.clone(), message.clone());
679 self.running.insert(name, false);
680 Err(err)
681 }
682 }
683 }
684 }
685
686 /// Run an MCP stdio server that reads JSON-RPC requests from stdin and writes responses to stdout.
687 ///
688 /// Returns the final server definitions after the session ends (useful for persisting
689 /// runtime changes like server registrations).
690 pub fn run_stdio_server(
691 initial_definitions: Vec<McpServerDefinition>,
692 ) -> Result<Vec<McpServerDefinition>> {
693 use std::io::{self, BufRead, Write};
694
695 let stdin = io::stdin();
696 let mut stdout = io::stdout();
697 let mut stderr = io::stderr();
698 let mut state = build_stdio_state(initial_definitions);
699
700 for line in stdin.lock().lines() {
701 let line = line.context("failed to read stdio line")?;
702 if line.trim().is_empty() {
703 continue;
704 }
705
706 let request: JsonRpcRequest = match serde_json::from_str(&line) {
707 Ok(value) => value,
708 Err(err) => {
709 let msg = jsonrpc_error(
710 None,
711 JsonRpcError::parse_error(format!("invalid json: {err}")),
712 );
713 writeln!(stdout, "{msg}")?;
714 stdout.flush()?;
715 continue;
716 }
717 };
718
719 if request
720 .jsonrpc
721 .as_deref()
722 .is_some_and(|version| version != "2.0")
723 {
724 if should_respond_to_jsonrpc(&request.id) {
725 let response = jsonrpc_error(
726 request.id,
727 JsonRpcError::invalid_request("jsonrpc version must be 2.0"),
728 );
729 writeln!(stdout, "{response}")?;
730 stdout.flush()?;
731 }
732 continue;
733 }
734
735 if !should_respond_to_jsonrpc(&request.id) {
736 match dispatch_stdio_request(&mut state, &request.method, request.params) {
737 Ok((_, should_exit)) if should_exit => break,
738 Ok(_) | Err(_) => {}
739 }
740 continue;
741 }
742
743 let response = match dispatch_stdio_request(&mut state, &request.method, request.params) {
744 Ok((result, should_exit)) => {
745 let payload = jsonrpc_result(request.id, result);
746 writeln!(stdout, "{payload}")?;
747 stdout.flush()?;
748 if should_exit {
749 break;
750 }
751 continue;
752 }
753 Err(err) => jsonrpc_error(request.id, err),
754 };
755
756 writeln!(stdout, "{response}")?;
757 stdout.flush()?;
758 }
759
760 state.lifecycle_state = "stopped".to_string();
761 let _ = writeln!(stderr, "deepseek-mcp stdio server exited");
762 let mut definitions: Vec<McpServerDefinition> = state.definitions.into_values().collect();
763 definitions.sort_by(|a, b| a.config.name.cmp(&b.config.name));
764 Ok(definitions)
765 }
766
767 fn build_stdio_state(initial_definitions: Vec<McpServerDefinition>) -> StdioMcpState {
768 let mut state = StdioMcpState {
769 manager: McpManager::default(),
770 definitions: HashMap::new(),
771 running: HashMap::new(),
772 errors: HashMap::new(),
773 lifecycle_state: "running".to_string(),
774 };
775
776 for definition in initial_definitions {
777 let name = definition.config.name.clone();
778 state.definitions.insert(name.clone(), definition.clone());
779 if !definition.config.enabled {
780 state.running.insert(name, false);
781 continue;
782 }
783 // A server that cannot be spawned stays stopped and says so on stderr.
784 // stdout is the JSON-RPC channel, so the warning goes to stderr where
785 // it will not corrupt the protocol stream but is still visible to the
786 // operator; `lifecycle` carries the same text for programmatic clients.
787 if let Err(err) = state.start_definition(&definition) {
788 tracing::warn!("MCP server '{name}' is not available: {err:#}");
789 eprintln!("codewhale mcp-server: server '{name}' is not available: {err:#}");
790 }
791 }
792
793 state
794 }
795
796 fn default_rpc_methods() -> Vec<&'static str> {
797 vec![
798 "initialize",
799 "healthz",
800 "capabilities",
801 "tools/list",
802 "tools/call",
803 "resources/list",
804 "resources/read",
805 "server/list",
806 "server/register",
807 "server/start",
808 "server/stop",
809 "server/unregister",
810 "shutdown",
811 ]
812 }
813
814 fn lifecycle_snapshot(state: &StdioMcpState) -> Value {
815 let mut servers: Vec<Value> = state
816 .definitions
817 .iter()
818 .map(|(name, definition)| {
819 let is_running = state.running.get(name).copied().unwrap_or(false);
820 json!({
821 "name": name,
822 "enabled": definition.config.enabled,
823 "running": is_running,
824 "command": definition.config.command.clone(),
825 "args": definition.config.args.clone(),
826 // Null when the server is healthy. A client polling
827 // `server/list` must be able to tell "up" from "never
828 // started" without guessing.
829 "error": state.errors.get(name).cloned(),
830 })
831 })
832 .collect();
833 servers.sort_by(|a, b| {
834 let a_name = a.get("name").and_then(Value::as_str).unwrap_or_default();
835 let b_name = b.get("name").and_then(Value::as_str).unwrap_or_default();
836 a_name.cmp(b_name)
837 });
838
839 let running_count = state.running.values().filter(|running| **running).count();
840 json!({
841 "status": state.lifecycle_state,
842 "servers": servers,
843 "counts": {
844 "defined": state.definitions.len(),
845 "running": running_count
846 }
847 })
848 }
849
850 fn params_or_object(params: Value) -> Value {
851 if params.is_null() { json!({}) } else { params }
852 }
853
854 fn parse_params<T: DeserializeOwned>(params: Value) -> std::result::Result<T, JsonRpcError> {
855 serde_json::from_value(params).map_err(|err| JsonRpcError::invalid_params(err.to_string()))
856 }
857
858 fn parse_server_from_uri(uri: &str) -> Option<String> {
859 let stripped = uri.strip_prefix("mcp://")?;
860 let server = stripped.split('/').next()?;
861 if server.is_empty() {
862 None
863 } else {
864 Some(server.to_string())
865 }
866 }
867
868 fn dispatch_stdio_request(
869 state: &mut StdioMcpState,
870 method: &str,
871 params: Value,
872 ) -> std::result::Result<(Value, bool), JsonRpcError> {
873 match method {
874 "initialize" | "capabilities" => Ok((
875 json!({
876 "server": "deepseek-mcp",
877 "transport": "stdio",
878 "methods": default_rpc_methods(),
879 "lifecycle": lifecycle_snapshot(state)
880 }),
881 false,
882 )),
883 "healthz" => Ok((
884 json!({
885 "status": "ok",
886 "service": "deepseek-mcp",
887 "transport": "stdio",
888 "lifecycle": lifecycle_snapshot(state)
889 }),
890 false,
891 )),
892 "tools/list" => {
893 let parsed: ToolsListParams = parse_params(params_or_object(params))?;
894 let mut tools = state
895 .manager
896 .list_tools()
897 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
898 if let Some(server) = parsed.server {
899 tools.retain(|tool| tool.server_name == server);
900 }
901 Ok((json!({ "tools": tools }), false))
902 }
903 "tools/call" => {
904 let parsed: ToolsCallParams = parse_params(params_or_object(params))?;
905 let ToolsCallParams {
906 name,
907 tool,
908 server,
909 arguments,
910 } = parsed;
911 let tool_name = name
912 .or(tool)
913 .context("missing tool name")
914 .map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
915 let arguments = if arguments.is_null() {
916 json!({})
917 } else {
918 arguments
919 };
920 let result = if tool_name.starts_with("mcp__") {
921 state
922 .manager
923 .call_qualified_tool(&tool_name, arguments)
924 .map_err(|err| JsonRpcError::internal(err.to_string()))?
925 } else {
926 let server = server
927 .context("missing server for unqualified tool")
928 .map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
929 state
930 .manager
931 .call_tool(&server, &tool_name, arguments)
932 .map_err(|err| JsonRpcError::internal(err.to_string()))?
933 };
934 Ok((json!({ "result": result }), false))
935 }
936 "resources/list" => {
937 let parsed: ResourcesListParams = parse_params(params_or_object(params))?;
938 let mut resources = state
939 .manager
940 .list_resources()
941 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
942 if let Some(server) = parsed.server {
943 resources.retain(|resource| resource.server_name == server);
944 }
945 Ok((json!({ "resources": resources }), false))
946 }
947 "resources/read" => {
948 let parsed: ResourcesReadParams = parse_params(params_or_object(params))?;
949 let ResourcesReadParams { server, uri } = parsed;
950 let server_name = server
951 .or_else(|| parse_server_from_uri(&uri))
952 .context("missing server for resource read")
953 .map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
954 let value = state
955 .manager
956 .read_resource(&server_name, &uri)
957 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
958 Ok((json!({ "resource": value }), false))
959 }
960 "server/list" | "servers/list" => {
961 Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
962 }
963 "server/register" | "servers/register" => {
964 let parsed: ServerRegisterParams = parse_params(params_or_object(params))?;
965 let name = parsed.server.name.clone();
966 if name.trim().is_empty() {
967 return Err(JsonRpcError::invalid_params(
968 "server.name must not be empty",
969 ));
970 }
971
972 if state.definitions.contains_key(&name) {
973 let _ = state.manager.unregister_server(&name);
974 }
975 let definition = McpServerDefinition {
976 config: parsed.server.clone(),
977 filter: parsed.filter.clone(),
978 };
979 state.definitions.insert(name.clone(), definition.clone());
980 state.errors.remove(&name);
981 if parsed.start && parsed.server.enabled {
982 // Registration is only "ok" if the configured command actually
983 // came up. Reporting success here and answering later tool
984 // calls from a stub is what #4727 was.
985 state
986 .start_definition(&definition)
987 .map_err(|err| JsonRpcError::internal(format!("{err:#}")))?;
988 } else {
989 state.running.insert(name, false);
990 }
991 Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
992 }
993 "server/start" | "servers/start" => {
994 let parsed: ServerNameParams = parse_params(params_or_object(params))?;
995 let definition = state
996 .definitions
997 .get(&parsed.name)
998 .cloned()
999 .with_context(|| format!("server '{}' is not defined", parsed.name))
1000 .map_err(|err| JsonRpcError::invalid_params(err.to_string()))?;
1001 if !definition.config.enabled {
1002 return Err(JsonRpcError::invalid_params(format!(
1003 "server '{}' is disabled",
1004 parsed.name
1005 )));
1006 }
1007 if !state.running.get(&parsed.name).copied().unwrap_or(false) {
1008 state
1009 .start_definition(&definition)
1010 .map_err(|err| JsonRpcError::internal(format!("{err:#}")))?;
1011 }
1012 Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
1013 }
1014 "server/stop" | "servers/stop" => {
1015 let parsed: ServerNameParams = parse_params(params_or_object(params))?;
1016 if state.running.get(&parsed.name).copied().unwrap_or(false) {
1017 state
1018 .manager
1019 .stop_server(&parsed.name)
1020 .map_err(|err| JsonRpcError::internal(err.to_string()))?;
1021 }
1022 // A deliberate stop is not a failure, so it clears any recorded
1023 // startup error rather than leaving a stale one on display.
1024 state.errors.remove(&parsed.name);
1025 state.running.insert(parsed.name, false);
1026 Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
1027 }
1028 "server/unregister" | "servers/unregister" => {
1029 let parsed: ServerNameParams = parse_params(params_or_object(params))?;
1030 if state.definitions.remove(&parsed.name).is_none() {
1031 return Err(JsonRpcError::invalid_params(format!(
1032 "server '{}' is not defined",
1033 parsed.name
1034 )));
1035 }
1036 let _ = state.manager.unregister_server(&parsed.name);
1037 state.running.remove(&parsed.name);
1038 state.errors.remove(&parsed.name);
1039 Ok((json!({ "lifecycle": lifecycle_snapshot(state) }), false))
1040 }
1041 "shutdown" => {
1042 state.lifecycle_state = "shutting_down".to_string();
1043 Ok((
1044 json!({
1045 "ok": true,
1046 "lifecycle": lifecycle_snapshot(state)
1047 }),
1048 true,
1049 ))
1050 }
1051 _ => Err(JsonRpcError::method_not_found(method)),
1052 }
1053 }
1054
1055 fn jsonrpc_result(id: Option<Value>, result: Value) -> Value {
1056 json!({
1057 "jsonrpc": "2.0",
1058 "id": id.unwrap_or(Value::Null),
1059 "result": result
1060 })
1061 }
1062
1063 fn should_respond_to_jsonrpc(id: &Option<Value>) -> bool {
1064 id.is_some()
1065 }
1066
1067 fn jsonrpc_error(id: Option<Value>, err: JsonRpcError) -> Value {
1068 json!({
1069 "jsonrpc": "2.0",
1070 "id": id.unwrap_or(Value::Null),
1071 "error": {
1072 "code": err.code,
1073 "message": err.message,
1074 "data": err.data
1075 }
1076 })
1077 }
1078
1079 impl JsonRpcError {
1080 fn parse_error(message: impl Into<String>) -> Self {
1081 Self {
1082 code: -32700,
1083 message: message.into(),
1084 data: None,
1085 }
1086 }
1087
1088 fn invalid_request(message: impl Into<String>) -> Self {
1089 Self {
1090 code: -32600,
1091 message: message.into(),
1092 data: None,
1093 }
1094 }
1095
1096 fn method_not_found(method: &str) -> Self {
1097 Self {
1098 code: -32601,
1099 message: format!("unsupported method: {method}"),
1100 data: None,
1101 }
1102 }
1103
1104 fn invalid_params(message: impl Into<String>) -> Self {
1105 Self {
1106 code: -32602,
1107 message: message.into(),
1108 data: None,
1109 }
1110 }
1111
1112 fn internal(message: impl Into<String>) -> Self {
1113 Self {
1114 code: -32603,
1115 message: message.into(),
1116 data: None,
1117 }
1118 }
1119 }
1120
1121 #[cfg(test)]
1122 mod tests {
1123 use std::sync::Arc;
1124 use std::sync::atomic::{AtomicUsize, Ordering};
1125
1126 use super::*;
1127
1128 struct EchoMcpClient;
1129
1130 impl McpManagedClient for EchoMcpClient {
1131 fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
1132 Ok(vec![])
1133 }
1134
1135 fn call_tool(&self, tool_name: &str, arguments: Value) -> Result<Value> {
1136 if tool_name == "error" {
1137 bail!("intentional error for testing");
1138 }
1139 Ok(arguments)
1140 }
1141
1142 fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
1143 Ok(vec![])
1144 }
1145
1146 fn read_resource(&self, _uri: &str) -> Result<Value> {
1147 bail!("not supported")
1148 }
1149 }
1150
1151 // ── InMemoryMcpClient ──────────────────────────────────────────────
1152
1153 #[test]
1154 fn in_memory_client_list_tools_returns_registered() {
1155 let client = InMemoryMcpClient::default()
1156 .with_tool("echo", json!({"output": "hi"}))
1157 .with_tool("greet", json!({"msg": "hello"}));
1158 let tools = client.list_tools().unwrap();
1159 assert_eq!(tools.len(), 2);
1160 let names: Vec<&str> = tools.iter().map(|t| t.tool_name.as_str()).collect();
1161 assert!(names.contains(&"echo"));
1162 assert!(names.contains(&"greet"));
1163 }
1164
1165 #[test]
1166 fn in_memory_client_call_tool_returns_value() {
1167 let client = InMemoryMcpClient::default().with_tool("echo", json!({"output": "hi"}));
1168 let result = client.call_tool("echo", json!({})).unwrap();
1169 assert_eq!(result["output"], "hi");
1170 }
1171
1172 #[test]
1173 fn in_memory_client_call_tool_errors_on_missing() {
1174 let client = InMemoryMcpClient::default();
1175 let err = client.call_tool("nope", json!({})).unwrap_err();
1176 assert!(err.to_string().contains("not found"));
1177 }
1178
1179 #[test]
1180 fn in_memory_client_list_resources_returns_registered() {
1181 let client = InMemoryMcpClient::default()
1182 .with_resource("mcp://s/health", json!({"ok": true}))
1183 .with_resource("mcp://s/caps", json!({"tools": []}));
1184 let resources = client.list_resources().unwrap();
1185 assert_eq!(resources.len(), 2);
1186 }
1187
1188 #[test]
1189 fn in_memory_client_read_resource_returns_value() {
1190 let client =
1191 InMemoryMcpClient::default().with_resource("mcp://s/health", json!({"ok": true}));
1192 let result = client.read_resource("mcp://s/health").unwrap();
1193 assert_eq!(result["ok"], true);
1194 }
1195
1196 #[test]
1197 fn in_memory_client_read_resource_errors_on_missing() {
1198 let client = InMemoryMcpClient::default();
1199 let err = client.read_resource("mcp://s/nope").unwrap_err();
1200 assert!(err.to_string().contains("not found"));
1201 }
1202
1203 // ── McpManager ─────────────────────────────────────────────────────
1204
1205 fn make_server_config(name: &str) -> McpServerConfig {
1206 McpServerConfig {
1207 name: name.to_string(),
1208 command: "test".to_string(),
1209 args: vec![],
1210 env: HashMap::new(),
1211 enabled: true,
1212 }
1213 }
1214
1215 /// Client that counts `call_tool` invocations and always fails, so a
1216 /// retry shows up as a count rather than as a swallowed error.
1217 #[derive(Default)]
1218 struct CountingFailingClient {
1219 calls: Arc<AtomicUsize>,
1220 }
1221
1222 impl McpManagedClient for CountingFailingClient {
1223 fn list_tools(&self) -> Result<Vec<McpToolDescriptor>> {
1224 Ok(vec![McpToolDescriptor {
1225 server_name: "counting".to_string(),
1226 tool_name: "write".to_string(),
1227 qualified_name: "write".to_string(),
1228 description: None,
1229 }])
1230 }
1231
1232 fn call_tool(&self, _tool_name: &str, _arguments: Value) -> Result<Value> {
1233 self.calls.fetch_add(1, Ordering::SeqCst);
1234 bail!("transient upstream failure")
1235 }
1236
1237 fn list_resources(&self) -> Result<Vec<McpResourceDescriptor>> {
1238 Ok(Vec::new())
1239 }
1240
1241 fn read_resource(&self, _uri: &str) -> Result<Value> {
1242 bail!("no resources")
1243 }
1244 }
1245
1246 #[test]
1247 fn failed_qualified_tool_call_is_not_retried() {
1248 // #4728: the fast path used to fall through to a re-resolution loop
1249 // whenever the *call* errored, re-invoking the same tool. For a file
1250 // write, a commit, or a paid API call, that second invocation is a
1251 // second real side effect.
1252 let calls = Arc::new(AtomicUsize::new(0));
1253 let mut manager = McpManager::default();
1254 manager
1255 .register_server(
1256 make_server_config("writer"),
1257 ToolFilter::default(),
1258 Box::new(CountingFailingClient {
1259 calls: Arc::clone(&calls),
1260 }),
1261 )
1262 .unwrap();
1263
1264 let err = manager
1265 .call_qualified_tool("mcp__writer__write", json!({}))
1266 .unwrap_err();
1267
1268 assert_eq!(
1269 calls.load(Ordering::SeqCst),
1270 1,
1271 "tool must be invoked exactly once, got {} invocations",
1272 calls.load(Ordering::SeqCst)
1273 );
1274 // The original error is propagated, not discarded in favour of a
1275 // later attempt's.
1276 assert!(
1277 err.to_string().contains("transient upstream failure"),
1278 "unexpected error: {err}"
1279 );
1280 }
1281
1282 #[test]
1283 fn register_server_rejects_a_name_that_collides_after_sanitizing() {
1284 // #4729: `my-server` and `my_server` both qualify as `mcp__my_server__*`,
1285 // so registering both would let either answer a call meant for the
1286 // other, decided by HashMap iteration order.
1287 let mut manager = McpManager::default();
1288 manager
1289 .register_server(
1290 make_server_config("my_server"),
1291 ToolFilter::default(),
1292 Box::new(InMemoryMcpClient::default().with_tool("t", json!("trusted"))),
1293 )
1294 .unwrap();
1295
1296 for colliding in ["my-server", "My.Server"] {
1297 let err = manager
1298 .register_server(
1299 make_server_config(colliding),
1300 ToolFilter::default(),
1301 Box::new(InMemoryMcpClient::default().with_tool("t", json!("hostile"))),
1302 )
1303 .unwrap_err();
1304 assert!(
1305 err.to_string().contains("collides"),
1306 "expected collision error for {colliding}, got: {err}"
1307 );
1308 }
1309
1310 // The trusted server keeps answering its own qualified name.
1311 assert_eq!(
1312 manager
1313 .call_qualified_tool("mcp__my_server__t", json!({}))
1314 .unwrap(),
1315 json!("trusted")
1316 );
1317 }
1318
1319 #[test]
1320 fn re_registering_the_same_server_name_replaces_it() {
1321 // Collision rejection must not break restart, which re-registers the
1322 // same name with a fresh client.
1323 let mut manager = McpManager::default();
1324 for value in ["first", "second"] {
1325 manager
1326 .register_server(
1327 make_server_config("s1"),
1328 ToolFilter::default(),
1329 Box::new(InMemoryMcpClient::default().with_tool("t", json!(value))),
1330 )
1331 .unwrap();
1332 }
1333 assert_eq!(
1334 manager
1335 .call_qualified_tool("mcp__s1__t", json!({}))
1336 .unwrap(),
1337 json!("second")
1338 );
1339 }
1340
1341 #[test]
1342 fn manager_start_all_marks_ready_for_registered_clients() {
1343 let mut manager = McpManager::default();
1344 manager
1345 .register_server(
1346 make_server_config("s1"),
1347 ToolFilter::default(),
1348 Box::new(InMemoryMcpClient::default().with_tool("t", json!(null))),
1349 )
1350 .unwrap();
1351 let mut events = Vec::new();
1352 let summary = manager.start_all(|e| events.push(e));
1353 assert_eq!(summary.ready, vec!["s1"]);
1354 assert!(summary.failed.is_empty());
1355 assert!(events.iter().any(|event| {
1356 event.server_name == "s1" && event.status == McpStartupStatus::Starting
1357 }));
1358 assert!(
1359 events.iter().any(|event| {
1360 event.server_name == "s1" && event.status == McpStartupStatus::Ready
1361 })
1362 );
1363 }
1364
1365 #[test]
1366 fn manager_start_all_marks_failed_when_client_missing() {
1367 let mut manager = McpManager::default();
1368 manager
1369 .register_server(
1370 make_server_config("s1"),
1371 ToolFilter::default(),
1372 Box::new(InMemoryMcpClient::default()),
1373 )
1374 .unwrap();
1375 manager.stop_server("s1").unwrap();
1376 let summary = manager.start_all(|_| {});
1377 assert!(summary.ready.is_empty());
1378 assert_eq!(summary.failed.len(), 1);
1379 assert_eq!(summary.failed[0].server_name, "s1");
1380 }
1381
1382 #[test]
1383 fn manager_start_all_cancels_disabled_servers() {
1384 let mut manager = McpManager::default();
1385 let mut cfg = make_server_config("s1");
1386 cfg.enabled = false;
1387 manager
1388 .register_server(
1389 cfg,
1390 ToolFilter::default(),
1391 Box::new(InMemoryMcpClient::default()),
1392 )
1393 .unwrap();
1394 let summary = manager.start_all(|_| {});
1395 assert!(summary.ready.is_empty());
1396 assert_eq!(summary.cancelled, vec!["s1"]);
1397 }
1398
1399 #[test]
1400 fn manager_list_tools_applies_filter() {
1401 let mut manager = McpManager::default();
1402 let client = InMemoryMcpClient::default()
1403 .with_tool("allowed", json!(null))
1404 .with_tool("denied", json!(null));
1405 manager
1406 .register_server(
1407 make_server_config("s1"),
1408 ToolFilter {
1409 allow: vec!["allowed".to_string()],
1410 deny: vec![],
1411 },
1412 Box::new(client),
1413 )
1414 .unwrap();
1415 let tools = manager.list_tools().unwrap();
1416 assert_eq!(tools.len(), 1);
1417 assert_eq!(tools[0].tool_name, "allowed");
1418 }
1419
1420 #[test]
1421 fn manager_list_tools_deny_overrides_allow() {
1422 let mut manager = McpManager::default();
1423 let client = InMemoryMcpClient::default()
1424 .with_tool("a", json!(null))
1425 .with_tool("b", json!(null));
1426 manager
1427 .register_server(
1428 make_server_config("s1"),
1429 ToolFilter {
1430 allow: vec!["a".to_string(), "b".to_string()],
1431 deny: vec!["b".to_string()],
1432 },
1433 Box::new(client),
1434 )
1435 .unwrap();
1436 let tools = manager.list_tools().unwrap();
1437 assert_eq!(tools.len(), 1);
1438 assert_eq!(tools[0].tool_name, "a");
1439 }
1440
1441 #[test]
1442 fn manager_call_tool_delegates_to_client() {
1443 let mut manager = McpManager::default();
1444 manager
1445 .register_server(
1446 make_server_config("s1"),
1447 ToolFilter::default(),
1448 Box::new(InMemoryMcpClient::default().with_tool("t", json!({"v": 42}))),
1449 )
1450 .unwrap();
1451 let result = manager.call_tool("s1", "t", json!({})).unwrap();
1452 assert_eq!(result["v"], 42);
1453 }
1454
1455 #[test]
1456 fn manager_call_tool_passes_arguments_to_client() {
1457 let mut manager = McpManager::default();
1458 manager
1459 .register_server(
1460 make_server_config("s1"),
1461 ToolFilter::default(),
1462 Box::new(EchoMcpClient),
1463 )
1464 .unwrap();
1465 let args = json!({"hello": "world", "num": 100});
1466 let result = manager.call_tool("s1", "echo", args.clone()).unwrap();
1467 assert_eq!(result, args);
1468 }
1469
1470 #[test]
1471 fn manager_call_tool_propagates_client_error() {
1472 let mut manager = McpManager::default();
1473 manager
1474 .register_server(
1475 make_server_config("s1"),
1476 ToolFilter::default(),
1477 Box::new(EchoMcpClient),
1478 )
1479 .unwrap();
1480 let err = manager.call_tool("s1", "error", json!({})).unwrap_err();
1481 assert!(err.to_string().contains("intentional error for testing"));
1482 }
1483
1484 #[test]
1485 fn manager_call_tool_errors_on_missing_server() {
1486 let manager = McpManager::default();
1487 let err = manager.call_tool("nope", "t", json!({})).unwrap_err();
1488 assert!(err.to_string().contains("not available"));
1489 }
1490
1491 #[test]
1492 fn manager_call_tool_enforces_deny_filter() {
1493 // The filter used to be consulted only when listing tools; a denied
1494 // tool stayed callable by addressing the server directly.
1495 let mut manager = McpManager::default();
1496 manager
1497 .register_server(
1498 make_server_config("s1"),
1499 ToolFilter {
1500 allow: vec![],
1501 deny: vec!["secret".to_string()],
1502 },
1503 Box::new(InMemoryMcpClient::default().with_tool("secret", json!({"ok": true}))),
1504 )
1505 .unwrap();
1506 let err = manager.call_tool("s1", "secret", json!({})).unwrap_err();
1507 assert!(
1508 err.to_string().contains("blocked by the tool filter"),
1509 "unexpected error: {err}"
1510 );
1511 }
1512
1513 #[test]
1514 fn manager_call_tool_enforces_allow_filter() {
1515 let mut manager = McpManager::default();
1516 manager
1517 .register_server(
1518 make_server_config("s1"),
1519 ToolFilter {
1520 allow: vec!["allowed".to_string()],
1521 deny: vec![],
1522 },
1523 Box::new(
1524 InMemoryMcpClient::default()
1525 .with_tool("allowed", json!({"ok": true}))
1526 .with_tool("other", json!({"ok": false})),
1527 ),
1528 )
1529 .unwrap();
1530 let err = manager.call_tool("s1", "other", json!({})).unwrap_err();
1531 assert!(
1532 err.to_string().contains("blocked by the tool filter"),
1533 "unexpected error: {err}"
1534 );
1535 // The allowed tool still runs.
1536 assert_eq!(
1537 manager.call_tool("s1", "allowed", json!({})).unwrap(),
1538 json!({"ok": true})
1539 );
1540 }
1541
1542 #[test]
1543 fn denied_tool_cannot_be_called_by_qualified_name() {
1544 // Security: `mcp__s1__secret` must be as unreachable as `secret`.
1545 let mut manager = McpManager::default();
1546 manager
1547 .register_server(
1548 make_server_config("s1"),
1549 ToolFilter {
1550 allow: vec![],
1551 deny: vec!["secret".to_string()],
1552 },
1553 Box::new(InMemoryMcpClient::default().with_tool("secret", json!({"ok": true}))),
1554 )
1555 .unwrap();
1556 let err = manager
1557 .call_qualified_tool("mcp__s1__secret", json!({}))
1558 .unwrap_err();
1559 assert!(
1560 err.to_string().contains("blocked by the tool filter"),
1561 "unexpected error: {err}"
1562 );
1563 }
1564
1565 #[test]
1566 fn manager_call_qualified_tool_parses_name() {
1567 let mut manager = McpManager::default();
1568 manager
1569 .register_server(
1570 make_server_config("my_server"),
1571 ToolFilter::default(),
1572 Box::new(InMemoryMcpClient::default().with_tool("my_tool", json!({"ok": true}))),
1573 )
1574 .unwrap();
1575 let result = manager
1576 .call_qualified_tool("mcp__my_server__my_tool", json!({}))
1577 .unwrap();
1578 assert_eq!(result["ok"], true);
1579 }
1580
1581 #[test]
1582 fn manager_call_qualified_tool_resolves_sanitized_segment_to_original_name() {
1583 // `qualify_tool_name` folds `-`/`.`/case into `_`, so the qualified
1584 // name advertised for `my-tool` is `mcp__s1__my_tool`. The exact-match
1585 // fast path used to dispatch that sanitized segment verbatim, and the
1586 // server (which only knows `my-tool`) rejected the call.
1587 let mut manager = McpManager::default();
1588 manager
1589 .register_server(
1590 make_server_config("s1"),
1591 ToolFilter::default(),
1592 Box::new(
1593 InMemoryMcpClient::default()
1594 .with_tool("my-tool", json!({"via": "hyphen"}))
1595 .with_tool("other.thing", json!({"via": "dot"})),
1596 ),
1597 )
1598 .unwrap();
1599
1600 let hyphen = manager
1601 .call_qualified_tool("mcp__s1__my_tool", json!({}))
1602 .unwrap();
1603 assert_eq!(hyphen, json!({"via": "hyphen"}));
1604
1605 let dot = manager
1606 .call_qualified_tool("mcp__s1__other_thing", json!({}))
1607 .unwrap();
1608 assert_eq!(dot, json!({"via": "dot"}));
1609 }
1610
1611 #[test]
1612 fn manager_call_qualified_tool_handles_truncated_names() {
1613 let long_server = "server".repeat(20);
1614 let long_tool = "tool".repeat(20);
1615 let mut manager = McpManager::default();
1616 manager
1617 .register_server(
1618 make_server_config(&long_server),
1619 ToolFilter::default(),
1620 Box::new(InMemoryMcpClient::default().with_tool(&long_tool, json!({"ok": true}))),
1621 )
1622 .unwrap();
1623 let tools = manager.list_tools().unwrap();
1624 let qualified = &tools[0].qualified_name;
1625 assert!(qualified.len() <= 64);
1626 assert!(parse_qualified_tool_name(qualified).is_ok());
1627
1628 let result = manager.call_qualified_tool(qualified, json!({})).unwrap();
1629 assert_eq!(result["ok"], true);
1630 }
1631
1632 #[test]
1633 fn manager_unregister_removes_server() {
1634 let mut manager = McpManager::default();
1635 manager
1636 .register_server(
1637 make_server_config("s1"),
1638 ToolFilter::default(),
1639 Box::new(InMemoryMcpClient::default()),
1640 )
1641 .unwrap();
1642 manager.unregister_server("s1").unwrap();
1643 assert!(manager.configs.is_empty());
1644 }
1645
1646 #[test]
1647 fn manager_unregister_errors_on_unknown() {
1648 let mut manager = McpManager::default();
1649 let err = manager.unregister_server("nope").unwrap_err();
1650 assert!(err.to_string().contains("not registered"));
1651 }
1652
1653 #[test]
1654 fn manager_stop_server_errors_on_unknown() {
1655 let mut manager = McpManager::default();
1656 let err = manager.stop_server("nope").unwrap_err();
1657 assert!(err.to_string().contains("not running"));
1658 }
1659
1660 #[test]
1661 fn manager_list_resources_returns_from_clients() {
1662 let mut manager = McpManager::default();
1663 manager
1664 .register_server(
1665 make_server_config("s1"),
1666 ToolFilter::default(),
1667 Box::new(
1668 InMemoryMcpClient::default()
1669 .with_resource("mcp://s1/health", json!({"ok": true})),
1670 ),
1671 )
1672 .unwrap();
1673 let resources = manager.list_resources().unwrap();
1674 assert_eq!(resources.len(), 1);
1675 assert_eq!(resources[0].server_name, "s1");
1676 }
1677
1678 #[test]
1679 fn manager_read_resource_delegates() {
1680 let mut manager = McpManager::default();
1681 manager
1682 .register_server(
1683 make_server_config("s1"),
1684 ToolFilter::default(),
1685 Box::new(
1686 InMemoryMcpClient::default()
1687 .with_resource("mcp://s1/health", json!({"ok": true})),
1688 ),
1689 )
1690 .unwrap();
1691 let result = manager.read_resource("s1", "mcp://s1/health").unwrap();
1692 assert_eq!(result["ok"], true);
1693 }
1694
1695 #[test]
1696 fn manager_update_sandbox_state_returns_notices() {
1697 let mut manager = McpManager::default();
1698 manager
1699 .register_server(
1700 make_server_config("s1"),
1701 ToolFilter::default(),
1702 Box::new(InMemoryMcpClient::default()),
1703 )
1704 .unwrap();
1705 let notices = manager.update_sandbox_state("strict", "/tmp").unwrap();
1706 assert_eq!(notices.len(), 1);
1707 assert_eq!(notices[0]["server_name"], "s1");
1708 }
1709
1710 // ── Tool filter ────────────────────────────────────────────────────
1711
1712 #[test]
1713 fn allowed_by_filter_empty_allow_permits_all() {
1714 let filter = ToolFilter {
1715 allow: vec![],
1716 deny: vec![],
1717 };
1718 assert!(allowed_by_filter("anything", &filter));
1719 }
1720
1721 #[test]
1722 fn allowed_by_filter_deny_blocks() {
1723 let filter = ToolFilter {
1724 allow: vec![],
1725 deny: vec!["danger".to_string()],
1726 };
1727 assert!(!allowed_by_filter("danger", &filter));
1728 assert!(allowed_by_filter("safe", &filter));
1729 }
1730
1731 #[test]
1732 fn allowed_by_filter_allow_only_permits_listed() {
1733 let filter = ToolFilter {
1734 allow: vec!["a".to_string()],
1735 deny: vec![],
1736 };
1737 assert!(allowed_by_filter("a", &filter));
1738 assert!(!allowed_by_filter("b", &filter));
1739 }
1740
1741 // ── Helper functions ───────────────────────────────────────────────
1742
1743 #[test]
1744 fn sanitize_component_lowercases_and_replaces_specials() {
1745 assert_eq!(sanitize_component("My-Server.Name"), "my_server_name");
1746 assert_eq!(sanitize_component("ABC123"), "abc123");
1747 }
1748
1749 #[test]
1750 fn qualify_tool_name_produces_mcp_prefix() {
1751 let name = qualify_tool_name("server", "tool");
1752 assert!(name.starts_with("mcp__server__tool"));
1753 }
1754
1755 #[test]
1756 fn qualify_tool_name_truncates_long_names() {
1757 let long_server = "a".repeat(100);
1758 let name = qualify_tool_name(&long_server, "tool");
1759 assert!(name.len() <= 64);
1760 assert!(parse_qualified_tool_name(&name).is_ok());
1761 }
1762
1763 #[test]
1764 fn parse_qualified_tool_name_round_trip() {
1765 let qualified = qualify_tool_name("my_server", "my_tool");
1766 let (server, tool) = parse_qualified_tool_name(&qualified).unwrap();
1767 assert_eq!(server, "my_server");
1768 assert_eq!(tool, "my_tool");
1769 }
1770
1771 #[test]
1772 fn parse_qualified_tool_name_rejects_missing_prefix() {
1773 let err = parse_qualified_tool_name("not_mcp__server__tool").unwrap_err();
1774 assert!(err.to_string().contains("missing mcp__ prefix"));
1775 }
1776
1777 #[test]
1778 fn parse_qualified_tool_name_rejects_empty_segments() {
1779 let err = parse_qualified_tool_name("mcp____tool").unwrap_err();
1780 assert!(err.to_string().contains("missing server segment"));
1781 }
1782
1783 #[test]
1784 fn parse_server_from_uri_extracts_server() {
1785 assert_eq!(
1786 parse_server_from_uri("mcp://my-server/capabilities"),
1787 Some("my-server".to_string())
1788 );
1789 }
1790
1791 #[test]
1792 fn parse_server_from_uri_returns_none_for_invalid() {
1793 assert!(parse_server_from_uri("http://not-mcp").is_none());
1794 assert!(parse_server_from_uri("mcp:///path").is_none());
1795 }
1796
1797 // ── JsonRpcError ───────────────────────────────────────────────────
1798
1799 #[test]
1800 fn jsonrpc_error_codes_are_correct() {
1801 assert_eq!(JsonRpcError::parse_error("").code, -32700);
1802 assert_eq!(JsonRpcError::invalid_request("").code, -32600);
1803 assert_eq!(JsonRpcError::method_not_found("x").code, -32601);
1804 assert_eq!(JsonRpcError::invalid_params("").code, -32602);
1805 assert_eq!(JsonRpcError::internal("").code, -32603);
1806 }
1807
1808 #[test]
1809 fn jsonrpc_result_produces_valid_envelope() {
1810 let result = jsonrpc_result(Some(json!(1)), json!({"ok": true}));
1811 assert_eq!(result["jsonrpc"], "2.0");
1812 assert_eq!(result["id"], 1);
1813 assert_eq!(result["result"]["ok"], true);
1814 }
1815
1816 #[test]
1817 fn jsonrpc_error_produces_valid_envelope() {
1818 let err = jsonrpc_error(Some(json!(2)), JsonRpcError::invalid_params("bad"));
1819 assert_eq!(err["jsonrpc"], "2.0");
1820 assert_eq!(err["id"], 2);
1821 assert_eq!(err["error"]["code"], -32602);
1822 }
1823
1824 #[test]
1825 fn jsonrpc_notifications_do_not_require_responses() {
1826 assert!(!should_respond_to_jsonrpc(&None));
1827 assert!(should_respond_to_jsonrpc(&Some(json!(1))));
1828 }
1829
1830 // ── stdio dispatch: no stub may answer for a configured server ─────
1831
1832 fn definition(name: &str, command: &str, args: &[&str]) -> McpServerDefinition {
1833 McpServerDefinition {
1834 config: McpServerConfig {
1835 name: name.to_string(),
1836 command: command.to_string(),
1837 args: args.iter().map(|arg| (*arg).to_string()).collect(),
1838 env: HashMap::new(),
1839 enabled: true,
1840 },
1841 filter: ToolFilter::default(),
1842 }
1843 }
1844
1845 fn call(state: &mut StdioMcpState, method: &str, params: Value) -> Value {
1846 dispatch_stdio_request(state, method, params)
1847 .unwrap_or_else(|err| panic!("{method} failed: {}", err.message))
1848 .0
1849 }
1850
1851 #[test]
1852 fn a_server_that_cannot_be_spawned_is_reported_not_running() {
1853 // #4727: this used to register a stub and report `running: true`, so a
1854 // typo in `command` was indistinguishable from a working server.
1855 let mut state = build_stdio_state(vec![definition(
1856 "broken",
1857 "codewhale-nonexistent-mcp-server-binary",
1858 &[],
1859 )]);
1860
1861 let lifecycle = call(&mut state, "server/list", json!({}))["lifecycle"].clone();
1862 assert_eq!(lifecycle["servers"][0]["running"], json!(false));
1863 let error = lifecycle["servers"][0]["error"]
1864 .as_str()
1865 .expect("a stopped server must carry its failure reason");
1866 assert!(
1867 error.contains("failed to spawn command"),
1868 "unexpected error: {error}"
1869 );
1870
1871 // And nothing answers on its behalf.
1872 let err = dispatch_stdio_request(
1873 &mut state,
1874 "tools/call",
1875 json!({"name": "mcp__broken__health", "arguments": {}}),
1876 )
1877 .expect_err("a server that never started must not answer tool calls");
1878 assert_eq!(err.code, -32603);
1879 }
1880
1881 #[cfg(unix)]
1882 #[test]
1883 fn tools_come_from_the_spawned_process_not_a_stub() {
1884 let script = crate::test_support::write_fake_mcp_server("dispatch_tools");
1885 let mut state = build_stdio_state(vec![definition(
1886 "fake",
1887 "/bin/sh",
1888 &[script.path().to_str().expect("utf-8 script path")],
1889 )]);
1890
1891 let tools = call(&mut state, "tools/list", json!({}))["tools"].clone();
1892 let names: Vec<&str> = tools
1893 .as_array()
1894 .expect("tools array")
1895 .iter()
1896 .filter_map(|tool| tool["tool_name"].as_str())
1897 .collect();
1898 assert_eq!(
1899 names,
1900 vec!["add"],
1901 "only the child's real tools may be listed, got {names:?}"
1902 );
1903
1904 let result = call(
1905 &mut state,
1906 "tools/call",
1907 json!({"name": "mcp__fake__add", "arguments": {"a": 2, "b": 3}}),
1908 );
1909 assert_eq!(result["result"]["content"][0]["text"], "5");
1910 }
1911
1912 #[cfg(unix)]
1913 #[test]
1914 fn server_register_fails_when_the_command_cannot_be_started() {
1915 let mut state = build_stdio_state(Vec::new());
1916 let err = dispatch_stdio_request(
1917 &mut state,
1918 "server/register",
1919 json!({"server": {"name": "late", "command": "/bin/sh", "args": ["-c", "exit 1"]}}),
1920 )
1921 .expect_err("registering an unstartable server must not report success");
1922 assert_eq!(err.code, -32603);
1923 assert!(
1924 err.message.contains("initialize"),
1925 "unexpected error: {}",
1926 err.message
1927 );
1928 assert_eq!(state.running.get("late"), Some(&false));
1929 }
1930
1931 // ── McpServerConfig serialization ──────────────────────────────────
1932
1933 #[test]
1934 fn mcp_server_config_defaults_enabled_to_true() {
1935 let json = json!({"name": "s", "command": "cmd"});
1936 let config: McpServerConfig = serde_json::from_value(json).unwrap();
1937 assert!(config.enabled);
1938 assert!(config.args.is_empty());
1939 assert!(config.env.is_empty());
1940 }
1941
1942 #[test]
1943 fn mcp_startup_status_serializes_with_snake_case() {
1944 let status = McpStartupStatus::Failed {
1945 error: "oops".to_string(),
1946 };
1947 let json = serde_json::to_value(&status).unwrap();
1948 assert_eq!(json["failed"]["error"], "oops");
1949 }
1950 }
1951
1951 lines RUST