mcpkit_client/
discovery.rs

1//! Server discovery utilities.
2//!
3//! This module provides utilities for discovering MCP servers in the environment.
4//! It supports:
5//!
6//! - Finding servers in standard locations
7//! - Parsing server configuration files
8//! - Spawning server processes
9
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14/// A discovered MCP server.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DiscoveredServer {
17    /// Name of the server.
18    pub name: String,
19    /// How to connect to the server.
20    pub transport: ServerTransport,
21    /// Optional description.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub description: Option<String>,
24    /// Optional icon.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub icon: Option<String>,
27    /// Environment variables to set when spawning.
28    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
29    pub env: HashMap<String, String>,
30}
31
32impl DiscoveredServer {
33    /// Create a new discovered server with stdio transport.
34    pub fn stdio(name: impl Into<String>, command: impl Into<String>) -> Self {
35        Self {
36            name: name.into(),
37            transport: ServerTransport::Stdio {
38                command: command.into(),
39                args: Vec::new(),
40            },
41            description: None,
42            icon: None,
43            env: HashMap::new(),
44        }
45    }
46
47    /// Create a new discovered server with HTTP transport.
48    pub fn http(name: impl Into<String>, url: impl Into<String>) -> Self {
49        Self {
50            name: name.into(),
51            transport: ServerTransport::Http { url: url.into() },
52            description: None,
53            icon: None,
54            env: HashMap::new(),
55        }
56    }
57
58    /// Set the description.
59    pub fn description(mut self, description: impl Into<String>) -> Self {
60        self.description = Some(description.into());
61        self
62    }
63
64    /// Add an environment variable.
65    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
66        self.env.insert(key.into(), value.into());
67        self
68    }
69}
70
71/// Transport configuration for a server.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(tag = "type", rename_all = "lowercase")]
74pub enum ServerTransport {
75    /// Stdio transport (spawn a process).
76    Stdio {
77        /// Command to run.
78        command: String,
79        /// Command arguments.
80        #[serde(default)]
81        args: Vec<String>,
82    },
83    /// HTTP transport (connect to URL).
84    Http {
85        /// The server URL.
86        url: String,
87    },
88    /// WebSocket transport.
89    WebSocket {
90        /// The WebSocket URL.
91        url: String,
92    },
93}
94
95/// Server discovery utility.
96///
97/// Discovers MCP servers from various sources:
98///
99/// - Configuration files in standard locations
100/// - Environment variables
101/// - Manual registration
102pub struct ServerDiscovery {
103    /// Known servers.
104    servers: HashMap<String, DiscoveredServer>,
105    /// Configuration file paths to check.
106    config_paths: Vec<PathBuf>,
107}
108
109impl Default for ServerDiscovery {
110    fn default() -> Self {
111        Self::new()
112    }
113}
114
115impl ServerDiscovery {
116    /// Create a new server discovery instance.
117    #[must_use]
118    pub fn new() -> Self {
119        let mut config_paths = Vec::new();
120
121        // Add standard config locations
122        if let Some(config_dir) = dirs_config_dir() {
123            config_paths.push(config_dir.join("mcp").join("servers.json"));
124        }
125        if let Some(home) = dirs_home_dir() {
126            config_paths.push(home.join(".mcp").join("servers.json"));
127        }
128
129        Self {
130            servers: HashMap::new(),
131            config_paths,
132        }
133    }
134
135    /// Add a custom configuration file path.
136    pub fn add_config_path(mut self, path: impl Into<PathBuf>) -> Self {
137        self.config_paths.push(path.into());
138        self
139    }
140
141    /// Register a server manually.
142    #[must_use]
143    pub fn register(mut self, server: DiscoveredServer) -> Self {
144        self.servers.insert(server.name.clone(), server);
145        self
146    }
147
148    /// Discover servers from all configured sources.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if reading configuration files fails.
153    pub fn discover(&mut self) -> Result<(), DiscoveryError> {
154        // Clone paths to avoid borrow conflict
155        let paths: Vec<_> = self.config_paths.clone();
156
157        // Load from config files
158        for path in paths {
159            if path.exists() {
160                self.load_config_file(&path)?;
161            }
162        }
163
164        Ok(())
165    }
166
167    /// Get all discovered servers.
168    pub fn servers(&self) -> impl Iterator<Item = &DiscoveredServer> {
169        self.servers.values()
170    }
171
172    /// Get a server by name.
173    #[must_use]
174    pub fn get(&self, name: &str) -> Option<&DiscoveredServer> {
175        self.servers.get(name)
176    }
177
178    /// Check if a server is registered.
179    #[must_use]
180    pub fn contains(&self, name: &str) -> bool {
181        self.servers.contains_key(name)
182    }
183
184    /// Load servers from a configuration file.
185    fn load_config_file(&mut self, path: &Path) -> Result<(), DiscoveryError> {
186        let contents = std::fs::read_to_string(path).map_err(|e| DiscoveryError::Io {
187            path: path.to_path_buf(),
188            source: e,
189        })?;
190
191        let config: ServerConfig =
192            serde_json::from_str(&contents).map_err(|e| DiscoveryError::Parse {
193                path: path.to_path_buf(),
194                source: e,
195            })?;
196
197        for server in config.servers {
198            self.servers.insert(server.name.clone(), server);
199        }
200
201        Ok(())
202    }
203}
204
205/// Configuration file format.
206#[derive(Debug, Deserialize)]
207struct ServerConfig {
208    servers: Vec<DiscoveredServer>,
209}
210
211/// Error type for server discovery.
212#[derive(Debug, thiserror::Error)]
213pub enum DiscoveryError {
214    /// I/O error reading a file.
215    #[error("Failed to read {path}: {source}")]
216    Io {
217        /// The file path.
218        path: PathBuf,
219        /// The underlying error.
220        source: std::io::Error,
221    },
222    /// Parse error in configuration file.
223    #[error("Failed to parse {path}: {source}")]
224    Parse {
225        /// The file path.
226        path: PathBuf,
227        /// The underlying error.
228        source: serde_json::Error,
229    },
230}
231
232// Platform-agnostic directory helpers
233fn dirs_config_dir() -> Option<PathBuf> {
234    #[cfg(target_os = "macos")]
235    {
236        dirs_home_dir().map(|h| h.join("Library").join("Application Support"))
237    }
238    #[cfg(target_os = "windows")]
239    {
240        std::env::var("APPDATA").ok().map(PathBuf::from)
241    }
242    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
243    {
244        std::env::var("XDG_CONFIG_HOME")
245            .ok()
246            .map(PathBuf::from)
247            .or_else(|| dirs_home_dir().map(|h| h.join(".config")))
248    }
249}
250
251fn dirs_home_dir() -> Option<PathBuf> {
252    std::env::var("HOME")
253        .ok()
254        .map(PathBuf::from)
255        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_discovered_server_stdio() {
264        let server = DiscoveredServer::stdio("my-server", "my-server-bin")
265            .description("A test server")
266            .env("DEBUG", "1");
267
268        assert_eq!(server.name, "my-server");
269        assert!(matches!(server.transport, ServerTransport::Stdio { .. }));
270        assert_eq!(server.env.get("DEBUG"), Some(&"1".to_string()));
271    }
272
273    #[test]
274    fn test_discovered_server_http() {
275        let server = DiscoveredServer::http("my-server", "http://localhost:8080");
276
277        assert_eq!(server.name, "my-server");
278        match server.transport {
279            ServerTransport::Http { url } => assert_eq!(url, "http://localhost:8080"),
280            _ => panic!("Expected HTTP transport"),
281        }
282    }
283
284    #[test]
285    fn test_server_discovery_register() {
286        let discovery =
287            ServerDiscovery::new().register(DiscoveredServer::stdio("test", "test-bin"));
288
289        assert!(discovery.contains("test"));
290        assert!(!discovery.contains("unknown"));
291    }
292
293    #[test]
294    fn test_transport_serialization() -> Result<(), Box<dyn std::error::Error>> {
295        let server = DiscoveredServer::stdio("test", "test-cmd");
296        let json = serde_json::to_string(&server)?;
297        assert!(json.contains("\"type\":\"stdio\""));
298
299        let server = DiscoveredServer::http("test", "http://localhost");
300        let json = serde_json::to_string(&server)?;
301        assert!(json.contains("\"type\":\"http\""));
302        Ok(())
303    }
304}