1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::{Arc, Mutex as StdMutex};
4use std::time::{Duration, Instant};
5
6use http::{HeaderName, HeaderValue};
7use rmcp::model::{CallToolRequestParams, ClientInfo, ProtocolVersion, RawContent};
8use rmcp::transport::child_process::ConfigureCommandExt;
9use rmcp::transport::streamable_http_client::{
10 StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
11};
12use rmcp::transport::TokioChildProcess;
13use rmcp::ServiceExt;
14use serde::Deserialize;
15use tokio::process::Command;
16use tokio::sync::Semaphore;
17use tracing::{error, info, warn};
18
19const DEFAULT_HEALTH_INTERVAL_SECS: u64 = 60;
20const TOOLS_CACHE_TTL_SECS: u64 = 300;
21const DEFAULT_CIRCUIT_BREAKER_FAILURE_THRESHOLD: u32 = 5;
22const DEFAULT_CIRCUIT_BREAKER_COOLDOWN_SECS: u64 = 30;
23const DEFAULT_MAX_CONCURRENT_REQUESTS: u32 = 4;
24const DEFAULT_QUEUE_WAIT_MS: u64 = 200;
25const DEFAULT_RATE_LIMIT_PER_MINUTE: u32 = 120;
26#[cfg(windows)]
27const CREATE_NO_WINDOW: u32 = 0x0800_0000;
28
29pub fn collect_config_paths(data_root: &Path) -> Vec<PathBuf> {
31 let mut paths = vec![data_root.join("mcp.json")];
32 let mut fragments = std::fs::read_dir(data_root.join("mcp.d"))
33 .map(|entries| {
34 entries
35 .flatten()
36 .map(|entry| entry.path())
37 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
38 .collect::<Vec<_>>()
39 })
40 .unwrap_or_default();
41 fragments.sort();
42 paths.extend(fragments);
43 paths
44}
45
46fn default_transport() -> String {
49 "stdio".to_string()
50}
51
52fn resolve_request_timeout_secs(
53 config_timeout_secs: Option<u64>,
54 default_request_timeout_secs: u64,
55) -> u64 {
56 config_timeout_secs
57 .unwrap_or(default_request_timeout_secs)
58 .max(1)
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum McpTrust {
76 Trusted,
77 #[default]
78 Limited,
79 Sandboxed,
80}
81
82#[derive(Debug, Clone, Deserialize)]
83pub struct McpServerConfig {
84 #[serde(default = "default_transport")]
85 pub transport: String,
86 #[serde(default)]
88 pub trust: McpTrust,
89 #[serde(default, alias = "protocolVersion")]
90 pub protocol_version: Option<String>,
91 #[serde(default)]
92 pub request_timeout_secs: Option<u64>,
93 #[serde(default)]
94 pub max_retries: Option<u32>,
95 #[serde(default)]
96 pub health_interval_secs: Option<u64>,
97 #[serde(default, alias = "circuitBreakerFailureThreshold")]
98 pub circuit_breaker_failure_threshold: Option<u32>,
99 #[serde(default, alias = "circuitBreakerCooldownSecs")]
100 pub circuit_breaker_cooldown_secs: Option<u64>,
101 #[serde(default, alias = "maxConcurrentRequests")]
102 pub max_concurrent_requests: Option<u32>,
103 #[serde(default, alias = "queueWaitMs")]
104 pub queue_wait_ms: Option<u64>,
105 #[serde(default, alias = "rateLimitPerMinute")]
106 pub rate_limit_per_minute: Option<u32>,
107
108 #[serde(default)]
110 pub command: String,
111 #[serde(default)]
112 pub args: Vec<String>,
113 #[serde(default)]
114 pub env: HashMap<String, String>,
115
116 #[serde(default, alias = "url")]
118 pub endpoint: String,
119 #[serde(default)]
120 pub headers: HashMap<String, String>,
121}
122
123#[derive(Debug, Deserialize)]
124pub struct McpConfig {
125 #[serde(default, alias = "defaultProtocolVersion")]
126 pub default_protocol_version: Option<String>,
127 #[serde(rename = "mcpServers")]
128 pub mcp_servers: HashMap<String, McpServerConfig>,
129}
130
131#[derive(Debug, Clone)]
132pub struct McpToolInfo {
133 pub server_name: String,
134 pub name: String,
135 pub description: String,
136 pub input_schema: serde_json::Value,
137}
138
139#[derive(Debug)]
142struct CircuitBreakerState {
143 threshold: u32,
144 cooldown: Duration,
145 consecutive_failures: u32,
146 open_until: Option<Instant>,
147}
148
149impl CircuitBreakerState {
150 fn new(threshold: u32, cooldown_secs: u64) -> Self {
151 Self {
152 threshold,
153 cooldown: Duration::from_secs(cooldown_secs.max(1)),
154 consecutive_failures: 0,
155 open_until: None,
156 }
157 }
158
159 fn check_ready(&mut self, now: Instant) -> Result<(), u64> {
160 if self.threshold == 0 {
161 return Ok(());
162 }
163 if let Some(open_until) = self.open_until {
164 if now < open_until {
165 return Err((open_until - now).as_secs().max(1));
166 }
167 self.open_until = None;
168 self.consecutive_failures = 0;
169 }
170 Ok(())
171 }
172
173 fn record_success(&mut self) {
174 self.consecutive_failures = 0;
175 self.open_until = None;
176 }
177
178 fn record_failure(&mut self, now: Instant) -> bool {
179 if self.threshold == 0 {
180 return false;
181 }
182 self.consecutive_failures = self.consecutive_failures.saturating_add(1);
183 if self.consecutive_failures >= self.threshold {
184 self.open_until = Some(now + self.cooldown);
185 self.consecutive_failures = 0;
186 return true;
187 }
188 false
189 }
190}
191
192#[derive(Debug)]
193struct FixedWindowRateLimiter {
194 limit_per_minute: u32,
195 window_started_at: Instant,
196 used_in_window: u32,
197}
198
199impl FixedWindowRateLimiter {
200 fn new(limit_per_minute: u32) -> Self {
201 Self {
202 limit_per_minute,
203 window_started_at: Instant::now(),
204 used_in_window: 0,
205 }
206 }
207
208 fn consume_or_retry_after_secs(&mut self, now: Instant) -> Result<(), u64> {
209 if self.limit_per_minute == 0 {
210 return Ok(());
211 }
212 let window = Duration::from_secs(60);
213 if now.duration_since(self.window_started_at) >= window {
214 self.window_started_at = now;
215 self.used_in_window = 0;
216 }
217 if self.used_in_window < self.limit_per_minute {
218 self.used_in_window = self.used_in_window.saturating_add(1);
219 return Ok(());
220 }
221 let retry_after = window
222 .saturating_sub(now.duration_since(self.window_started_at))
223 .as_secs()
224 .max(1);
225 Err(retry_after)
226 }
227}
228
229enum McpPeer {
232 Stdio(rmcp::service::RunningService<rmcp::RoleClient, ClientInfo>),
233 Http(rmcp::service::RunningService<rmcp::RoleClient, ClientInfo>),
234}
235
236impl McpPeer {
237 fn peer(&self) -> &rmcp::Peer<rmcp::RoleClient> {
238 match self {
239 McpPeer::Stdio(s) => s.peer(),
240 McpPeer::Http(s) => s.peer(),
241 }
242 }
243}
244
245pub struct McpServer {
248 name: String,
249 trust: McpTrust,
250 peer: McpPeer,
251 request_timeout: Duration,
252 max_retries: u32,
253 tools_cache: StdMutex<Vec<McpToolInfo>>,
254 tools_cache_updated_at: StdMutex<Option<Instant>>,
255 circuit_breaker: StdMutex<CircuitBreakerState>,
256 inflight_limiter: Arc<Semaphore>,
257 queue_wait: Duration,
258 rate_limiter: StdMutex<FixedWindowRateLimiter>,
259}
260
261impl McpServer {
262 pub async fn connect(
263 name: &str,
264 config: &McpServerConfig,
265 default_protocol_version: Option<&str>,
266 default_request_timeout_secs: u64,
267 ) -> Result<Self, String> {
268 let request_timeout = Duration::from_secs(resolve_request_timeout_secs(
269 config.request_timeout_secs,
270 default_request_timeout_secs,
271 ));
272 let max_retries = config.max_retries.unwrap_or(0);
273 let requested_protocol = config
274 .protocol_version
275 .clone()
276 .or_else(|| default_protocol_version.map(|v| v.to_string()));
277 let client_info = build_client_info(requested_protocol);
278 let circuit_breaker_threshold = config
279 .circuit_breaker_failure_threshold
280 .unwrap_or(DEFAULT_CIRCUIT_BREAKER_FAILURE_THRESHOLD);
281 let circuit_breaker_cooldown_secs = config
282 .circuit_breaker_cooldown_secs
283 .unwrap_or(DEFAULT_CIRCUIT_BREAKER_COOLDOWN_SECS);
284 let max_concurrent_requests = config
285 .max_concurrent_requests
286 .unwrap_or(DEFAULT_MAX_CONCURRENT_REQUESTS)
287 .max(1);
288 let queue_wait =
289 Duration::from_millis(config.queue_wait_ms.unwrap_or(DEFAULT_QUEUE_WAIT_MS).max(1));
290 let rate_limit_per_minute = config
291 .rate_limit_per_minute
292 .unwrap_or(DEFAULT_RATE_LIMIT_PER_MINUTE);
293 let transport_name = config.transport.trim().to_ascii_lowercase();
294
295 let peer = match transport_name.as_str() {
296 "stdio" | "" => {
297 if config.command.trim().is_empty() {
298 return Err(format!(
299 "MCP server '{name}' requires `command` when transport=stdio"
300 ));
301 }
302 let resolved = resolve_command(&config.command);
303 let transport = TokioChildProcess::new(Command::new(&resolved).configure(|cmd| {
304 cmd.args(&config.args);
305 cmd.envs(&config.env);
306 cmd.stderr(std::process::Stdio::null());
307 #[cfg(windows)]
308 hide_windows_subprocess_window_tokio(cmd);
309 }))
310 .map_err(|e| format!("Failed to spawn MCP server '{name}': {e}"))?;
311
312 let running = client_info.clone().serve(transport).await.map_err(|e| {
313 format!("Failed to initialize MCP server '{name}' (stdio): {e}")
314 })?;
315 McpPeer::Stdio(running)
316 }
317 "streamable_http" | "http" => {
318 if config.endpoint.trim().is_empty() {
319 return Err(format!(
320 "MCP server '{name}' requires `endpoint` when transport=streamable_http"
321 ));
322 }
323 let mut custom_headers: HashMap<HeaderName, HeaderValue> = HashMap::new();
324 for (k, v) in &config.headers {
325 let header_name = HeaderName::from_bytes(k.as_bytes())
326 .map_err(|e| format!("Invalid header name '{k}' for MCP '{name}': {e}"))?;
327 let header_value = HeaderValue::from_str(v).map_err(|e| {
328 format!("Invalid header value for '{k}' in MCP '{name}': {e}")
329 })?;
330 custom_headers.insert(header_name, header_value);
331 }
332 let http_config =
333 StreamableHttpClientTransportConfig::with_uri(config.endpoint.as_str())
334 .custom_headers(custom_headers);
335 let transport = StreamableHttpClientTransport::from_config(http_config);
336 let running =
337 client_info.clone().serve(transport).await.map_err(|e| {
338 format!("Failed to initialize MCP server '{name}' (http): {e}")
339 })?;
340 McpPeer::Http(running)
341 }
342 other => {
343 return Err(format!(
344 "MCP server '{name}' has unsupported transport '{other}'"
345 ));
346 }
347 };
348
349 let server = McpServer {
350 name: name.to_string(),
351 trust: config.trust,
352 peer,
353 request_timeout,
354 max_retries,
355 tools_cache: StdMutex::new(Vec::new()),
356 tools_cache_updated_at: StdMutex::new(None),
357 circuit_breaker: StdMutex::new(CircuitBreakerState::new(
358 circuit_breaker_threshold,
359 circuit_breaker_cooldown_secs,
360 )),
361 inflight_limiter: Arc::new(Semaphore::new(max_concurrent_requests as usize)),
362 queue_wait,
363 rate_limiter: StdMutex::new(FixedWindowRateLimiter::new(rate_limit_per_minute)),
364 };
365
366 let _ = server.refresh_tools_cache(true).await?;
367
368 Ok(server)
369 }
370
371 pub fn trust(&self) -> McpTrust {
372 self.trust
373 }
374
375 fn is_cache_fresh(&self) -> bool {
376 let guard = self
377 .tools_cache_updated_at
378 .lock()
379 .unwrap_or_else(|e| e.into_inner());
380 if let Some(ts) = *guard {
381 ts.elapsed() < Duration::from_secs(TOOLS_CACHE_TTL_SECS)
382 } else {
383 false
384 }
385 }
386
387 fn set_tools_cache(&self, tools: Vec<McpToolInfo>) {
388 {
389 let mut guard = self.tools_cache.lock().unwrap_or_else(|e| e.into_inner());
390 *guard = tools;
391 }
392 let mut ts = self
393 .tools_cache_updated_at
394 .lock()
395 .unwrap_or_else(|e| e.into_inner());
396 *ts = Some(Instant::now());
397 }
398
399 pub fn tools_snapshot(&self) -> Vec<McpToolInfo> {
400 self.tools_cache
401 .lock()
402 .unwrap_or_else(|e| e.into_inner())
403 .clone()
404 }
405
406 fn is_tool_not_found_error(err: &str) -> bool {
407 let lower = err.to_ascii_lowercase();
408 lower.contains("not found")
409 || lower.contains("unknown tool")
410 || lower.contains("tool not found")
411 }
412
413 fn should_retry_error(err: &str) -> bool {
414 let lower = err.to_ascii_lowercase();
415 lower.contains("timeout")
416 || lower.contains("request timeout")
417 || lower.contains("transport closed")
418 || lower.contains("closed connection")
419 || lower.contains("broken pipe")
420 || lower.contains("connection reset")
421 || lower.contains("temporarily unavailable")
422 }
423
424 fn invalidate_tools_cache(&self) {
425 {
426 let mut cache = self.tools_cache.lock().unwrap_or_else(|e| e.into_inner());
427 cache.clear();
428 }
429 let mut ts = self
430 .tools_cache_updated_at
431 .lock()
432 .unwrap_or_else(|e| e.into_inner());
433 *ts = None;
434 }
435
436 async fn list_tools_uncached(&self) -> Result<Vec<McpToolInfo>, String> {
437 let rmcp_tools = self.peer.peer().list_all_tools();
438 let rmcp_tools = tokio::time::timeout(self.request_timeout, rmcp_tools)
439 .await
440 .map_err(|_| {
441 format!(
442 "MCP list tools timed out for '{}' after {:?}",
443 self.name, self.request_timeout
444 )
445 })?
446 .map_err(|e| format!("Failed to list tools for '{}': {e}", self.name))?;
447
448 let tools = rmcp_tools
449 .into_iter()
450 .map(|t| {
451 let input_schema = serde_json::to_value(&*t.input_schema)
452 .unwrap_or_else(|_| serde_json::json!({"type": "object", "properties": {}}));
453 McpToolInfo {
454 server_name: self.name.clone(),
455 name: t.name.into_owned(),
456 description: t.description.map(|d| d.into_owned()).unwrap_or_default(),
457 input_schema,
458 }
459 })
460 .collect();
461
462 Ok(tools)
463 }
464
465 pub async fn refresh_tools_cache(&self, force: bool) -> Result<Vec<McpToolInfo>, String> {
466 if !force && self.is_cache_fresh() {
467 return Ok(self.tools_snapshot());
468 }
469
470 let tools = self.list_tools_uncached().await?;
471 self.set_tools_cache(tools.clone());
472 Ok(tools)
473 }
474
475 pub async fn health_probe(&self) -> Result<(), String> {
476 let _ = self.refresh_tools_cache(true).await?;
477 Ok(())
478 }
479
480 pub fn start_health_probe(self: Arc<Self>, interval_secs: u64) {
481 if interval_secs == 0 {
482 return;
483 }
484
485 tokio::spawn(async move {
486 loop {
487 tokio::time::sleep(Duration::from_secs(interval_secs)).await;
488 if let Err(e) = self.health_probe().await {
489 warn!("MCP health probe failed for '{}': {}", self.name, e);
490 }
491 }
492 });
493 }
494
495 async fn call_tool_inner(
496 &self,
497 tool_name: &str,
498 arguments: serde_json::Value,
499 ) -> Result<String, String> {
500 let args_map = arguments.as_object().cloned().unwrap_or_default();
501
502 let params = CallToolRequestParams::new(tool_name.to_string()).with_arguments(args_map);
503
504 let result = self.peer.peer().call_tool(params);
505 let result = tokio::time::timeout(self.request_timeout, result)
506 .await
507 .map_err(|_| {
508 format!(
509 "MCP tool call timed out for '{}' after {:?}",
510 self.name, self.request_timeout
511 )
512 })?
513 .map_err(|e| format!("MCP tool call error: {e}"))?;
514
515 let is_error = result.is_error.unwrap_or(false);
516
517 let mut output = String::new();
518 for item in &result.content {
519 if let RawContent::Text(text_content) = &item.raw {
520 if !output.is_empty() {
521 output.push('\n');
522 }
523 output.push_str(&text_content.text);
524 }
525 }
526
527 if output.is_empty() {
528 output = serde_json::to_string_pretty(&result.content).unwrap_or_default();
529 }
530
531 if is_error {
532 if Self::is_tool_not_found_error(&output) {
533 self.invalidate_tools_cache();
534 let _ = self.refresh_tools_cache(true).await;
535 }
536 Err(output)
537 } else {
538 Ok(output)
539 }
540 }
541
542 pub async fn call_tool(
543 &self,
544 tool_name: &str,
545 arguments: serde_json::Value,
546 ) -> Result<String, String> {
547 let snapshot = self.tools_snapshot();
549 if !snapshot.iter().any(|t| t.name == tool_name) {
550 let _ = self.refresh_tools_cache(true).await;
551 } else {
552 let _ = self.refresh_tools_cache(false).await;
553 }
554
555 {
557 let mut rate = self.rate_limiter.lock().unwrap_or_else(|e| e.into_inner());
558 if let Err(retry_after_secs) = rate.consume_or_retry_after_secs(Instant::now()) {
559 return Err(format!(
560 "MCP server '{}' rate-limited; retry in ~{}s",
561 self.name, retry_after_secs
562 ));
563 }
564 }
565
566 let permit = tokio::time::timeout(
568 self.queue_wait,
569 self.inflight_limiter.clone().acquire_owned(),
570 )
571 .await
572 .map_err(|_| {
573 format!(
574 "MCP server '{}' busy; exceeded queue wait of {:?}",
575 self.name, self.queue_wait
576 )
577 })?
578 .map_err(|_| format!("MCP server '{}' limiter is closed", self.name))?;
579
580 {
582 let mut breaker = self
583 .circuit_breaker
584 .lock()
585 .unwrap_or_else(|e| e.into_inner());
586 if let Err(remaining_secs) = breaker.check_ready(Instant::now()) {
587 return Err(format!(
588 "MCP server '{}' circuit open; retry in ~{}s",
589 self.name, remaining_secs
590 ));
591 }
592 }
593
594 let mut attempt: u32 = 0;
595 let result = loop {
596 let result = self.call_tool_inner(tool_name, arguments.clone()).await;
597 match &result {
598 Ok(_) => break result,
599 Err(err) if attempt < self.max_retries && Self::should_retry_error(err) => {
600 let backoff_ms = 200u64.saturating_mul(2u64.saturating_pow(attempt.min(8)));
601 warn!(
602 "MCP server '{}' call failed (attempt {}/{}), retrying in {}ms: {}",
603 self.name,
604 attempt + 1,
605 self.max_retries + 1,
606 backoff_ms,
607 err
608 );
609 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
610 attempt = attempt.saturating_add(1);
611 }
612 _ => break result,
613 }
614 };
615 drop(permit);
616
617 let mut breaker = self
619 .circuit_breaker
620 .lock()
621 .unwrap_or_else(|e| e.into_inner());
622 match &result {
623 Ok(_) => breaker.record_success(),
624 Err(_) => {
625 if breaker.record_failure(Instant::now()) {
626 warn!(
627 "MCP server '{}' circuit opened after consecutive failures",
628 self.name
629 );
630 }
631 }
632 }
633
634 result
635 }
636}
637
638pub struct McpManager {
641 servers: Vec<Arc<McpServer>>,
642}
643
644impl McpManager {
645 pub async fn from_config_file(path: &str, default_request_timeout_secs: u64) -> Self {
646 Self::from_config_paths(&[PathBuf::from(path)], default_request_timeout_secs).await
647 }
648
649 pub async fn from_config_paths(paths: &[PathBuf], default_request_timeout_secs: u64) -> Self {
650 let default_request_timeout_secs =
651 resolve_request_timeout_secs(None, default_request_timeout_secs);
652 let (loaded_any_config, merged_default_protocol_version, merged_servers) =
653 merge_config_sources(paths);
654 if !loaded_any_config {
655 return McpManager {
656 servers: Vec::new(),
657 };
658 }
659
660 let mut servers = Vec::new();
661 let mut entries: Vec<(String, McpServerConfig)> = merged_servers.into_iter().collect();
662 entries.sort_by(|a, b| a.0.cmp(&b.0));
663 for (name, server_config) in entries {
664 info!("Connecting to MCP server '{name}'...");
665 match tokio::time::timeout(
666 Duration::from_secs(30),
667 McpServer::connect(
668 &name,
669 &server_config,
670 merged_default_protocol_version.as_deref(),
671 default_request_timeout_secs,
672 ),
673 )
674 .await
675 {
676 Ok(Ok(server)) => {
677 let server = Arc::new(server);
678 let interval = server_config
679 .health_interval_secs
680 .unwrap_or(DEFAULT_HEALTH_INTERVAL_SECS);
681 server.clone().start_health_probe(interval);
682
683 info!(
684 "MCP server '{name}' connected ({} tools)",
685 server.tools_snapshot().len(),
686 );
687 servers.push(server);
688 }
689 Ok(Err(e)) => {
690 warn!("Failed to connect MCP server '{name}': {e}");
691 }
692 Err(_) => {
693 warn!("MCP server '{name}' connection timed out (30s)");
694 }
695 }
696 }
697
698 McpManager { servers }
699 }
700
701 #[allow(dead_code)]
702 pub fn servers(&self) -> &[Arc<McpServer>] {
703 &self.servers
704 }
705
706 pub fn all_tools(&self) -> Vec<(Arc<McpServer>, McpToolInfo)> {
707 let mut tools = Vec::new();
708 for server in &self.servers {
709 for tool in server.tools_snapshot() {
710 tools.push((server.clone(), tool));
711 }
712 }
713 tools
714 }
715}
716
717fn merge_config_sources(
718 paths: &[PathBuf],
719) -> (bool, Option<String>, HashMap<String, McpServerConfig>) {
720 let mut merged_default_protocol_version: Option<String> = None;
721 let mut merged_servers: HashMap<String, McpServerConfig> = HashMap::new();
722 let mut loaded_any_config = false;
723 for path in paths {
724 let Some(config) = load_config_from_path(path) else {
725 continue;
726 };
727 loaded_any_config = true;
728 if let Some(protocol_version) = config.default_protocol_version {
729 merged_default_protocol_version = Some(protocol_version);
730 }
731 for (name, server_cfg) in config.mcp_servers {
732 if merged_servers.insert(name.clone(), server_cfg).is_some() {
733 warn!(
734 "MCP server '{}' overridden by later config source '{}'",
735 name,
736 path.display()
737 );
738 }
739 }
740 }
741 (
742 loaded_any_config,
743 merged_default_protocol_version,
744 merged_servers,
745 )
746}
747
748fn load_config_from_path(path: &Path) -> Option<McpConfig> {
749 let config_str = match std::fs::read_to_string(path) {
750 Ok(s) => s,
751 Err(_) => return None,
752 };
753 match serde_json::from_str::<McpConfig>(&config_str) {
754 Ok(cfg) => Some(cfg),
755 Err(e) => {
756 error!("Failed to parse MCP config {}: {e}", path.display());
757 None
758 }
759 }
760}
761
762fn build_client_info(requested_protocol: Option<String>) -> ClientInfo {
763 let mut info = ClientInfo::default();
764 if let Some(protocol_version) = requested_protocol {
765 let parsed = serde_json::from_value::<ProtocolVersion>(serde_json::json!(protocol_version))
766 .unwrap_or_else(|_| ProtocolVersion::default());
767 info = info.with_protocol_version(parsed);
768 }
769 info
770}
771
772#[cfg(windows)]
773fn hide_windows_subprocess_window_tokio(cmd: &mut tokio::process::Command) {
774 use std::os::windows::process::CommandExt;
775 cmd.as_std_mut().creation_flags(CREATE_NO_WINDOW);
776}
777
778#[cfg(windows)]
779fn hide_windows_subprocess_window_std(cmd: &mut std::process::Command) {
780 std::os::windows::process::CommandExt::creation_flags(cmd, CREATE_NO_WINDOW);
781}
782
783fn resolve_command(command: &str) -> String {
785 if std::path::Path::new(command).is_absolute() {
786 return command.to_string();
787 }
788
789 let mut lookup = std::process::Command::new(if cfg!(windows) { "where" } else { "which" });
790 lookup.arg(command);
791 #[cfg(windows)]
792 hide_windows_subprocess_window_std(&mut lookup);
793
794 if let Ok(output) = lookup.output() {
795 if output.status.success() {
796 let resolved = String::from_utf8_lossy(&output.stdout)
797 .lines()
798 .next()
799 .unwrap_or("")
800 .trim()
801 .to_string();
802 if !resolved.is_empty() {
803 return resolved;
804 }
805 }
806 }
807
808 #[cfg(windows)]
809 {
810 let candidates = [
811 format!("C:\\Program Files\\nodejs\\{command}.cmd"),
812 format!("C:\\Program Files\\nodejs\\{command}.exe"),
813 format!("C:\\Program Files\\nodejs\\{command}"),
814 ];
815 for candidate in &candidates {
816 if std::path::Path::new(candidate).exists() {
817 return candidate.clone();
818 }
819 }
820 }
821
822 command.to_string()
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828
829 #[test]
830 fn test_mcp_config_defaults() {
831 let json = r#"{
832 "mcpServers": {
833 "demo": {
834 "command": "npx",
835 "args": ["-y", "@modelcontextprotocol/server-filesystem", "."]
836 }
837 }
838 }"#;
839
840 let cfg: McpConfig = serde_json::from_str(json).unwrap();
841 let server = cfg.mcp_servers.get("demo").unwrap();
842 assert_eq!(server.transport, "stdio");
843 assert!(server.protocol_version.is_none());
844 assert!(server.max_retries.is_none());
845 assert!(server.circuit_breaker_failure_threshold.is_none());
846 assert!(server.circuit_breaker_cooldown_secs.is_none());
847 assert!(server.max_concurrent_requests.is_none());
848 assert!(server.queue_wait_ms.is_none());
849 assert!(server.rate_limit_per_minute.is_none());
850 assert_eq!(server.trust, McpTrust::Limited);
852 }
853
854 #[test]
855 fn test_mcp_trust_tier_parses() {
856 let json = r#"{
857 "mcpServers": {
858 "safe": {"command": "a", "trust": "trusted"},
859 "risky": {"command": "b", "trust": "sandboxed"}
860 }
861 }"#;
862 let cfg: McpConfig = serde_json::from_str(json).unwrap();
863 assert_eq!(
864 cfg.mcp_servers.get("safe").unwrap().trust,
865 McpTrust::Trusted
866 );
867 assert_eq!(
868 cfg.mcp_servers.get("risky").unwrap().trust,
869 McpTrust::Sandboxed
870 );
871 }
872
873 #[test]
874 fn test_tool_not_found_error_detection() {
875 assert!(McpServer::is_tool_not_found_error("Tool not found"));
876 assert!(McpServer::is_tool_not_found_error("unknown tool: x"));
877 assert!(!McpServer::is_tool_not_found_error("permission denied"));
878 }
879
880 #[test]
881 fn test_mcp_http_config_parse() {
882 let json = r#"{
883 "default_protocol_version": "2024-11-05",
884 "mcpServers": {
885 "remote": {
886 "transport": "streamable_http",
887 "endpoint": "http://127.0.0.1:8080/mcp",
888 "headers": {"Authorization": "Bearer test"},
889 "max_retries": 3,
890 "health_interval_secs": 15
891 }
892 }
893 }"#;
894
895 let cfg: McpConfig = serde_json::from_str(json).unwrap();
896 assert_eq!(cfg.default_protocol_version.unwrap(), "2024-11-05");
897 let remote = cfg.mcp_servers.get("remote").unwrap();
898 assert_eq!(remote.transport, "streamable_http");
899 assert_eq!(remote.endpoint, "http://127.0.0.1:8080/mcp");
900 assert_eq!(remote.max_retries, Some(3));
901 assert_eq!(remote.health_interval_secs, Some(15));
902 }
903
904 #[test]
905 fn test_resolve_request_timeout_secs_prefers_server_override() {
906 assert_eq!(resolve_request_timeout_secs(Some(25), 90), 25);
907 assert_eq!(resolve_request_timeout_secs(None, 90), 90);
908 assert_eq!(resolve_request_timeout_secs(Some(0), 90), 1);
909 assert_eq!(resolve_request_timeout_secs(None, 0), 1);
910 }
911
912 #[test]
913 fn test_mcp_bulkhead_and_rate_limit_parse() {
914 let json = r#"{
915 "mcpServers": {
916 "remote": {
917 "transport": "streamable_http",
918 "endpoint": "http://127.0.0.1:8080/mcp",
919 "max_concurrent_requests": 6,
920 "queue_wait_ms": 500,
921 "rate_limit_per_minute": 240
922 }
923 }
924 }"#;
925
926 let cfg: McpConfig = serde_json::from_str(json).unwrap();
927 let remote = cfg.mcp_servers.get("remote").unwrap();
928 assert_eq!(remote.max_concurrent_requests, Some(6));
929 assert_eq!(remote.queue_wait_ms, Some(500));
930 assert_eq!(remote.rate_limit_per_minute, Some(240));
931 }
932
933 #[test]
934 fn test_merge_config_sources_later_overrides_earlier() {
935 let dir =
936 std::env::temp_dir().join(format!("microclaw_mcp_merge_{}", uuid::Uuid::new_v4()));
937 std::fs::create_dir_all(&dir).unwrap();
938 let base = dir.join("00-base.json");
939 let override_cfg = dir.join("10-override.json");
940 std::fs::write(
941 &base,
942 r#"{
943 "defaultProtocolVersion": "2024-11-05",
944 "mcpServers": {
945 "shared": {
946 "transport": "streamable_http",
947 "endpoint": "http://127.0.0.1:7001/mcp"
948 }
949 }
950 }"#,
951 )
952 .unwrap();
953 std::fs::write(
954 &override_cfg,
955 r#"{
956 "defaultProtocolVersion": "2025-12-01",
957 "mcpServers": {
958 "shared": {
959 "transport": "streamable_http",
960 "endpoint": "http://127.0.0.1:7002/mcp"
961 },
962 "extra": {
963 "transport": "streamable_http",
964 "endpoint": "http://127.0.0.1:7003/mcp"
965 }
966 }
967 }"#,
968 )
969 .unwrap();
970
971 let (loaded, protocol, servers) = merge_config_sources(&[base, override_cfg]);
972 assert!(loaded);
973 assert_eq!(protocol.as_deref(), Some("2025-12-01"));
974 assert_eq!(servers.len(), 2);
975 assert_eq!(
976 servers.get("shared").map(|s| s.endpoint.as_str()),
977 Some("http://127.0.0.1:7002/mcp")
978 );
979 assert_eq!(
980 servers.get("extra").map(|s| s.endpoint.as_str()),
981 Some("http://127.0.0.1:7003/mcp")
982 );
983
984 let _ = std::fs::remove_dir_all(&dir);
985 }
986
987 #[test]
988 fn collect_config_paths_includes_canonical_file_and_sorted_fragments() {
989 let directory = tempfile::tempdir().unwrap();
990 let fragments = directory.path().join("mcp.d");
991 std::fs::create_dir_all(&fragments).unwrap();
992 std::fs::write(fragments.join("20-second.json"), "{}").unwrap();
993 std::fs::write(fragments.join("10-first.json"), "{}").unwrap();
994 std::fs::write(fragments.join("ignored.txt"), "{}").unwrap();
995
996 let paths = collect_config_paths(directory.path());
997
998 assert_eq!(paths[0], directory.path().join("mcp.json"));
999 assert_eq!(paths[1], fragments.join("10-first.json"));
1000 assert_eq!(paths[2], fragments.join("20-second.json"));
1001 assert_eq!(paths.len(), 3);
1002 }
1003
1004 #[test]
1005 fn test_rate_limiter_blocks_after_limit() {
1006 let mut limiter = FixedWindowRateLimiter::new(2);
1007 let now = Instant::now();
1008 assert!(limiter.consume_or_retry_after_secs(now).is_ok());
1009 assert!(limiter.consume_or_retry_after_secs(now).is_ok());
1010 assert!(limiter.consume_or_retry_after_secs(now).is_err());
1011 }
1012
1013 #[test]
1014 fn test_rate_limiter_can_be_disabled() {
1015 let mut limiter = FixedWindowRateLimiter::new(0);
1016 let now = Instant::now();
1017 for _ in 0..10 {
1018 assert!(limiter.consume_or_retry_after_secs(now).is_ok());
1019 }
1020 }
1021
1022 #[test]
1023 fn test_circuit_breaker_trips_and_recovers() {
1024 let mut breaker = CircuitBreakerState::new(2, 1);
1025 let now = Instant::now();
1026
1027 assert!(breaker.check_ready(now).is_ok());
1028 assert!(!breaker.record_failure(now));
1029 assert!(breaker.check_ready(Instant::now()).is_ok());
1030 assert!(breaker.record_failure(Instant::now()));
1031
1032 let blocked = breaker.check_ready(Instant::now());
1033 assert!(blocked.is_err());
1034
1035 std::thread::sleep(Duration::from_millis(1100));
1036 assert!(breaker.check_ready(Instant::now()).is_ok());
1037 }
1038
1039 #[test]
1040 fn test_circuit_breaker_can_be_disabled() {
1041 let mut breaker = CircuitBreakerState::new(0, 1);
1042 assert!(breaker.check_ready(Instant::now()).is_ok());
1043 assert!(!breaker.record_failure(Instant::now()));
1044 assert!(breaker.check_ready(Instant::now()).is_ok());
1045 }
1046}