Skip to main content

claude_wrapper/
mcp_config.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use serde::Serialize;
5
6use crate::error::Result;
7
8/// Builder for generating `.mcp.json` config files programmatically.
9///
10/// This is useful when you need to dynamically configure MCP servers
11/// for agent processes that communicate via MCP.
12///
13/// # Example
14///
15/// ```no_run
16/// use claude_wrapper::McpConfigBuilder;
17///
18/// # fn example() -> claude_wrapper::Result<()> {
19/// let config = McpConfigBuilder::new()
20///     .http_server("my-hub", "http://127.0.0.1:9090")
21///     .stdio_server("my-tool", "npx", ["my-mcp-server"])
22///     .write_to("/tmp/my-project/.mcp.json")?;
23/// # Ok(())
24/// # }
25/// ```
26#[derive(Debug, Clone, Default)]
27pub struct McpConfigBuilder {
28    servers: HashMap<String, McpServerConfig>,
29}
30
31/// Configuration for a single MCP server entry.
32#[derive(Debug, Clone, Serialize)]
33#[serde(tag = "type")]
34pub enum McpServerConfig {
35    /// HTTP transport (streamable HTTP or SSE).
36    #[serde(rename = "http")]
37    Http {
38        url: String,
39        #[serde(skip_serializing_if = "HashMap::is_empty")]
40        headers: HashMap<String, String>,
41    },
42
43    /// Stdio transport (subprocess).
44    #[serde(rename = "stdio")]
45    Stdio {
46        command: String,
47        #[serde(skip_serializing_if = "Vec::is_empty")]
48        args: Vec<String>,
49        #[serde(skip_serializing_if = "HashMap::is_empty")]
50        env: HashMap<String, String>,
51    },
52}
53
54/// Wrapper for serializing the full config file.
55#[derive(Debug, Serialize)]
56struct McpConfigFile {
57    #[serde(rename = "mcpServers")]
58    mcp_servers: HashMap<String, McpServerConfig>,
59}
60
61impl McpConfigBuilder {
62    /// Create a new empty MCP config builder.
63    #[must_use]
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Add an HTTP MCP server.
69    #[must_use]
70    pub fn http_server(mut self, name: impl Into<String>, url: impl Into<String>) -> Self {
71        self.servers.insert(
72            name.into(),
73            McpServerConfig::Http {
74                url: url.into(),
75                headers: HashMap::new(),
76            },
77        );
78        self
79    }
80
81    /// Add an HTTP MCP server with custom headers.
82    #[must_use]
83    pub fn http_server_with_headers(
84        mut self,
85        name: impl Into<String>,
86        url: impl Into<String>,
87        headers: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
88    ) -> Self {
89        self.servers.insert(
90            name.into(),
91            McpServerConfig::Http {
92                url: url.into(),
93                headers: headers
94                    .into_iter()
95                    .map(|(k, v)| (k.into(), v.into()))
96                    .collect(),
97            },
98        );
99        self
100    }
101
102    /// Add a stdio MCP server.
103    #[must_use]
104    pub fn stdio_server(
105        mut self,
106        name: impl Into<String>,
107        command: impl Into<String>,
108        args: impl IntoIterator<Item = impl Into<String>>,
109    ) -> Self {
110        self.servers.insert(
111            name.into(),
112            McpServerConfig::Stdio {
113                command: command.into(),
114                args: args.into_iter().map(Into::into).collect(),
115                env: HashMap::new(),
116            },
117        );
118        self
119    }
120
121    /// Add a stdio MCP server with environment variables.
122    #[must_use]
123    pub fn stdio_server_with_env(
124        mut self,
125        name: impl Into<String>,
126        command: impl Into<String>,
127        args: impl IntoIterator<Item = impl Into<String>>,
128        env: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
129    ) -> Self {
130        self.servers.insert(
131            name.into(),
132            McpServerConfig::Stdio {
133                command: command.into(),
134                args: args.into_iter().map(Into::into).collect(),
135                env: env.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
136            },
137        );
138        self
139    }
140
141    /// Add a raw server config.
142    #[must_use]
143    pub fn server(mut self, name: impl Into<String>, config: McpServerConfig) -> Self {
144        self.servers.insert(name.into(), config);
145        self
146    }
147
148    /// Serialize to JSON string.
149    pub fn to_json(&self) -> Result<String> {
150        let file = McpConfigFile {
151            mcp_servers: self.servers.clone(),
152        };
153
154        #[cfg(feature = "json")]
155        {
156            serde_json::to_string_pretty(&file).map_err(|e| crate::error::Error::Json {
157                message: "failed to serialize MCP config".to_string(),
158                source: e,
159            })
160        }
161
162        #[cfg(not(feature = "json"))]
163        {
164            let _ = file;
165            Err(crate::error::Error::Io {
166                message: "json feature required for MCP config serialization".to_string(),
167                source: std::io::Error::new(
168                    std::io::ErrorKind::Unsupported,
169                    "json feature not enabled",
170                ),
171                working_dir: None,
172            })
173        }
174    }
175
176    /// Write the config to a file path, returning the path.
177    pub fn write_to(&self, path: impl AsRef<Path>) -> Result<PathBuf> {
178        let path = path.as_ref().to_path_buf();
179        let json = self.to_json()?;
180
181        if let Some(parent) = path.parent() {
182            std::fs::create_dir_all(parent).map_err(|e| crate::error::Error::Io {
183                message: format!("failed to create directory: {}", parent.display()),
184                source: e,
185                working_dir: None,
186            })?;
187        }
188
189        std::fs::write(&path, json).map_err(|e| crate::error::Error::Io {
190            message: format!("failed to write MCP config to {}", path.display()),
191            source: e,
192            working_dir: None,
193        })?;
194
195        Ok(path)
196    }
197
198    /// Write the config to a temporary file that is cleaned up on drop.
199    ///
200    /// Returns a [`TempMcpConfig`] that holds the temp file and provides
201    /// the path for use with [`QueryCommand::mcp_config()`](crate::QueryCommand::mcp_config).
202    ///
203    /// # Example
204    ///
205    /// ```no_run
206    /// use claude_wrapper::{Claude, ClaudeCommand, McpConfigBuilder, QueryCommand};
207    ///
208    /// # async fn example() -> claude_wrapper::Result<()> {
209    /// let claude = Claude::builder().build()?;
210    ///
211    /// let config = McpConfigBuilder::new()
212    ///     .http_server("hub", "http://localhost:9090")
213    ///     .stdio_server("tool", "npx", ["my-server"])
214    ///     .build_temp()?;
215    ///
216    /// let output = QueryCommand::new("list tools")
217    ///     .mcp_config(config.path())
218    ///     .execute(&claude)
219    ///     .await?;
220    /// // temp file is cleaned up when `config` is dropped
221    /// # Ok(())
222    /// # }
223    /// ```
224    #[cfg(feature = "tempfile")]
225    pub fn build_temp(&self) -> Result<TempMcpConfig> {
226        use std::io::Write;
227
228        let json = self.to_json()?;
229        let mut file = tempfile::Builder::new()
230            .suffix(".mcp.json")
231            .tempfile()
232            .map_err(|e| crate::error::Error::Io {
233                message: "failed to create temp MCP config file".to_string(),
234                source: e,
235                working_dir: None,
236            })?;
237
238        file.write_all(json.as_bytes())
239            .map_err(|e| crate::error::Error::Io {
240                message: "failed to write temp MCP config".to_string(),
241                source: e,
242                working_dir: None,
243            })?;
244
245        Ok(TempMcpConfig { file })
246    }
247}
248
249/// A temporary MCP config file that is cleaned up when dropped.
250///
251/// Created by [`McpConfigBuilder::build_temp()`]. Use [`path()`](TempMcpConfig::path)
252/// to get the file path for passing to [`QueryCommand::mcp_config()`](crate::QueryCommand::mcp_config).
253#[cfg(feature = "tempfile")]
254#[derive(Debug)]
255pub struct TempMcpConfig {
256    file: tempfile::NamedTempFile,
257}
258
259#[cfg(feature = "tempfile")]
260impl TempMcpConfig {
261    /// Get the path to the temporary config file.
262    ///
263    /// Returns a string suitable for passing to `QueryCommand::mcp_config()`.
264    #[must_use]
265    pub fn path(&self) -> &str {
266        self.file
267            .path()
268            .to_str()
269            .expect("temp file path is valid UTF-8")
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn test_http_server_config() {
279        let config = McpConfigBuilder::new().http_server("my-hub", "http://127.0.0.1:9090");
280
281        let json = config.to_json().unwrap();
282        assert!(json.contains("my-hub"));
283        assert!(json.contains("http://127.0.0.1:9090"));
284        assert!(json.contains(r#""type": "http""#));
285    }
286
287    #[test]
288    fn test_stdio_server_config() {
289        let config = McpConfigBuilder::new().stdio_server(
290            "my-tool",
291            "npx",
292            ["my-mcp-server", "--port", "3000"],
293        );
294
295        let json = config.to_json().unwrap();
296        assert!(json.contains("my-tool"));
297        assert!(json.contains("npx"));
298        assert!(json.contains("my-mcp-server"));
299        assert!(json.contains(r#""type": "stdio""#));
300    }
301
302    #[test]
303    #[cfg(feature = "tempfile")]
304    fn test_build_temp() {
305        let config = McpConfigBuilder::new()
306            .http_server("hub", "http://localhost:9090")
307            .stdio_server("tool", "echo", ["hello"]);
308
309        let temp = config.build_temp().unwrap();
310        let path = temp.path();
311        assert!(path.ends_with(".mcp.json"));
312
313        let contents = std::fs::read_to_string(path).unwrap();
314        assert!(contents.contains("hub"));
315        assert!(contents.contains("localhost:9090"));
316    }
317
318    #[test]
319    fn test_multiple_servers() {
320        let config = McpConfigBuilder::new()
321            .http_server("hub", "http://localhost:9090")
322            .stdio_server("tool", "node", ["server.js"]);
323
324        let json = config.to_json().unwrap();
325        assert!(json.contains("hub"));
326        assert!(json.contains("tool"));
327    }
328}