Skip to main content

oxicode_agent/mcp/
mod.rs

1//! MCP (Model Context Protocol) integration.
2//!
3//! Provides a built-in `mcp` tool that acts as a gateway to MCP servers,
4//! plus a per-tool direct-registration path (Phase 3) and a disk-backed
5//! metadata cache (Phase 1) that lets `search` / `list` / `describe`
6//! work without a live connection.
7//!
8//! # Architecture
9//!
10//! ```text
11//! McpTool (AgentTool) ──┐
12//! McpDirectTool  (x N) ─┴─→ McpManager ─→ McpClient (per server, Transport-based)
13//!                         │       ├── JSON-RPC over transport (stdio / http_sse)
14//!                         │       ├── Metadata cache (disk-backed)
15//!                         │       ├── Consent manager (disk-backed)
16//!                         │       └── Lifecycle task (mpsc, owns idle/health timers)
17//! ```
18//!
19//! # Concurrency
20//!
21//! `McpManager` is internally `Arc<McpManager>` after `spawn()`. The
22//! lifecycle timer task receives a `Weak<McpManager>` so it never
23//! participates in a reference cycle. The inner state is guarded by
24//! `tokio::sync::Mutex` for write paths and `parking_lot::RwLock` for
25//! cheap read paths (cache, consent).
26//!
27//! # Config format
28//!
29//! ```json
30//! {
31//!   "mcpServers": {
32//!     "my-server": {
33//!       "command": "npx",
34//!       "args": ["-y", "@my-org/mcp-server"],
35//!       "lifecycle": "lazy",
36//!       "idleTimeout": 10,
37//!       "directTools": true
38//!     }
39//!   },
40//!   "settings": {
41//!     "toolPrefix": "server"
42//!   }
43//! }
44//! ```
45
46pub mod auth;
47pub mod cache;
48pub mod client;
49pub mod config;
50pub mod consent;
51pub mod content;
52pub mod direct_tool;
53pub mod error;
54pub mod lifecycle;
55pub mod spawn;
56pub mod tool;
57pub mod transport;
58pub mod types;
59
60pub use auth::{Credential, McpCredentialProvider, NoopCredentialProvider};
61pub use cache::MetadataCache;
62pub use client::{McpClient, McpLogLevel, McpPrompt, McpPromptArgument, McpSamplingRequest};
63pub use consent::ConsentManager;
64pub use direct_tool::McpDirectTool;
65pub use error::McpError;
66pub use spawn::{NoopSpawnValidator, SpawnValidator};
67pub use tool::McpTool;
68pub use transport::{McpTransport, http::StreamableHttpTransport, stdio::StdioTransport};
69pub use types::{
70    ConsentState, DirectToolDef, DirectToolsConfig, LifecycleMode, McpCallResult, McpConfig,
71    McpConnectionStatus, McpContent, McpDashboardData, McpServerInfo, McpSettings, McpSettingsView,
72    McpToolDef, McpToolInfo, ServerEntry, ServerInfo, ServerStatus, ToolMetadata, ToolPrefix,
73    effective_prefix_mode, format_schema, format_tool_name, get_server_prefix,
74};
75
76use anyhow::{Context, Result};
77use std::collections::{HashMap, HashSet};
78use std::path::PathBuf;
79use std::sync::Arc;
80use std::time::{Duration, Instant};
81
82use lifecycle::{LifecycleEvent, channel as lifecycle_channel, lifecycle_event_loop};
83
84/// Default back-off period after a server connection failure (seconds).
85pub const DEFAULT_FAILURE_BACKOFF_SECS: u64 = 30;
86/// Default global idle timeout (minutes).
87pub const DEFAULT_IDLE_TIMEOUT_MINS: u64 = 10;
88
89/// Inner mutable state for [`McpManager`].
90pub struct McpManagerInner {
91    /// Connected MCP clients (server name → client).
92    clients: HashMap<String, McpClient>,
93    /// Raw tool definitions (server name → list, in original naming).
94    /// Prefixed names are computed at lookup time.
95    raw_tool_metadata: HashMap<String, Vec<McpToolDef>>,
96    /// Server connection failure timestamps (for back-off).
97    failure_tracker: HashMap<String, Instant>,
98    /// Servers whose connection is currently in progress.
99    /// Prevents two concurrent `ensure_connected` calls from racing.
100    connecting: HashSet<String>,
101}
102
103/// Determine which server names to auto-trust during the one-time consent
104/// migration. Only servers found in the provided config `paths` (expected:
105/// global config files) are included — project-local config files are
106/// deliberately NOT passed here, so servers introduced by `git clone` stay
107/// `Ask` at the spawn gate. This is the clone-to-RCE guard; see the
108/// `migration_trusts_only_global_servers_not_project_local` regression test.
109fn migration_trust_set(global_paths: &[PathBuf]) -> HashSet<String> {
110    global_paths
111        .iter()
112        .filter_map(|path| config::read_config_file(path))
113        .flat_map(|cfg| cfg.mcp_servers.into_keys().collect::<Vec<_>>())
114        .collect()
115}
116
117/// Central manager for all MCP server connections.
118///
119/// Created via [`McpManager::spawn()`] which returns an `Arc<Self>`.
120/// Use [`McpManager::new_no_spawn()`] only in tests where the lifecycle
121/// task is not needed.
122pub struct McpManager {
123    inner: tokio::sync::Mutex<McpManagerInner>,
124    /// Configuration (read-mostly; `parking_lot` for cheap clones).
125    config: parking_lot::RwLock<McpConfig>,
126    /// On-disk + in-memory tool metadata cache.
127    cache: MetadataCache,
128    /// Consent decisions (per-tool Allow/Deny).
129    consent: ConsentManager,
130    /// Lifecycle event channel sender.
131    lifecycle_tx: lifecycle::LifecycleTx,
132    /// Handle to the background lifecycle task (kept alive via `Arc<Self>`).
133    /// `None` when constructed with `new_no_spawn()` outside a runtime.
134    _lifecycle_handle: Option<tokio::task::JoinHandle<()>>,
135    /// Authentication credential provider for MCP servers.
136    /// Defaults to [`NoopCredentialProvider`]; replace via
137    /// [`McpManager::set_credential_provider`] to enable authenticated
138    /// servers (API keys, OAuth).
139    credential_provider: parking_lot::RwLock<Arc<dyn McpCredentialProvider>>,
140}
141
142impl std::fmt::Debug for McpManager {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        f.debug_struct("McpManager")
145            .field("cache_path", &self.cache.path())
146            .field("consent_path", &self.consent.path())
147            .finish()
148    }
149}
150
151impl McpManager {
152    /// Spawns the background lifecycle task using the oxicode-default config
153    /// and disk paths (`~/.config/oxicode/`), and eagerly connects to any
154    /// `Eager` / `KeepAlive` servers.
155    ///
156    /// Returns `Arc<Self>` so it can be shared freely across the agent
157    /// loop, the TUI dashboard, and the lifecycle task (via `Weak`).
158    pub fn spawn() -> Arc<Self> {
159        Self::spawn_with_config(config::load_mcp_config())
160    }
161
162    /// Spawn with a programmatically-supplied config (used by the SDK
163    /// `OxicodeBuilder::with_mcp_config`). Disk paths default to the oxicode
164    /// standard locations (`~/.config/oxicode/`).
165    pub fn spawn_with_config(mcp_config: McpConfig) -> Arc<Self> {
166        Self::spawn_with_paths(mcp_config, None, None)
167    }
168
169    /// **Primary constructor.** Spawn with a programmatically-supplied
170    /// config **and** optional custom disk paths for the metadata cache
171    /// and consent store.
172    ///
173    /// Pass `None` for either path to use the oxicode default
174    /// (`~/.config/oxicode/mcp-cache.json` / `mcp-consent.json`).
175    ///
176    /// This is the constructor SDK consumers (e.g. oxios) should use when
177    /// they self-host MCP state under their own config directory. It
178    /// spawns the background lifecycle task and eagerly connects to any
179    /// `Eager` / `KeepAlive` servers.
180    ///
181    /// Returns `Arc<Self>` so it can be shared freely across the agent
182    /// loop, the TUI dashboard, and the lifecycle task (via `Weak`).
183    pub fn spawn_with_paths(
184        mcp_config: McpConfig,
185        cache_path: Option<PathBuf>,
186        consent_path: Option<PathBuf>,
187    ) -> Arc<Self> {
188        let cache = match cache_path {
189            Some(p) => MetadataCache::with_path(p),
190            None => MetadataCache::new(),
191        };
192        // Loading is best-effort: a missing or malformed cache must not
193        // prevent startup.
194        let _ = cache.load();
195
196        let consent = match consent_path {
197            Some(p) => ConsentManager::with_path(p),
198            None => ConsentManager::new(),
199        };
200        // Check before load: a missing file means fresh install/upgrade and
201        // triggers the one-time auto-trust migration below.
202        let consent_existed_before = consent.path().exists();
203        let _ = consent.load();
204
205        // ── F-2 migration (code audit 2026-07-25) ───────────────────────
206        // One-time backward-compat: if the consent file didn't exist on
207        // disk, auto-trust only servers from the GLOBAL config file(s)
208        // (`~/.config/oxicode/mcp.json`, `~/.config/mcp/mcp.json`). Project-
209        // local servers (`.oxicode/mcp.json`, `.mcp.json` from a cloned repo)
210        // are deliberately left as `Ask` — auto-trusting them would reopen
211        // the clone-to-RCE surface F-2 closes. New global servers added
212        // later also require explicit `oxicode mcp trust <name>`.
213        if !consent_existed_before {
214            let global_servers = migration_trust_set(&config::global_config_paths());
215            for name in &global_servers {
216                let _ = consent.decide(name, ConsentState::Allow);
217            }
218            if !global_servers.is_empty() {
219                tracing::info!(
220                    "MCP: consent file not found — auto-trusting {} global \
221                     server(s) for backward compat; project-local servers stay \
222                     gated (`oxicode mcp trust <name>` to allow)",
223                    global_servers.len()
224                );
225            }
226        }
227        // Pre-populate the in-memory cache snapshot for any servers that
228        // have cached tools.
229        let cached_servers = cache.cached_servers();
230
231        // Detect whether a Tokio runtime is available. When absent (e.g.
232        // unit tests calling `OxicodeBuilder::build()` outside a runtime), we
233        // skip spawning the lifecycle task and eager connectors — those
234        // require a runtime, and a manager constructed this way is only
235        // useful for non-MCP work anyway. Real consumers always run inside
236        // a Tokio runtime, so this guard is transparent to them.
237        let has_runtime = tokio::runtime::Handle::try_current().is_ok();
238
239        let (lifecycle_tx, lifecycle_rx) = lifecycle_channel();
240
241        // `Arc::new_cyclic` lets us pass a `Weak<Self>` into the
242        // lifecycle task during construction, avoiding any use-before-
243        // initialization pattern.
244        let manager = Arc::new_cyclic(|weak| {
245            let _lifecycle_handle = if has_runtime {
246                Some(tokio::spawn(lifecycle_event_loop(
247                    lifecycle_rx,
248                    weak.clone(),
249                )))
250            } else {
251                None
252            };
253            Self {
254                inner: tokio::sync::Mutex::new(McpManagerInner {
255                    clients: HashMap::new(),
256                    raw_tool_metadata: HashMap::new(),
257                    failure_tracker: HashMap::new(),
258                    connecting: HashSet::new(),
259                }),
260                config: parking_lot::RwLock::new(mcp_config),
261                cache,
262                consent,
263                lifecycle_tx,
264                _lifecycle_handle,
265                credential_provider: parking_lot::RwLock::new(
266                    Arc::new(NoopCredentialProvider) as Arc<dyn McpCredentialProvider>
267                ),
268            }
269        });
270
271        // Seed the in-memory metadata from cache, so `search` / `list` /
272        // `describe` work before the first live connection.
273        {
274            let prefix_mode = effective_prefix_mode(manager.config.read().settings.as_ref());
275            // SAFETY: inside `Arc::new_cyclic` the manager is freshly constructed
276            // and nothing else holds the mutex, so `try_lock` cannot fail.
277            #[allow(clippy::expect_used)]
278            let mut inner = manager.inner.try_lock().expect("freshly constructed");
279            for server in &cached_servers {
280                let tools = manager.cache.get_tools(server, &prefix_mode);
281                if !tools.is_empty() {
282                    // Convert ToolMetadata back to raw McpToolDef for
283                    // raw_tool_metadata. (The cache stores names, but we
284                    // need the defs here.)
285                    let raw: Vec<McpToolDef> = tools
286                        .iter()
287                        .map(|t| McpToolDef {
288                            name: t.original_name.clone(),
289                            description: Some(t.description.clone()),
290                            input_schema: t.input_schema.clone(),
291                        })
292                        .collect();
293                    inner.raw_tool_metadata.insert(server.clone(), raw);
294                }
295            }
296        }
297
298        // Fire-and-forget: start eager/keep-alive servers in the background.
299        // Only when a runtime is available (see `has_runtime` above).
300        if has_runtime {
301            let mgr = manager.clone();
302            tokio::spawn(async move {
303                mgr.start_eager_servers().await;
304            });
305        }
306
307        manager
308    }
309
310    /// Construct a manager without spawning the lifecycle task.
311    /// Intended for tests that don't need timer/disconnect behaviour.
312    pub fn new_no_spawn() -> Self {
313        let cache = MetadataCache::new();
314        let _ = cache.load();
315        let consent = ConsentManager::new();
316        let _ = consent.load();
317        let (lifecycle_tx, _lifecycle_rx) = lifecycle_channel();
318        let handle = tokio::runtime::Handle::try_current()
319            .ok()
320            .map(|h| h.spawn(async {}));
321        Self {
322            inner: tokio::sync::Mutex::new(McpManagerInner {
323                clients: HashMap::new(),
324                raw_tool_metadata: HashMap::new(),
325                failure_tracker: HashMap::new(),
326                connecting: HashSet::new(),
327            }),
328            config: parking_lot::RwLock::new(config::load_mcp_config()),
329            cache,
330            consent,
331            lifecycle_tx,
332            _lifecycle_handle: handle,
333            credential_provider: parking_lot::RwLock::new(
334                Arc::new(NoopCredentialProvider) as Arc<dyn McpCredentialProvider>
335            ),
336        }
337    }
338
339    /// Get a snapshot of the current config.
340    pub fn config(&self) -> parking_lot::RwLockReadGuard<'_, McpConfig> {
341        self.config.read()
342    }
343
344    /// Hot-replace the in-memory MCP configuration.
345    ///
346    /// Used by the `/mcp` management overlay after it writes a new
347    /// config to disk: the updated server map becomes visible to
348    /// `connect()`, `status()`, and the proxy tool **without** a full
349    /// process restart. Existing live connections are left untouched;
350    /// newly added servers connect lazily on first use (or eagerly if
351    /// their [`LifecycleMode`] is `eager`/`keep-alive`).
352    ///
353    /// Direct-tool registration happens once at boot from the cache, so
354    /// newly-added `directTools` servers still require a restart to
355    /// surface as first-class agent tools — but the `mcp` proxy tool
356    /// can reach them immediately.
357    pub fn replace_config(&self, new_config: McpConfig) {
358        *self.config.write() = new_config;
359    }
360
361    /// Inject a credential provider. Replaces the noop default.
362    /// Called by oxicode-cli bootstrap (or any host product) to enable
363    /// authenticated MCP servers.
364    ///
365    /// Existing transports retain the provider they were constructed
366    /// with; new transports created after this call pick up the new
367    /// one at their next construction.
368    pub fn set_credential_provider(&self, provider: Arc<dyn McpCredentialProvider>) {
369        *self.credential_provider.write() = provider;
370    }
371
372    /// Get the consent manager.
373    pub fn consent(&self) -> &ConsentManager {
374        &self.consent
375    }
376
377    /// Get the metadata cache.
378    pub fn cache(&self) -> &MetadataCache {
379        &self.cache
380    }
381
382    fn failure_backoff_secs(&self) -> u64 {
383        self.config
384            .read()
385            .settings
386            .as_ref()
387            .and_then(|s| s.failure_backoff_secs)
388            .unwrap_or(DEFAULT_FAILURE_BACKOFF_SECS)
389    }
390
391    fn global_idle_timeout(&self) -> Duration {
392        let mins = self
393            .config
394            .read()
395            .settings
396            .as_ref()
397            .and_then(|s| s.idle_timeout)
398            .unwrap_or(DEFAULT_IDLE_TIMEOUT_MINS);
399        Duration::from_secs(mins.saturating_mul(60))
400    }
401
402    // ── Eager / Keep-Alive startup ─────────────────────────────────
403
404    /// Connect to all servers whose lifecycle is `Eager` or `KeepAlive`.
405    async fn start_eager_servers(self: &Arc<Self>) {
406        let eager_servers: Vec<(String, LifecycleMode, Option<u64>)> = {
407            let config = self.config.read();
408            config
409                .mcp_servers
410                .iter()
411                .filter_map(|(name, entry)| {
412                    let mode = entry.lifecycle.clone().unwrap_or(LifecycleMode::Lazy);
413                    match mode {
414                        LifecycleMode::Eager | LifecycleMode::KeepAlive => {
415                            Some((name.clone(), mode, entry.idle_timeout))
416                        }
417                        LifecycleMode::Lazy => None,
418                    }
419                })
420                .collect()
421        };
422
423        for (name, mode, idle_override) in eager_servers {
424            if let Err(e) = self.connect(&name).await {
425                tracing::warn!("MCP: eager connect to '{}' failed: {}", name, e);
426                continue;
427            }
428            match mode {
429                LifecycleMode::KeepAlive => {
430                    let _ = self.lifecycle_tx.send(LifecycleEvent::StartHealthCheck {
431                        server: name.clone(),
432                    });
433                }
434                LifecycleMode::Eager => {
435                    if let Some(mins) = idle_override {
436                        let _ = self.lifecycle_tx.send(LifecycleEvent::StartIdleTimer {
437                            server: name.clone(),
438                            timeout: Duration::from_secs(mins.saturating_mul(60)),
439                        });
440                    }
441                }
442                // Unreachable by construction: the `eager_servers` filter_map
443                // above drops `LifecycleMode::Lazy` before this loop runs. A
444                // no-op arm degrades gracefully if that filter ever changes.
445                LifecycleMode::Lazy => {}
446            }
447        }
448    }
449
450    // ── Status ─────────────────────────────────────────────────────
451
452    /// Get a formatted status summary (legacy `mcp({})` interface).
453    pub async fn status(self: &Arc<Self>) -> String {
454        let inner = self.inner.lock().await;
455        let config = self.config.read();
456        let servers = &config.mcp_servers;
457
458        if servers.is_empty() {
459            return "MCP: No servers configured. Create ~/.config/oxicode/mcp.json or .mcp.json"
460                .to_string();
461        }
462
463        let mut text = String::new();
464        let mut connected_count = 0;
465        let mut total_tools = 0;
466
467        for name in servers.keys() {
468            let (status_marker, tool_count) = if inner.clients.contains_key(name) {
469                connected_count += 1;
470                let count = inner
471                    .raw_tool_metadata
472                    .get(name)
473                    .map(|m| m.len())
474                    .unwrap_or(0);
475                total_tools += count;
476                ("✓", count)
477            } else if let Some(failed_at) = inner.failure_tracker.get(name) {
478                let ago = failed_at.elapsed().as_secs();
479                if ago < self.failure_backoff_secs() {
480                    ("✗", 0)
481                } else {
482                    ("○", 0)
483                }
484            } else {
485                let count = inner
486                    .raw_tool_metadata
487                    .get(name)
488                    .map(|m| m.len())
489                    .unwrap_or(0);
490                total_tools += count;
491                ("○", count)
492            };
493
494            text.push_str(&format!(
495                "{} {} ({} tools)\n",
496                status_marker, name, tool_count
497            ));
498        }
499
500        format!(
501            "MCP: {}/{} servers, {} tools\n\n{}",
502            connected_count,
503            servers.len(),
504            total_tools,
505            text.trim_end()
506        )
507    }
508
509    // ── Dashboard (Phase 2) ────────────────────────────────────────
510
511    /// Snapshot of dashboard data (Phase 2). Synchronous — only reads
512    /// `parking_lot`-guarded state and in-memory copies of cached tool
513    /// lists, so it is safe to call from `render()`.
514    pub fn dashboard_data(self: &Arc<Self>) -> McpDashboardData {
515        use McpConnectionStatus as CS;
516        let config = self.config.read();
517        let prefix_mode = effective_prefix_mode(config.settings.as_ref());
518
519        let inner = self.inner.try_lock();
520        let (clients_connected, raw_metadata) = match &inner {
521            Ok(g) => (
522                g.clients.keys().cloned().collect::<HashSet<_>>(),
523                g.raw_tool_metadata.clone(),
524            ),
525            Err(_) => (HashSet::new(), HashMap::new()),
526        };
527
528        let mut servers = Vec::new();
529        let mut total_tools = 0usize;
530        let mut connected_servers = 0usize;
531
532        for (name, entry) in &config.mcp_servers {
533            let lifecycle = entry
534                .lifecycle
535                .as_ref()
536                .map(|l| match l {
537                    LifecycleMode::Lazy => "lazy".to_string(),
538                    LifecycleMode::Eager => "eager".to_string(),
539                    LifecycleMode::KeepAlive => "keep-alive".to_string(),
540                })
541                .unwrap_or_else(|| "lazy".to_string());
542
543            let raw_tools = raw_metadata.get(name);
544            let tool_count = raw_tools.map(|t| t.len()).unwrap_or(0);
545            total_tools += tool_count;
546
547            let status = if clients_connected.contains(name) {
548                connected_servers += 1;
549                CS::Connected
550            } else {
551                CS::Disconnected
552            };
553
554            let direct_set = collect_direct_tool_names(entry, config.settings.as_ref());
555            let exclude: HashSet<String> = entry
556                .exclude_tools
557                .clone()
558                .unwrap_or_default()
559                .into_iter()
560                .collect();
561
562            let tools: Vec<McpToolInfo> = raw_tools
563                .map(|defs| {
564                    defs.iter()
565                        .filter(|d| !exclude.contains(&d.name))
566                        .map(|d| McpToolInfo {
567                            name: format_tool_name(&d.name, name, &prefix_mode),
568                            original_name: d.name.clone(),
569                            description: d.description.clone().unwrap_or_default(),
570                            is_direct: direct_set.contains(&d.name),
571                            consent: self.consent.check(&d.name),
572                        })
573                        .collect()
574                })
575                .unwrap_or_default();
576
577            servers.push(McpServerInfo {
578                name: name.clone(),
579                status,
580                lifecycle,
581                tool_count,
582                tools,
583            });
584        }
585
586        let settings = McpSettingsView {
587            tool_prefix: match prefix_mode {
588                ToolPrefix::Server => "server".to_string(),
589                ToolPrefix::Short => "short".to_string(),
590                ToolPrefix::None => "none".to_string(),
591            },
592            idle_timeout: config.settings.as_ref().and_then(|s| s.idle_timeout),
593            total_servers: config.mcp_servers.len(),
594            connected_servers,
595            total_tools,
596        };
597
598        McpDashboardData { servers, settings }
599    }
600
601    // ── Connect / disconnect ──────────────────────────────────────
602
603    /// Connect to a specific MCP server by name. Selects the transport
604    /// from the entry: `command` → stdio (spawn), `url` → Streamable HTTP.
605    /// Stores the connected client and updates the metadata cache.
606    pub async fn connect(self: &Arc<Self>, server_name: &str) -> Result<String> {
607        let (command, args, env, cwd, debug, url, timeout_ms) = {
608            let config = self.config.read();
609            let entry = config
610                .mcp_servers
611                .get(server_name)
612                .ok_or_else(|| anyhow::anyhow!("Server '{}' not found", server_name))?;
613            (
614                entry.command.clone(),
615                entry.args.clone().unwrap_or_default(),
616                entry.env.clone().unwrap_or_default(),
617                entry.cwd.clone(),
618                entry.debug.unwrap_or(false),
619                entry.url.clone(),
620                entry
621                    .timeout
622                    .unwrap_or(crate::mcp::transport::http::DEFAULT_TIMEOUT_MS),
623            )
624        };
625
626        let provider = self.credential_provider.read().clone();
627
628        // ── Consent gate (F-2, code audit 2026-07-25) ───────────────────
629        // Covers BOTH stdio (spawn) and HTTP transports — the gate sits
630        // before the `match (command, url)`, so neither path can bypass it.
631        // Unknown servers return `Ask` from `check_spawn_consent`, which we
632        // treat as "not trusted" and deny with an actionable error.
633        let spawn_consent = self.consent.check_spawn_consent(server_name);
634        if spawn_consent != ConsentState::Allow {
635            return Err(McpError::ConsentDenied {
636                server: server_name.to_string(),
637            }
638            .into());
639        }
640        let transport: Box<dyn McpTransport> = match (command, url) {
641            (Some(cmd), _) => Box::new(
642                StdioTransport::spawn(&cmd, &args, &env, cwd.as_deref(), debug, None)
643                    .with_context(|| format!("Failed to spawn MCP server '{}'", server_name))?,
644            ),
645            (None, Some(endpoint)) => Box::new(
646                StreamableHttpTransport::new(server_name, &endpoint, Some(provider), timeout_ms)
647                    .with_context(|| {
648                        format!("Failed to build HTTP transport for '{}'", server_name)
649                    })?,
650            ),
651            (None, None) => anyhow::bail!(
652                "Server '{}' has neither 'command' nor 'url' configured",
653                server_name
654            ),
655        };
656
657        let mut client = McpClient::connect_with_transport(transport)
658            .await
659            .with_context(|| format!("Failed to connect to MCP server '{}'", server_name))?;
660
661        let tools = client.list_tools().await.unwrap_or_default();
662
663        if let Err(e) = self.cache.update(server_name, &tools) {
664            tracing::warn!("MCP: failed to update cache for '{}': {}", server_name, e);
665        }
666
667        let tool_names: Vec<String> = tools.iter().map(|t| t.name.clone()).collect();
668
669        let mut inner = self.inner.lock().await;
670        inner.clients.insert(server_name.to_string(), client);
671        inner
672            .raw_tool_metadata
673            .insert(server_name.to_string(), tools);
674        inner.failure_tracker.remove(server_name);
675        inner.connecting.remove(server_name);
676
677        if tool_names.is_empty() {
678            Ok(format!(
679                "Connected to '{}' — no tools available.",
680                server_name
681            ))
682        } else {
683            Ok(format!(
684                "Connected to '{}' ({} tools):\n\n{}",
685                server_name,
686                tool_names.len(),
687                tool_names
688                    .iter()
689                    .map(|n| format!("- {}", n))
690                    .collect::<Vec<_>>()
691                    .join("\n")
692            ))
693        }
694    }
695
696    /// Trust an MCP server for spawn consent (persist `Allow` to disk).
697    /// Used by `oxicode mcp trust <server>`. After this, the server's next
698    /// `connect()` proceeds past the consent gate.
699    pub fn trust_server(&self, server_name: &str) -> Result<()> {
700        self.consent.trust(server_name)
701    }
702
703    /// Revoke spawn trust for an MCP server (persist `Deny` to disk).
704    /// Used by `oxicode mcp untrust <server>`. After this, the server's next
705    /// `connect()` is denied with [`McpError::ConsentDenied`].
706    pub fn untrust_server(&self, server_name: &str) -> Result<()> {
707        self.consent.untrust(server_name)
708    }
709
710    /// Lazily connect (or return true if already connected).
711    pub async fn ensure_connected(self: &Arc<Self>, server_name: &str) -> bool {
712        let should_connect = {
713            let mut inner = self.inner.lock().await;
714            if inner.clients.contains_key(server_name) {
715                return true;
716            }
717            if inner.connecting.contains(server_name) {
718                return false;
719            }
720            if let Some(failed_at) = inner.failure_tracker.get(server_name)
721                && failed_at.elapsed().as_secs() < self.failure_backoff_secs()
722            {
723                return false;
724            }
725            inner.connecting.insert(server_name.to_string());
726            true
727        };
728
729        if !should_connect {
730            return false;
731        }
732
733        let result = self.connect(server_name).await;
734        self.inner.lock().await.connecting.remove(server_name);
735        match result {
736            Ok(_) => {
737                // Reset/clear any pending idle timer — the server is
738                // now connected and we want to start a fresh timer
739                // when the next call happens.
740                let _ = self.lifecycle_tx.send(LifecycleEvent::CancelIdleTimer {
741                    server: server_name.to_string(),
742                });
743                true
744            }
745            Err(e) => {
746                tracing::warn!("MCP: lazy connect failed for {}: {}", server_name, e);
747                let mut inner = self.inner.lock().await;
748                inner
749                    .failure_tracker
750                    .insert(server_name.to_string(), Instant::now());
751                false
752            }
753        }
754    }
755
756    /// Disconnect a single server, regardless of reason. Idempotent:
757    /// returns `Ok(())` even if the server was not connected. Removes
758    /// the live client, clears cached tool metadata and any pending
759    /// "connecting" flag, and emits a [`LifecycleEvent::ServerStopped`].
760    async fn do_disconnect(self: &Arc<Self>, server_name: &str, reason: &str) -> Result<bool> {
761        // Take the client out of the map under the lock, then release
762        // the lock before awaiting transport shutdown (`close()` may
763        // block on the child process / HTTP DELETE).
764        let removed = {
765            let mut inner = self.inner.lock().await;
766            let client = inner.clients.remove(server_name);
767            inner.raw_tool_metadata.remove(server_name);
768            inner.connecting.remove(server_name);
769            client
770        };
771        let was_connected = removed.is_some();
772        if let Some(mut client) = removed {
773            let _ = client.close().await;
774        }
775
776        let _ = self.lifecycle_tx.send(LifecycleEvent::ServerStopped {
777            server: server_name.to_string(),
778        });
779        tracing::info!("MCP: disconnected '{}' ({})", server_name, reason);
780        Ok(was_connected)
781    }
782
783    /// Disconnect a single server (used by the lifecycle idle timer).
784    async fn disconnect_server(self: &Arc<Self>, server_name: &str) -> Result<()> {
785        self.do_disconnect(server_name, "idle timeout").await?;
786        Ok(())
787    }
788
789    /// Manually disconnect a single server. Exposed for the MCP
790    /// dashboard's `D` key. Idempotent — returns `Ok(true)` if a live
791    /// connection was actually closed, `Ok(false)` if the server was
792    /// already disconnected.
793    pub async fn disconnect(self: &Arc<Self>, server_name: &str) -> Result<bool> {
794        self.do_disconnect(server_name, "manual").await
795    }
796
797    /// Health check + reconnect for a keep-alive server (v2.3 G6).
798    ///
799    /// Retries reconnect with exponential backoff (500ms, 1s, 2s) so a
800    /// single transient blip no longer permanently kills the health
801    /// check (v2.0 only attempted reconnect once before giving up).
802    /// A per-server circuit breaker (5 failures / 30s → pause) is the
803    /// planned 2nd defence; see design §8.
804    async fn health_check_and_reconnect(self: &Arc<Self>, server_name: &str) -> Result<()> {
805        const BACKOFFS: [Duration; 3] = [
806            Duration::from_millis(500),
807            Duration::from_secs(1),
808            Duration::from_secs(2),
809        ];
810        // Fast path: a healthy client answers a ping.
811        {
812            let mut inner = self.inner.lock().await;
813            if let Some(client) = inner.clients.get_mut(server_name)
814                && client.ping().await.is_ok()
815            {
816                return Ok(());
817            }
818        }
819        // Reconnect with backoff. On success → return; on final failure → Err.
820        for (i, delay) in BACKOFFS.iter().enumerate() {
821            tracing::warn!(
822                "MCP: health check '{}' failed; reconnect attempt {}/{} after {:?}",
823                server_name,
824                i + 1,
825                BACKOFFS.len(),
826                delay
827            );
828            tokio::time::sleep(*delay).await;
829            match self.connect(server_name).await {
830                Ok(_) => return Ok(()),
831                Err(e) => tracing::warn!(
832                    "MCP: reconnect attempt {} for '{}' failed: {}",
833                    i + 1,
834                    server_name,
835                    e
836                ),
837            }
838        }
839        Err(anyhow::anyhow!(
840            "Health check for '{}' exhausted {} reconnect attempts",
841            server_name,
842            BACKOFFS.len()
843        ))
844    }
845
846    /// Force-refresh the OAuth credential for `server_name` (v2.2).
847    /// Used by `/mcp reauth <server>` to rotate credentials without
848    /// restarting the process. Returns an error if no credential
849    /// provider is configured, the server has no `url`, or the
850    /// refresh fails.
851    pub async fn reauth_server(self: &Arc<Self>, server_name: &str) -> Result<()> {
852        let url = {
853            let config = self.config.read();
854            config
855                .mcp_servers
856                .get(server_name)
857                .and_then(|e| e.url.clone())
858                .ok_or_else(|| {
859                    anyhow::anyhow!(
860                        "Server '{}' not found or has no URL (auth requires Streamable HTTP)",
861                        server_name
862                    )
863                })?
864        };
865        let provider = {
866            let guard = self.credential_provider.read();
867            Arc::clone(&*guard)
868        };
869        let cred = provider.refresh(server_name, &url).await;
870        match cred {
871            Some(_) => Ok(()),
872            None => Err(anyhow::anyhow!(
873                "Credential refresh for '{}' failed (see logs for details)",
874                server_name
875            )),
876        }
877    }
878
879    // ── Tool operations ───────────────────────────────────────────
880
881    /// Call an MCP tool by name, optionally targeting a specific server.
882    pub async fn call_tool(
883        self: &Arc<Self>,
884        tool_name: &str,
885        args: serde_json::Value,
886        server_override: Option<&str>,
887    ) -> Result<McpCallResult> {
888        let (server_name, original_name) = self.find_tool(tool_name, server_override).await?;
889
890        // Consent gate (Phase 3) — proxy path also honors consent.
891        if self.consent.check(&original_name) == ConsentState::Deny {
892            return Err(anyhow::anyhow!(
893                "Tool '{}' is denied by consent policy",
894                original_name
895            ));
896        }
897
898        self.ensure_connected(&server_name).await;
899
900        let mut inner = self.inner.lock().await;
901        let client = inner
902            .clients
903            .get_mut(&server_name)
904            .ok_or_else(|| anyhow::anyhow!("Server '{}' not connected", server_name))?;
905
906        let result = client
907            .call_tool(&original_name, args)
908            .await
909            .with_context(|| format!("Tool '{}' call failed", tool_name))?;
910        drop(inner);
911
912        // Reset idle timer after a successful call.
913        self.reset_idle_timer(&server_name);
914
915        let text = content::transform_mcp_content(&result.content);
916        Ok(McpCallResult {
917            content: vec![McpContent::Text { text }],
918            is_error: result.is_error,
919        })
920    }
921
922    /// List resources from a connected MCP server (v2.3 G5).
923    pub async fn list_resources(
924        self: &Arc<Self>,
925        server_name: &str,
926    ) -> Result<Vec<serde_json::Value>> {
927        self.ensure_connected(server_name).await;
928        let mut inner = self.inner.lock().await;
929        let client = inner
930            .clients
931            .get_mut(server_name)
932            .ok_or_else(|| anyhow::anyhow!("Server '{}' not connected", server_name))?;
933        client
934            .list_resources()
935            .await
936            .with_context(|| format!("list_resources on '{}' failed", server_name))
937    }
938
939    /// Read a resource URI from a connected MCP server (v2.3 G5).
940    pub async fn read_resource(
941        self: &Arc<Self>,
942        server_name: &str,
943        uri: &str,
944    ) -> Result<Vec<McpContent>> {
945        self.ensure_connected(server_name).await;
946        let mut inner = self.inner.lock().await;
947        let client = inner
948            .clients
949            .get_mut(server_name)
950            .ok_or_else(|| anyhow::anyhow!("Server '{}' not connected", server_name))?;
951        client
952            .read_resource(uri)
953            .await
954            .with_context(|| format!("read_resource '{}' on '{}' failed", uri, server_name))
955    }
956
957    /// List prompt templates from a connected MCP server (v2.3 G5).
958    pub async fn list_prompts(self: &Arc<Self>, server_name: &str) -> Result<Vec<McpPrompt>> {
959        self.ensure_connected(server_name).await;
960        let mut inner = self.inner.lock().await;
961        let client = inner
962            .clients
963            .get_mut(server_name)
964            .ok_or_else(|| anyhow::anyhow!("Server '{}' not connected", server_name))?;
965        client
966            .list_prompts()
967            .await
968            .with_context(|| format!("list_prompts on '{}' failed", server_name))
969    }
970
971    /// Get a prompt template with arguments (v2.3 G5).
972    pub async fn get_prompt(
973        self: &Arc<Self>,
974        server_name: &str,
975        name: &str,
976        arguments: std::collections::HashMap<String, String>,
977    ) -> Result<Vec<serde_json::Value>> {
978        self.ensure_connected(server_name).await;
979        let mut inner = self.inner.lock().await;
980        let client = inner
981            .clients
982            .get_mut(server_name)
983            .ok_or_else(|| anyhow::anyhow!("Server '{}' not connected", server_name))?;
984        client
985            .get_prompt(name, arguments)
986            .await
987            .with_context(|| format!("get_prompt '{}' on '{}' failed", name, server_name))
988    }
989
990    /// Reset (or start) the idle-disconnect timer for a server.
991    /// Called after every successful tool use.
992    pub fn reset_idle_timer(self: &Arc<Self>, server_name: &str) {
993        let timeout = {
994            let config = self.config.read();
995            let per_server = config
996                .mcp_servers
997                .get(server_name)
998                .and_then(|e| e.idle_timeout)
999                .map(|m| Duration::from_secs(m.saturating_mul(60)));
1000            per_server.unwrap_or_else(|| self.global_idle_timeout())
1001        };
1002        let _ = self.lifecycle_tx.send(LifecycleEvent::StartIdleTimer {
1003            server: server_name.to_string(),
1004            timeout,
1005        });
1006    }
1007
1008    /// Describe a tool by name.
1009    pub async fn describe(self: &Arc<Self>, tool_name: &str) -> Result<String> {
1010        let (server_name, original_name) = self.find_tool(tool_name, None).await?;
1011
1012        // Look up the cached/live def to get description + schema.
1013        let prefix_mode = effective_prefix_mode(self.config.read().settings.as_ref());
1014        let prefixed = format_tool_name(&original_name, &server_name, &prefix_mode);
1015
1016        let (description, input_schema) = {
1017            let inner = self.inner.lock().await;
1018            inner
1019                .raw_tool_metadata
1020                .get(&server_name)
1021                .and_then(|defs| defs.iter().find(|d| d.name == original_name).cloned())
1022                .map(|d| (d.description.unwrap_or_default(), d.input_schema))
1023                .unwrap_or_default()
1024        };
1025
1026        let mut text = format!("{}\n", prefixed);
1027        text.push_str(&format!("Server: {}\n", server_name));
1028        text.push_str(&format!("\n{}\n", description));
1029
1030        if let Some(ref schema) = input_schema {
1031            text.push_str(&format!("\nParameters:\n{}", format_schema(schema, "  ")));
1032        } else {
1033            text.push_str("\nNo parameters defined.");
1034        }
1035
1036        Ok(text)
1037    }
1038
1039    /// Search tools by name or description.
1040    pub async fn search(
1041        self: &Arc<Self>,
1042        query: &str,
1043        regex: bool,
1044        server_filter: Option<&str>,
1045    ) -> Result<String> {
1046        let pattern = if regex {
1047            regex::Regex::new(query).with_context(|| format!("Invalid regex: {}", query))?
1048        } else {
1049            let terms: Vec<&str> = query.split_whitespace().collect();
1050            if terms.is_empty() {
1051                return Ok("Search query cannot be empty".to_string());
1052            }
1053            let escaped: Vec<String> = terms.iter().map(|t| regex::escape(t)).collect();
1054            regex::Regex::new(&format!("(?i){}", escaped.join("|")))
1055                .context("Invalid search pattern")?
1056        };
1057
1058        let inner = self.inner.lock().await;
1059        let mut matches = Vec::new();
1060
1061        for (server_name, raw_tools) in &inner.raw_tool_metadata {
1062            if let Some(filter) = server_filter
1063                && server_name != filter
1064            {
1065                continue;
1066            }
1067            for tool in raw_tools {
1068                let prefixed = format_tool_name(
1069                    &tool.name,
1070                    server_name,
1071                    &effective_prefix_mode(self.config.read().settings.as_ref()),
1072                );
1073                let description = tool.description.clone().unwrap_or_default();
1074                if pattern.is_match(&prefixed) || pattern.is_match(&description) {
1075                    matches.push((
1076                        server_name.clone(),
1077                        tool.name.clone(),
1078                        description,
1079                        tool.input_schema.clone(),
1080                    ));
1081                }
1082            }
1083        }
1084
1085        if matches.is_empty() {
1086            let msg = if let Some(s) = server_filter {
1087                format!("No tools matching \"{}\" in \"{}\"", query, s)
1088            } else {
1089                format!("No tools matching \"{}\"", query)
1090            };
1091            return Ok(msg);
1092        }
1093
1094        let mut text = format!(
1095            "Found {} tool{} matching \"{}\":\n\n",
1096            matches.len(),
1097            if matches.len() == 1 { "" } else { "s" },
1098            query
1099        );
1100
1101        for (server, original, description, schema) in &matches {
1102            let prefixed = format_tool_name(
1103                original,
1104                server,
1105                &effective_prefix_mode(self.config.read().settings.as_ref()),
1106            );
1107            text.push_str(&format!("{}\n", prefixed));
1108            if !description.is_empty() {
1109                text.push_str(&format!("  {}\n", description));
1110            }
1111            if let Some(s) = schema {
1112                text.push_str(&format!("  Parameters:\n{}\n", format_schema(s, "    ")));
1113            }
1114            text.push('\n');
1115        }
1116
1117        Ok(text.trim_end().to_string())
1118    }
1119
1120    /// List tools for a specific server.
1121    pub async fn list_tools(self: &Arc<Self>, server_name: &str) -> Result<String> {
1122        {
1123            let config = self.config.read();
1124            if !config.mcp_servers.contains_key(server_name) {
1125                return Ok(format!(
1126                    "Server '{}' not found. Use mcp({{}}) to see available servers.",
1127                    server_name
1128                ));
1129            }
1130        }
1131
1132        self.ensure_connected(server_name).await;
1133
1134        let inner = self.inner.lock().await;
1135        let metadata = inner.raw_tool_metadata.get(server_name);
1136        let prefix_mode = effective_prefix_mode(self.config.read().settings.as_ref());
1137
1138        match metadata {
1139            Some(tools) if !tools.is_empty() => {
1140                let mut text = format!("{} ({} tools):\n\n", server_name, tools.len());
1141                for tool in tools {
1142                    let prefixed = format_tool_name(&tool.name, server_name, &prefix_mode);
1143                    text.push_str(&format!("- {}", prefixed));
1144                    if let Some(desc) = &tool.description {
1145                        let short: String = desc.chars().take(60).collect();
1146                        text.push_str(&format!(" - {}", short));
1147                    }
1148                    text.push('\n');
1149                }
1150                Ok(text.trim_end().to_string())
1151            }
1152            Some(_) => Ok(format!("Server '{}' has no tools.", server_name)),
1153            None => Ok(format!(
1154                "Server '{}' is configured but not connected. Use mcp({{ connect: \"{}\" }}) to connect.",
1155                server_name, server_name
1156            )),
1157        }
1158    }
1159
1160    /// Direct tool definitions for `ToolRegistry` registration (Phase 3).
1161    /// Reads from the metadata cache, applies `direct_tools` /
1162    /// `exclude_tools` filters, and returns the precomputed prefixed names.
1163    pub fn direct_tools_from_cache(self: &Arc<Self>) -> Vec<DirectToolDef> {
1164        let config = self.config.read();
1165        let prefix_mode = effective_prefix_mode(config.settings.as_ref());
1166        let global_direct = config
1167            .settings
1168            .as_ref()
1169            .and_then(|s| s.direct_tools.clone());
1170
1171        let mut out = Vec::new();
1172
1173        for (server_name, entry) in &config.mcp_servers {
1174            // Determine whether to honor this server at all
1175            let effective = entry.direct_tools.clone().or_else(|| global_direct.clone());
1176            let is_direct_enabled = match &effective {
1177                None => false,
1178                Some(DirectToolsConfig::All(b)) => *b,
1179                Some(DirectToolsConfig::Specific(_)) => true,
1180            };
1181            if !is_direct_enabled {
1182                continue;
1183            }
1184            let exclude: HashSet<String> = entry
1185                .exclude_tools
1186                .clone()
1187                .unwrap_or_default()
1188                .into_iter()
1189                .collect();
1190
1191            // Iterate over cached tools for this server.
1192            let tools = self.cache.get_tools(server_name, &prefix_mode);
1193            for t in tools {
1194                if exclude.contains(&t.original_name) {
1195                    continue;
1196                }
1197                let in_set = match &effective {
1198                    Some(DirectToolsConfig::All(_)) => true,
1199                    Some(DirectToolsConfig::Specific(list)) => list.contains(&t.original_name),
1200                    None => false,
1201                };
1202                if !in_set {
1203                    continue;
1204                }
1205                out.push(DirectToolDef {
1206                    prefixed_name: format_tool_name(&t.original_name, server_name, &prefix_mode),
1207                    original_name: t.original_name.clone(),
1208                    server_name: server_name.clone(),
1209                    description: t.description.clone(),
1210                    input_schema: t.input_schema.clone(),
1211                });
1212            }
1213        }
1214
1215        out
1216    }
1217
1218    /// Whether the `mcp` proxy tool should be hidden in the tool registry
1219    /// (Phase 3 — `settings.disable_proxy_tool: true`).
1220    pub fn should_disable_proxy(self: &Arc<Self>) -> bool {
1221        self.config
1222            .read()
1223            .settings
1224            .as_ref()
1225            .and_then(|s| s.disable_proxy_tool)
1226            .unwrap_or(false)
1227    }
1228
1229    // ── Internal helpers ──────────────────────────────────────────
1230
1231    /// Find a tool by name across all known (cached + live) servers.
1232    async fn find_tool(
1233        self: &Arc<Self>,
1234        tool_name: &str,
1235        server_override: Option<&str>,
1236    ) -> Result<(String, String)> {
1237        // If a specific server was requested, verify it exists.
1238        if let Some(server) = server_override {
1239            let config = self.config.read();
1240            if !config.mcp_servers.contains_key(server) {
1241                return Err(anyhow::anyhow!("Server '{}' not found", server));
1242            }
1243        }
1244
1245        // 1. Try exact match against the in-memory metadata.
1246        {
1247            let inner = self.inner.lock().await;
1248            let server_keys: Vec<String> = if let Some(s) = server_override {
1249                vec![s.to_string()]
1250            } else {
1251                inner.raw_tool_metadata.keys().cloned().collect()
1252            };
1253            for server_name in &server_keys {
1254                if let Some(raw) = inner.raw_tool_metadata.get(server_name)
1255                    && let Some(d) = raw.iter().find(|t| t.name == tool_name)
1256                {
1257                    return Ok((server_name.clone(), d.name.clone()));
1258                }
1259            }
1260        }
1261
1262        // 2. Try prefix-based matching on configured server names
1263        //    (e.g. `chrome_take_screenshot` → server `chrome`,
1264        //    tool `take_screenshot`).
1265        let prefix_mode = effective_prefix_mode(self.config.read().settings.as_ref());
1266        let candidates: Vec<String> = {
1267            let config = self.config.read();
1268            config
1269                .mcp_servers
1270                .keys()
1271                .filter(|server_name| {
1272                    if let Some(s) = server_override {
1273                        server_name.as_str() == s
1274                    } else {
1275                        true
1276                    }
1277                })
1278                .filter(|server_name| {
1279                    let prefix = get_server_prefix(server_name, &prefix_mode);
1280                    !prefix.is_empty() && tool_name.starts_with(&format!("{}_", prefix))
1281                })
1282                .cloned()
1283                .collect()
1284        };
1285
1286        for server_name in &candidates {
1287            self.ensure_connected(server_name).await;
1288            let inner = self.inner.lock().await;
1289            if let Some(raw) = inner.raw_tool_metadata.get(server_name) {
1290                // Look for prefixed match
1291                for d in raw {
1292                    if format_tool_name(&d.name, server_name, &prefix_mode) == tool_name {
1293                        return Ok((server_name.clone(), d.name.clone()));
1294                    }
1295                }
1296            }
1297        }
1298
1299        // 3. Not found — helpful error.
1300        let inner = self.inner.lock().await;
1301        let mut hint_servers = Vec::new();
1302        let prefix_mode = effective_prefix_mode(self.config.read().settings.as_ref());
1303        for (server_name, raw) in &inner.raw_tool_metadata {
1304            let names: Vec<String> = raw
1305                .iter()
1306                .map(|d| format_tool_name(&d.name, server_name, &prefix_mode))
1307                .collect();
1308            if !names.is_empty() {
1309                hint_servers.push(format!("{}: {}", server_name, names.join(", ")));
1310            }
1311        }
1312        let mut msg = format!("Tool '{}' not found.", tool_name);
1313        if !hint_servers.is_empty() {
1314            msg.push_str(&format!(
1315                "\n\nAvailable tools:\n{}",
1316                hint_servers
1317                    .iter()
1318                    .map(|s| format!("  {}", s))
1319                    .collect::<Vec<_>>()
1320                    .join("\n")
1321            ));
1322        } else {
1323            msg.push_str(" Use mcp({ search: \"...\" }) to search.");
1324        }
1325        Err(anyhow::anyhow!(msg))
1326    }
1327}
1328
1329/// Compute the set of tool original-names that should be exposed as direct
1330/// for the given server, taking the per-server `direct_tools` override
1331/// first, then the global default.
1332fn collect_direct_tool_names(
1333    entry: &ServerEntry,
1334    settings: Option<&McpSettings>,
1335) -> HashSet<String> {
1336    let cfg = entry
1337        .direct_tools
1338        .clone()
1339        .or_else(|| settings.and_then(|s| s.direct_tools.clone()));
1340    match cfg {
1341        Some(DirectToolsConfig::All(true)) => HashSet::new(), // "all" sentinel: handled elsewhere
1342        Some(DirectToolsConfig::All(false)) => HashSet::new(),
1343        Some(DirectToolsConfig::Specific(list)) => list.into_iter().collect(),
1344        None => HashSet::new(),
1345    }
1346}
1347
1348impl Default for McpManager {
1349    fn default() -> Self {
1350        Self::new_no_spawn()
1351    }
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356    use super::*;
1357    use tempfile::TempDir;
1358
1359    #[test]
1360    fn new_no_spawn_succeeds() {
1361        let m = McpManager::new_no_spawn();
1362        assert_eq!(m.config().mcp_servers.len(), 0);
1363    }
1364
1365    #[tokio::test]
1366    async fn spawn_with_paths_uses_supplied_paths() {
1367        let dir = TempDir::new().unwrap();
1368        let cache_p = dir.path().join("c.json");
1369        let consent_p = dir.path().join("consent.json");
1370        let mgr = McpManager::spawn_with_paths(
1371            McpConfig::default(),
1372            Some(cache_p.clone()),
1373            Some(consent_p.clone()),
1374        );
1375        assert_eq!(mgr.cache().path(), cache_p);
1376        assert_eq!(mgr.consent().path(), consent_p);
1377    }
1378
1379    #[tokio::test]
1380    async fn spawn_with_paths_none_uses_default_paths() {
1381        // None, None → 기본 경로 사용 (에러 없이 spawn).
1382        let mgr = McpManager::spawn_with_paths(McpConfig::default(), None, None);
1383        assert!(!mgr.cache().path().as_os_str().is_empty());
1384        assert!(!mgr.consent().path().as_os_str().is_empty());
1385    }
1386
1387    #[test]
1388    fn dashboard_data_empty_config() {
1389        let mgr = Arc::new(McpManager::new_no_spawn());
1390        let data = mgr.dashboard_data();
1391        assert!(data.servers.is_empty());
1392        assert_eq!(data.settings.total_servers, 0);
1393    }
1394
1395    #[tokio::test]
1396    async fn direct_tools_from_cache_respects_specific_list() {
1397        let dir = TempDir::new().unwrap();
1398        let cache = MetadataCache::with_path(dir.path().join("mcp-cache.json"));
1399        let consent = ConsentManager::with_path(dir.path().join("consent.json"));
1400
1401        // Manually populate the cache with a single server + 2 tools.
1402        let defs = vec![
1403            McpToolDef {
1404                name: "take_screenshot".into(),
1405                description: Some("screenshot".into()),
1406                input_schema: None,
1407            },
1408            McpToolDef {
1409                name: "navigate".into(),
1410                description: Some("go to url".into()),
1411                input_schema: None,
1412            },
1413        ];
1414        cache.update("chrome", &defs).unwrap();
1415
1416        // Build a config that asks for only `take_screenshot` as direct.
1417        let mut cfg = McpConfig::default();
1418        cfg.mcp_servers.insert(
1419            "chrome".into(),
1420            ServerEntry {
1421                command: Some("echo".into()),
1422                direct_tools: Some(DirectToolsConfig::Specific(vec!["take_screenshot".into()])),
1423                ..Default::default()
1424            },
1425        );
1426
1427        // Manually construct a manager so we can install our cache/consent.
1428        let (lifecycle_tx, _rx) = lifecycle_channel();
1429        let mgr = Arc::new(McpManager {
1430            inner: tokio::sync::Mutex::new(McpManagerInner {
1431                clients: HashMap::new(),
1432                raw_tool_metadata: HashMap::new(),
1433                failure_tracker: HashMap::new(),
1434                connecting: HashSet::new(),
1435            }),
1436            config: parking_lot::RwLock::new(cfg),
1437            cache,
1438            consent,
1439            lifecycle_tx,
1440            credential_provider: parking_lot::RwLock::new(Arc::new(NoopCredentialProvider)),
1441            _lifecycle_handle: Some(tokio::spawn(async {})),
1442        });
1443
1444        let direct = mgr.direct_tools_from_cache();
1445        assert_eq!(direct.len(), 1);
1446        assert_eq!(direct[0].original_name, "take_screenshot");
1447        assert_eq!(direct[0].prefixed_name, "chrome_take_screenshot");
1448    }
1449
1450    #[tokio::test]
1451    async fn disconnect_is_idempotent_and_clears_state() {
1452        // A server that is *configured* and has leftover cached metadata,
1453        // but no live client — as if the idle timer had already fired or
1454        // the child process had exited. `disconnect` must report
1455        // "was not connected" (Ok(false)) while still clearing the cached
1456        // metadata and the pending "connecting" flag.
1457        let dir = TempDir::new().unwrap();
1458        let cache = MetadataCache::with_path(dir.path().join("mcp-cache.json"));
1459        let consent = ConsentManager::with_path(dir.path().join("consent.json"));
1460
1461        let mut cfg = McpConfig::default();
1462        cfg.mcp_servers.insert(
1463            "chrome".into(),
1464            ServerEntry {
1465                command: Some("echo".into()),
1466                ..Default::default()
1467            },
1468        );
1469
1470        let mut raw: HashMap<String, Vec<McpToolDef>> = HashMap::new();
1471        raw.insert(
1472            "chrome".to_string(),
1473            vec![McpToolDef {
1474                name: "navigate".into(),
1475                description: None,
1476                input_schema: None,
1477            }],
1478        );
1479        let mut connecting = HashSet::new();
1480        connecting.insert("chrome".to_string());
1481
1482        let (lifecycle_tx, _rx) = lifecycle_channel();
1483        let mgr = Arc::new(McpManager {
1484            inner: tokio::sync::Mutex::new(McpManagerInner {
1485                clients: HashMap::new(),
1486                raw_tool_metadata: raw,
1487                failure_tracker: HashMap::new(),
1488                connecting,
1489            }),
1490            config: parking_lot::RwLock::new(cfg),
1491            cache,
1492            consent,
1493            lifecycle_tx,
1494            credential_provider: parking_lot::RwLock::new(Arc::new(NoopCredentialProvider)),
1495            _lifecycle_handle: Some(tokio::spawn(async {})),
1496        });
1497
1498        // No live client → Ok(false), but metadata + connecting cleared.
1499        let closed = mgr.disconnect("chrome").await.unwrap();
1500        assert!(!closed, "disconnect should report not-connected");
1501        {
1502            let inner = mgr.inner.lock().await;
1503            assert!(
1504                !inner.raw_tool_metadata.contains_key("chrome"),
1505                "cached metadata should be cleared"
1506            );
1507            assert!(
1508                !inner.connecting.contains("chrome"),
1509                "connecting flag should be cleared"
1510            );
1511        }
1512
1513        // Idempotent: a second call is still Ok(false) and never errors.
1514        assert!(!mgr.disconnect("chrome").await.unwrap());
1515        // An unknown server name is a clean Ok(false), not an error.
1516        assert!(!mgr.disconnect("never-configured").await.unwrap());
1517    }
1518
1519    // ── F-2 migration regression (code audit 2026-07-25) ────────────────
1520    // The migration must auto-trust ONLY global-config servers. Project-
1521    // local servers (from a cloned repo's .oxicode/mcp.json) must stay `Ask`
1522    // — this is the clone-to-RCE guard. If this test fails, the migration
1523    // is accidentally trusting untrusted input.
1524    #[test]
1525    fn migration_trusts_only_global_servers_not_project_local() {
1526        let dir = TempDir::new().unwrap();
1527
1528        // Simulate a global config file (user-authored, trusted).
1529        let global_path = dir.path().join("global-mcp.json");
1530        std::fs::write(
1531            &global_path,
1532            r#"{"mcpServers": {"safe-global": {"command": "echo", "args": []}}}"#,
1533        )
1534        .unwrap();
1535
1536        // Simulate a project-local config file (from `git clone`, untrusted).
1537        let project_path = dir.path().join("project-mcp.json");
1538        std::fs::write(
1539            &project_path,
1540            r#"{"mcpServers": {"malicious-clone": {"command": "pwned", "args": []}}}"#,
1541        )
1542        .unwrap();
1543
1544        // The migration function receives ONLY the global path — this is
1545        // the invariant that must hold. Passing the project path here would
1546        // be the regression.
1547        let trust_set = migration_trust_set(std::slice::from_ref(&global_path));
1548
1549        assert!(
1550            trust_set.contains("safe-global"),
1551            "global server must be in the auto-trust set"
1552        );
1553        assert!(
1554            !trust_set.contains("malicious-clone"),
1555            "project-local server must NOT be auto-trusted — \
1556             this is the clone-to-RCE guard (F-2)"
1557        );
1558
1559        // End-to-end: feed the trust set into a fresh ConsentManager and
1560        // verify the consent states match the security policy.
1561        let consent = consent::ConsentManager::with_path(dir.path().join("consent.json"));
1562        let _ = consent.load();
1563        for name in &trust_set {
1564            let _ = consent.decide(name, ConsentState::Allow);
1565        }
1566        assert_eq!(
1567            consent.check_spawn_consent("safe-global"),
1568            ConsentState::Allow,
1569            "global server should be trusted after migration"
1570        );
1571        assert_eq!(
1572            consent.check_spawn_consent("malicious-clone"),
1573            ConsentState::Ask,
1574            "project-local server must stay Ask — not trusted by migration"
1575        );
1576    }
1577}