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 #[must_use]
118 pub fn new() -> Self {
119 let mut config_paths = Vec::new();
120
121 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 pub fn add_config_path(mut self, path: impl Into<PathBuf>) -> Self {
137 self.config_paths.push(path.into());
138 self
139 }
140
141 #[must_use]
143 pub fn register(mut self, server: DiscoveredServer) -> Self {
144 self.servers.insert(server.name.clone(), server);
145 self
146 }
147
148 pub fn discover(&mut self) -> Result<(), DiscoveryError> {
154 let paths: Vec<_> = self.config_paths.clone();
156
157 for path in paths {
159 if path.exists() {
160 self.load_config_file(&path)?;
161 }
162 }
163
164 Ok(())
165 }
166
167 pub fn servers(&self) -> impl Iterator<Item = &DiscoveredServer> {
169 self.servers.values()
170 }
171
172 #[must_use]
174 pub fn get(&self, name: &str) -> Option<&DiscoveredServer> {
175 self.servers.get(name)
176 }
177
178 #[must_use]
180 pub fn contains(&self, name: &str) -> bool {
181 self.servers.contains_key(name)
182 }
183
184 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#[derive(Debug, Deserialize)]
207struct ServerConfig {
208 servers: Vec<DiscoveredServer>,
209}
210
211#[derive(Debug, thiserror::Error)]
213pub enum DiscoveryError {
214 #[error("Failed to read {path}: {source}")]
216 Io {
217 path: PathBuf,
219 source: std::io::Error,
221 },
222 #[error("Failed to parse {path}: {source}")]
224 Parse {
225 path: PathBuf,
227 source: serde_json::Error,
229 },
230}
231
232fn 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}