1use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12use serde::Serialize;
13
14use crate::error::Result;
15
16#[derive(Debug, Clone, Default)]
35pub struct McpConfigBuilder {
36 servers: HashMap<String, McpServerConfig>,
37}
38
39#[derive(Debug, Clone, Serialize)]
41#[serde(tag = "type")]
42pub enum McpServerConfig {
43 #[serde(rename = "http")]
45 Http {
46 url: String,
48 #[serde(skip_serializing_if = "HashMap::is_empty")]
50 headers: HashMap<String, String>,
51 },
52
53 #[serde(rename = "stdio")]
55 Stdio {
56 command: String,
58 #[serde(skip_serializing_if = "Vec::is_empty")]
60 args: Vec<String>,
61 #[serde(skip_serializing_if = "HashMap::is_empty")]
63 env: HashMap<String, String>,
64 },
65}
66
67#[derive(Debug, Serialize)]
69struct McpConfigFile {
70 #[serde(rename = "mcpServers")]
71 mcp_servers: HashMap<String, McpServerConfig>,
72}
73
74impl McpConfigBuilder {
75 #[must_use]
77 pub fn new() -> Self {
78 Self::default()
79 }
80
81 #[must_use]
83 pub fn http_server(mut self, name: impl Into<String>, url: impl Into<String>) -> Self {
84 self.servers.insert(
85 name.into(),
86 McpServerConfig::Http {
87 url: url.into(),
88 headers: HashMap::new(),
89 },
90 );
91 self
92 }
93
94 #[must_use]
96 pub fn http_server_with_headers(
97 mut self,
98 name: impl Into<String>,
99 url: impl Into<String>,
100 headers: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
101 ) -> Self {
102 self.servers.insert(
103 name.into(),
104 McpServerConfig::Http {
105 url: url.into(),
106 headers: headers
107 .into_iter()
108 .map(|(k, v)| (k.into(), v.into()))
109 .collect(),
110 },
111 );
112 self
113 }
114
115 #[must_use]
117 pub fn stdio_server(
118 mut self,
119 name: impl Into<String>,
120 command: impl Into<String>,
121 args: impl IntoIterator<Item = impl Into<String>>,
122 ) -> Self {
123 self.servers.insert(
124 name.into(),
125 McpServerConfig::Stdio {
126 command: command.into(),
127 args: args.into_iter().map(Into::into).collect(),
128 env: HashMap::new(),
129 },
130 );
131 self
132 }
133
134 #[must_use]
136 pub fn stdio_server_with_env(
137 mut self,
138 name: impl Into<String>,
139 command: impl Into<String>,
140 args: impl IntoIterator<Item = impl Into<String>>,
141 env: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
142 ) -> Self {
143 self.servers.insert(
144 name.into(),
145 McpServerConfig::Stdio {
146 command: command.into(),
147 args: args.into_iter().map(Into::into).collect(),
148 env: env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
149 },
150 );
151 self
152 }
153
154 #[must_use]
156 pub fn server(mut self, name: impl Into<String>, config: McpServerConfig) -> Self {
157 self.servers.insert(name.into(), config);
158 self
159 }
160
161 pub fn to_json(&self) -> Result<String> {
163 let file = McpConfigFile {
164 mcp_servers: self.servers.clone(),
165 };
166
167 #[cfg(feature = "json")]
168 {
169 serde_json::to_string_pretty(&file).map_err(|e| crate::error::Error::Json {
170 message: "failed to serialize MCP config".to_string(),
171 source: e,
172 })
173 }
174
175 #[cfg(not(feature = "json"))]
176 {
177 let _ = file;
178 Err(crate::error::Error::Io {
179 message: "json feature required for MCP config serialization".to_string(),
180 source: std::io::Error::new(
181 std::io::ErrorKind::Unsupported,
182 "json feature not enabled",
183 ),
184 working_dir: None,
185 })
186 }
187 }
188
189 pub fn write_to(&self, path: impl AsRef<Path>) -> Result<PathBuf> {
191 let path = path.as_ref().to_path_buf();
192 let json = self.to_json()?;
193
194 if let Some(parent) = path.parent() {
195 std::fs::create_dir_all(parent).map_err(|e| crate::error::Error::Io {
196 message: format!("failed to create directory: {}", parent.display()),
197 source: e,
198 working_dir: None,
199 })?;
200 }
201
202 std::fs::write(&path, json).map_err(|e| crate::error::Error::Io {
203 message: format!("failed to write MCP config to {}", path.display()),
204 source: e,
205 working_dir: None,
206 })?;
207
208 Ok(path)
209 }
210
211 #[cfg(feature = "tempfile")]
238 pub fn build_temp(&self) -> Result<TempMcpConfig> {
239 use std::io::Write;
240
241 let json = self.to_json()?;
242 let mut file = tempfile::Builder::new()
243 .suffix(".mcp.json")
244 .tempfile()
245 .map_err(|e| crate::error::Error::Io {
246 message: "failed to create temp MCP config file".to_string(),
247 source: e,
248 working_dir: None,
249 })?;
250
251 file.write_all(json.as_bytes())
252 .map_err(|e| crate::error::Error::Io {
253 message: "failed to write temp MCP config".to_string(),
254 source: e,
255 working_dir: None,
256 })?;
257
258 Ok(TempMcpConfig { file })
259 }
260}
261
262#[cfg(feature = "tempfile")]
267#[derive(Debug)]
268pub struct TempMcpConfig {
269 file: tempfile::NamedTempFile,
270}
271
272#[cfg(feature = "tempfile")]
273impl TempMcpConfig {
274 #[must_use]
278 pub fn path(&self) -> &str {
279 self.file
280 .path()
281 .to_str()
282 .expect("temp file path is valid UTF-8")
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 #[cfg(feature = "json")]
290 use super::*;
291
292 #[test]
293 #[cfg(feature = "json")]
294 fn test_http_server_config() {
295 let config = McpConfigBuilder::new().http_server("my-hub", "http://127.0.0.1:9090");
296
297 let json = config.to_json().unwrap();
298 assert!(json.contains("my-hub"));
299 assert!(json.contains("http://127.0.0.1:9090"));
300 assert!(json.contains(r#""type": "http""#));
301 }
302
303 #[test]
304 #[cfg(feature = "json")]
305 fn test_stdio_server_config() {
306 let config = McpConfigBuilder::new().stdio_server(
307 "my-tool",
308 "npx",
309 ["my-mcp-server", "--port", "3000"],
310 );
311
312 let json = config.to_json().unwrap();
313 assert!(json.contains("my-tool"));
314 assert!(json.contains("npx"));
315 assert!(json.contains("my-mcp-server"));
316 assert!(json.contains(r#""type": "stdio""#));
317 }
318
319 #[test]
320 #[cfg(all(feature = "tempfile", feature = "json"))]
321 fn test_build_temp() {
322 let config = McpConfigBuilder::new()
323 .http_server("hub", "http://localhost:9090")
324 .stdio_server("tool", "echo", ["hello"]);
325
326 let temp = config.build_temp().unwrap();
327 let path = temp.path();
328 assert!(path.ends_with(".mcp.json"));
329
330 let contents = std::fs::read_to_string(path).unwrap();
331 assert!(contents.contains("hub"));
332 assert!(contents.contains("localhost:9090"));
333 }
334
335 #[test]
336 #[cfg(feature = "json")]
337 fn test_multiple_servers() {
338 let config = McpConfigBuilder::new()
339 .http_server("hub", "http://localhost:9090")
340 .stdio_server("tool", "node", ["server.js"]);
341
342 let json = config.to_json().unwrap();
343 assert!(json.contains("hub"));
344 assert!(json.contains("tool"));
345 }
346
347 #[test]
350 #[cfg(feature = "json")]
351 fn write_to_creates_parent_dirs_and_roundtrips() {
352 let dir = tempfile::tempdir().unwrap();
354 let nested = dir.path().join("a/b/c/servers.mcp.json");
355
356 let written = McpConfigBuilder::new()
357 .http_server("hub", "http://localhost:9090")
358 .stdio_server("tool", "node", ["server.js"])
359 .write_to(&nested)
360 .unwrap();
361
362 assert_eq!(written, nested);
363 assert!(nested.exists());
364
365 let parsed: serde_json::Value =
366 serde_json::from_str(&std::fs::read_to_string(&nested).unwrap()).unwrap();
367 let servers = &parsed["mcpServers"];
368 assert_eq!(servers["hub"]["type"], "http");
369 assert_eq!(servers["hub"]["url"], "http://localhost:9090");
370 assert_eq!(servers["tool"]["type"], "stdio");
371 assert_eq!(servers["tool"]["command"], "node");
372 assert_eq!(servers["tool"]["args"][0], "server.js");
373 }
374
375 #[test]
376 #[cfg(feature = "json")]
377 fn http_server_with_headers_serializes_headers() {
378 let config = McpConfigBuilder::new().http_server_with_headers(
379 "hub",
380 "http://localhost:9090",
381 [("Authorization", "Bearer token")],
382 );
383 let parsed: serde_json::Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
384 let hub = &parsed["mcpServers"]["hub"];
385 assert_eq!(hub["type"], "http");
386 assert_eq!(hub["url"], "http://localhost:9090");
387 assert_eq!(hub["headers"]["Authorization"], "Bearer token");
388 }
389
390 #[test]
391 #[cfg(feature = "json")]
392 fn stdio_server_with_env_serializes_env() {
393 let config = McpConfigBuilder::new().stdio_server_with_env(
394 "tool",
395 "node",
396 ["server.js"],
397 [("API_KEY", "secret")],
398 );
399 let parsed: serde_json::Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
400 let tool = &parsed["mcpServers"]["tool"];
401 assert_eq!(tool["type"], "stdio");
402 assert_eq!(tool["command"], "node");
403 assert_eq!(tool["args"][0], "server.js");
404 assert_eq!(tool["env"]["API_KEY"], "secret");
405 }
406
407 #[test]
408 #[cfg(feature = "json")]
409 fn raw_server_config_serializes() {
410 let config = McpConfigBuilder::new().server(
411 "raw",
412 McpServerConfig::Http {
413 url: "http://example.test".into(),
414 headers: HashMap::new(),
415 },
416 );
417 let parsed: serde_json::Value = serde_json::from_str(&config.to_json().unwrap()).unwrap();
418 assert_eq!(parsed["mcpServers"]["raw"]["type"], "http");
419 assert_eq!(parsed["mcpServers"]["raw"]["url"], "http://example.test");
420 }
421
422 #[test]
423 #[cfg(all(feature = "tempfile", feature = "json"))]
424 fn build_temp_roundtrips_to_parsed_servers() {
425 let config = McpConfigBuilder::new().http_server_with_headers(
426 "hub",
427 "http://localhost:9090",
428 [("X-Test", "1")],
429 );
430
431 let temp = config.build_temp().unwrap();
432 assert!(temp.path().ends_with(".mcp.json"));
433
434 let parsed: serde_json::Value =
435 serde_json::from_str(&std::fs::read_to_string(temp.path()).unwrap()).unwrap();
436 assert_eq!(parsed["mcpServers"]["hub"]["headers"]["X-Test"], "1");
437 }
438}