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    pub fn new() -> Self {
118        let mut config_paths = Vec::new();
119
120        // Add standard config locations
121        if let Some(config_dir) = dirs_config_dir() {
122            config_paths.push(config_dir.join("mcp").join("servers.json"));
123        }
124        if let Some(home) = dirs_home_dir() {
125            config_paths.push(home.join(".mcp").join("servers.json"));
126        }
127
128        Self {
129            servers: HashMap::new(),
130            config_paths,
131        }
132    }
133
134    /// Add a custom configuration file path.
135    pub fn add_config_path(mut self, path: impl Into<PathBuf>) -> Self {
136        self.config_paths.push(path.into());
137        self
138    }
139
140    /// Register a server manually.
141    pub fn register(mut self, server: DiscoveredServer) -> Self {
142        self.servers.insert(server.name.clone(), server);
143        self
144    }
145
146    /// Discover servers from all configured sources.
147    ///
148    /// # Errors
149    ///
150    /// Returns an error if reading configuration files fails.
151    pub fn discover(&mut self) -> Result<(), DiscoveryError> {
152        // Clone paths to avoid borrow conflict
153        let paths: Vec<_> = self.config_paths.iter().cloned().collect();
154
155        // Load from config files
156        for path in paths {
157            if path.exists() {
158                self.load_config_file(&path)?;
159            }
160        }
161
162        Ok(())
163    }
164
165    /// Get all discovered servers.
166    pub fn servers(&self) -> impl Iterator<Item = &DiscoveredServer> {
167        self.servers.values()
168    }
169
170    /// Get a server by name.
171    pub fn get(&self, name: &str) -> Option<&DiscoveredServer> {
172        self.servers.get(name)
173    }
174
175    /// Check if a server is registered.
176    pub fn contains(&self, name: &str) -> bool {
177        self.servers.contains_key(name)
178    }
179
180    /// Load servers from a configuration file.
181    fn load_config_file(&mut self, path: &Path) -> Result<(), DiscoveryError> {
182        let contents = std::fs::read_to_string(path).map_err(|e| DiscoveryError::Io {
183            path: path.to_path_buf(),
184            source: e,
185        })?;
186
187        let config: ServerConfig =
188            serde_json::from_str(&contents).map_err(|e| DiscoveryError::Parse {
189                path: path.to_path_buf(),
190                source: e,
191            })?;
192
193        for server in config.servers {
194            self.servers.insert(server.name.clone(), server);
195        }
196
197        Ok(())
198    }
199}
200
201/// Configuration file format.
202#[derive(Debug, Deserialize)]
203struct ServerConfig {
204    servers: Vec<DiscoveredServer>,
205}
206
207/// Error type for server discovery.
208#[derive(Debug, thiserror::Error)]
209pub enum DiscoveryError {
210    /// I/O error reading a file.
211    #[error("Failed to read {path}: {source}")]
212    Io {
213        /// The file path.
214        path: PathBuf,
215        /// The underlying error.
216        source: std::io::Error,
217    },
218    /// Parse error in configuration file.
219    #[error("Failed to parse {path}: {source}")]
220    Parse {
221        /// The file path.
222        path: PathBuf,
223        /// The underlying error.
224        source: serde_json::Error,
225    },
226}
227
228// Platform-agnostic directory helpers
229fn dirs_config_dir() -> Option<PathBuf> {
230    #[cfg(target_os = "macos")]
231    {
232        dirs_home_dir().map(|h| h.join("Library").join("Application Support"))
233    }
234    #[cfg(target_os = "windows")]
235    {
236        std::env::var("APPDATA").ok().map(PathBuf::from)
237    }
238    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
239    {
240        std::env::var("XDG_CONFIG_HOME")
241            .ok()
242            .map(PathBuf::from)
243            .or_else(|| dirs_home_dir().map(|h| h.join(".config")))
244    }
245}
246
247fn dirs_home_dir() -> Option<PathBuf> {
248    std::env::var("HOME")
249        .ok()
250        .map(PathBuf::from)
251        .or_else(|| std::env::var("USERPROFILE").ok().map(PathBuf::from))
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_discovered_server_stdio() {
260        let server = DiscoveredServer::stdio("my-server", "my-server-bin")
261            .description("A test server")
262            .env("DEBUG", "1");
263
264        assert_eq!(server.name, "my-server");
265        assert!(matches!(server.transport, ServerTransport::Stdio { .. }));
266        assert_eq!(server.env.get("DEBUG"), Some(&"1".to_string()));
267    }
268
269    #[test]
270    fn test_discovered_server_http() {
271        let server = DiscoveredServer::http("my-server", "http://localhost:8080");
272
273        assert_eq!(server.name, "my-server");
274        match server.transport {
275            ServerTransport::Http { url } => assert_eq!(url, "http://localhost:8080"),
276            _ => panic!("Expected HTTP transport"),
277        }
278    }
279
280    #[test]
281    fn test_server_discovery_register() {
282        let discovery = ServerDiscovery::new()
283            .register(DiscoveredServer::stdio("test", "test-bin"));
284
285        assert!(discovery.contains("test"));
286        assert!(!discovery.contains("unknown"));
287    }
288
289    #[test]
290    fn test_transport_serialization() {
291        let server = DiscoveredServer::stdio("test", "test-cmd");
292        let json = serde_json::to_string(&server).unwrap();
293        assert!(json.contains("\"type\":\"stdio\""));
294
295        let server = DiscoveredServer::http("test", "http://localhost");
296        let json = serde_json::to_string(&server).unwrap();
297        assert!(json.contains("\"type\":\"http\""));
298    }
299}