Skip to main content

claude_wrapper/
mcp_config.rs

1//! Programmatic `.mcp.json` generation.
2//!
3//! [`McpConfigBuilder`] assembles HTTP and stdio MCP server entries and
4//! serializes them to the `.mcp.json` shape the `claude` CLI expects,
5//! either to a string, a caller-chosen path, or (with the `tempfile`
6//! feature) a self-cleaning [`TempMcpConfig`] for one-shot use with
7//! [`QueryCommand::mcp_config`](crate::QueryCommand::mcp_config).
8
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12use serde::Serialize;
13
14use crate::error::Result;
15
16/// Builder for generating `.mcp.json` config files programmatically.
17///
18/// This is useful when you need to dynamically configure MCP servers
19/// for agent processes that communicate via MCP.
20///
21/// # Example
22///
23/// ```no_run
24/// use claude_wrapper::McpConfigBuilder;
25///
26/// # fn example() -> claude_wrapper::Result<()> {
27/// let config = McpConfigBuilder::new()
28///     .http_server("my-hub", "http://127.0.0.1:9090")
29///     .stdio_server("my-tool", "npx", ["my-mcp-server"])
30///     .write_to("/tmp/my-project/.mcp.json")?;
31/// # Ok(())
32/// # }
33/// ```
34#[derive(Debug, Clone, Default)]
35pub struct McpConfigBuilder {
36    servers: HashMap<String, McpServerConfig>,
37}
38
39/// Configuration for a single MCP server entry.
40#[derive(Debug, Clone, Serialize)]
41#[serde(tag = "type")]
42pub enum McpServerConfig {
43    /// HTTP transport (streamable HTTP or SSE).
44    #[serde(rename = "http")]
45    Http {
46        /// The server endpoint URL.
47        url: String,
48        /// Extra HTTP headers to send (e.g. auth).
49        #[serde(skip_serializing_if = "HashMap::is_empty")]
50        headers: HashMap<String, String>,
51    },
52
53    /// Stdio transport (subprocess).
54    #[serde(rename = "stdio")]
55    Stdio {
56        /// The executable to launch.
57        command: String,
58        /// Arguments passed to the command.
59        #[serde(skip_serializing_if = "Vec::is_empty")]
60        args: Vec<String>,
61        /// Environment variables set for the subprocess.
62        #[serde(skip_serializing_if = "HashMap::is_empty")]
63        env: HashMap<String, String>,
64    },
65}
66
67/// Wrapper for serializing the full config file.
68#[derive(Debug, Serialize)]
69struct McpConfigFile {
70    #[serde(rename = "mcpServers")]
71    mcp_servers: HashMap<String, McpServerConfig>,
72}
73
74impl McpConfigBuilder {
75    /// Create a new empty MCP config builder.
76    #[must_use]
77    pub fn new() -> Self {
78        Self::default()
79    }
80
81    /// Add an HTTP MCP server.
82    #[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    /// Add an HTTP MCP server with custom headers.
95    #[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    /// Add a stdio MCP server.
116    #[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    /// Add a stdio MCP server with environment variables.
135    #[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    /// Add a raw server config.
155    #[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    /// Serialize to JSON string.
162    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    /// Write the config to a file path, returning the path.
190    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    /// Write the config to a temporary file that is cleaned up on drop.
212    ///
213    /// Returns a [`TempMcpConfig`] that holds the temp file and provides
214    /// the path for use with [`QueryCommand::mcp_config()`](crate::QueryCommand::mcp_config).
215    ///
216    /// # Example
217    ///
218    /// ```no_run
219    /// use claude_wrapper::{Claude, ClaudeCommand, McpConfigBuilder, QueryCommand};
220    ///
221    /// # async fn example() -> claude_wrapper::Result<()> {
222    /// let claude = Claude::builder().build()?;
223    ///
224    /// let config = McpConfigBuilder::new()
225    ///     .http_server("hub", "http://localhost:9090")
226    ///     .stdio_server("tool", "npx", ["my-server"])
227    ///     .build_temp()?;
228    ///
229    /// let output = QueryCommand::new("list tools")
230    ///     .mcp_config(config.path())
231    ///     .execute(&claude)
232    ///     .await?;
233    /// // temp file is cleaned up when `config` is dropped
234    /// # Ok(())
235    /// # }
236    /// ```
237    #[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/// A temporary MCP config file that is cleaned up when dropped.
263///
264/// Created by [`McpConfigBuilder::build_temp()`]. Use [`path()`](TempMcpConfig::path)
265/// to get the file path for passing to [`QueryCommand::mcp_config()`](crate::QueryCommand::mcp_config).
266#[cfg(feature = "tempfile")]
267#[derive(Debug)]
268pub struct TempMcpConfig {
269    file: tempfile::NamedTempFile,
270}
271
272#[cfg(feature = "tempfile")]
273impl TempMcpConfig {
274    /// Get the path to the temporary config file.
275    ///
276    /// Returns a string suitable for passing to `QueryCommand::mcp_config()`.
277    #[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    // Every test in this module requires `json`.
289    #[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    // -- filesystem / constructor coverage (#681) -------------------
348
349    #[test]
350    #[cfg(feature = "json")]
351    fn write_to_creates_parent_dirs_and_roundtrips() {
352        // Nested path whose parent does not exist exercises create_dir_all.
353        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}