mcpkit_client/
discovery.rs1use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct DiscoveredServer {
17 pub name: String,
19 pub transport: ServerTransport,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub description: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub icon: Option<String>,
27 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
29 pub env: HashMap<String, String>,
30}
31
32impl DiscoveredServer {
33 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 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 pub fn description(mut self, description: impl Into<String>) -> Self {
60 self.description = Some(description.into());
61 self
62 }
63
64 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#[derive(Debug, Clone, Serialize, Deserialize)]
73#[serde(tag = "type", rename_all = "lowercase")]
74pub enum ServerTransport {
75 Stdio {
77 command: String,
79 #[serde(default)]
81 args: Vec<String>,
82 },
83 Http {
85 url: String,
87 },
88 WebSocket {
90 url: String,
92 },
93}
94
95pub struct ServerDiscovery {
103 servers: HashMap<String, DiscoveredServer>,
105 config_paths: Vec<PathBuf>,
107}
108
109impl Default for ServerDiscovery {
110 fn default() -> Self {
111 Self::new()
112 }
113}
114
115impl ServerDiscovery {
116 pub fn new() -> Self {
118 let mut config_paths = Vec::new();
119
120 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 pub fn add_config_path(mut self, path: impl Into<PathBuf>) -> Self {
136 self.config_paths.push(path.into());
137 self
138 }
139
140 pub fn register(mut self, server: DiscoveredServer) -> Self {
142 self.servers.insert(server.name.clone(), server);
143 self
144 }
145
146 pub fn discover(&mut self) -> Result<(), DiscoveryError> {
152 let paths: Vec<_> = self.config_paths.iter().cloned().collect();
154
155 for path in paths {
157 if path.exists() {
158 self.load_config_file(&path)?;
159 }
160 }
161
162 Ok(())
163 }
164
165 pub fn servers(&self) -> impl Iterator<Item = &DiscoveredServer> {
167 self.servers.values()
168 }
169
170 pub fn get(&self, name: &str) -> Option<&DiscoveredServer> {
172 self.servers.get(name)
173 }
174
175 pub fn contains(&self, name: &str) -> bool {
177 self.servers.contains_key(name)
178 }
179
180 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#[derive(Debug, Deserialize)]
203struct ServerConfig {
204 servers: Vec<DiscoveredServer>,
205}
206
207#[derive(Debug, thiserror::Error)]
209pub enum DiscoveryError {
210 #[error("Failed to read {path}: {source}")]
212 Io {
213 path: PathBuf,
215 source: std::io::Error,
217 },
218 #[error("Failed to parse {path}: {source}")]
220 Parse {
221 path: PathBuf,
223 source: serde_json::Error,
225 },
226}
227
228fn 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}