Skip to main content

microclaw_engine/
mcp.rs

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
29/// Resolve the canonical MCP config plus sorted fragment files.
30pub 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
46// --- MCP config types ---
47
48fn 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/// Named per-server trust tier, mapped onto tool-policy risk when the
62/// server's tools are registered: `trusted` → low, `limited` → medium
63/// (the historical default for every MCP tool), `sandboxed` → high (so a
64/// `max_risk: medium` policy blocks it and web/control-chat calls require
65/// explicit approval).
66///
67/// Note the mapping is two-directional: `sandboxed` raises scrutiny, and
68/// `trusted` *lowers* the tool's rated risk from the historical medium to
69/// low — so a server marked `trusted` passes a `max_risk: low` policy that
70/// would otherwise block all MCP tools. Tiers never add a tool the server
71/// didn't expose, but `trusted` is a deliberate risk downgrade; audit it
72/// as such.
73#[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    /// Trust tier for every tool this server exposes. Default: limited.
87    #[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    // stdio transport
109    #[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    // streamable_http transport
117    #[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// --- Resilience primitives (unchanged from before) ---
140
141#[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
229// --- rmcp peer wrapper ---
230
231enum 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
245// --- MCP server connection ---
246
247pub 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        // Refresh cache if tool not known, else lazy refresh
548        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        // Rate limiter
556        {
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        // Bulkhead
567        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        // Circuit breaker check
581        {
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        // Update circuit breaker
618        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
638// --- MCP manager ---
639
640pub 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
783/// Resolve a command name to its full path.
784fn 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        // Historical behavior preserved: no trust field → limited.
851        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}