1pub 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
84pub const DEFAULT_FAILURE_BACKOFF_SECS: u64 = 30;
86pub const DEFAULT_IDLE_TIMEOUT_MINS: u64 = 10;
88
89pub struct McpManagerInner {
91 clients: HashMap<String, McpClient>,
93 raw_tool_metadata: HashMap<String, Vec<McpToolDef>>,
96 failure_tracker: HashMap<String, Instant>,
98 connecting: HashSet<String>,
101}
102
103fn 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
117pub struct McpManager {
123 inner: tokio::sync::Mutex<McpManagerInner>,
124 config: parking_lot::RwLock<McpConfig>,
126 cache: MetadataCache,
128 consent: ConsentManager,
130 lifecycle_tx: lifecycle::LifecycleTx,
132 _lifecycle_handle: Option<tokio::task::JoinHandle<()>>,
135 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 pub fn spawn() -> Arc<Self> {
159 Self::spawn_with_config(config::load_mcp_config())
160 }
161
162 pub fn spawn_with_config(mcp_config: McpConfig) -> Arc<Self> {
166 Self::spawn_with_paths(mcp_config, None, None)
167 }
168
169 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 let _ = cache.load();
195
196 let consent = match consent_path {
197 Some(p) => ConsentManager::with_path(p),
198 None => ConsentManager::new(),
199 };
200 let consent_existed_before = consent.path().exists();
203 let _ = consent.load();
204
205 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 let cached_servers = cache.cached_servers();
230
231 let has_runtime = tokio::runtime::Handle::try_current().is_ok();
238
239 let (lifecycle_tx, lifecycle_rx) = lifecycle_channel();
240
241 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 {
274 let prefix_mode = effective_prefix_mode(manager.config.read().settings.as_ref());
275 #[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 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 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 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 pub fn config(&self) -> parking_lot::RwLockReadGuard<'_, McpConfig> {
341 self.config.read()
342 }
343
344 pub fn replace_config(&self, new_config: McpConfig) {
358 *self.config.write() = new_config;
359 }
360
361 pub fn set_credential_provider(&self, provider: Arc<dyn McpCredentialProvider>) {
369 *self.credential_provider.write() = provider;
370 }
371
372 pub fn consent(&self) -> &ConsentManager {
374 &self.consent
375 }
376
377 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 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 LifecycleMode::Lazy => {}
446 }
447 }
448 }
449
450 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 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 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 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 pub fn trust_server(&self, server_name: &str) -> Result<()> {
700 self.consent.trust(server_name)
701 }
702
703 pub fn untrust_server(&self, server_name: &str) -> Result<()> {
707 self.consent.untrust(server_name)
708 }
709
710 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 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 async fn do_disconnect(self: &Arc<Self>, server_name: &str, reason: &str) -> Result<bool> {
761 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 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 pub async fn disconnect(self: &Arc<Self>, server_name: &str) -> Result<bool> {
794 self.do_disconnect(server_name, "manual").await
795 }
796
797 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 {
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 async fn find_tool(
1233 self: &Arc<Self>,
1234 tool_name: &str,
1235 server_override: Option<&str>,
1236 ) -> Result<(String, String)> {
1237 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 {
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 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 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 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
1329fn 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(), 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 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 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 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 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 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 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 assert!(!mgr.disconnect("chrome").await.unwrap());
1515 assert!(!mgr.disconnect("never-configured").await.unwrap());
1517 }
1518
1519 #[test]
1525 fn migration_trusts_only_global_servers_not_project_local() {
1526 let dir = TempDir::new().unwrap();
1527
1528 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 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 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 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}