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            })
172        }
173    }
174
175    /// Write the config to a file path, returning the path.
176    pub fn write_to(&self, path: impl AsRef<Path>) -> Result<PathBuf> {
177        let path = path.as_ref().to_path_buf();
178        let json = self.to_json()?;
179
180        if let Some(parent) = path.parent() {
181            std::fs::create_dir_all(parent).map_err(|e| crate::error::Error::Io {
182                message: format!("failed to create directory: {}", parent.display()),
183                source: e,
184                working_dir: None,
185            })?;
186        }
187
188        std::fs::write(&path, json).map_err(|e| crate::error::Error::Io {
189            message: format!("failed to write MCP config to {}", path.display()),
190            source: e,
191            working_dir: None,
192        })?;
193
194        Ok(path)
195    }
196
197    /// Write the config to a temporary file that is cleaned up on drop.
198    ///
199    /// Returns a [`TempMcpConfig`] that holds the temp file and provides
200    /// the path for use with [`QueryCommand::mcp_config()`](crate::QueryCommand::mcp_config).
201    ///
202    /// # Example
203    ///
204    /// ```no_run
205    /// use claude_wrapper::{Claude, ClaudeCommand, McpConfigBuilder, QueryCommand};
206    ///
207    /// # async fn example() -> claude_wrapper::Result<()> {
208    /// let claude = Claude::builder().build()?;
209    ///
210    /// let config = McpConfigBuilder::new()
211    ///     .http_server("hub", "http://localhost:9090")
212    ///     .stdio_server("tool", "npx", ["my-server"])
213    ///     .build_temp()?;
214    ///
215    /// let output = QueryCommand::new("list tools")
216    ///     .mcp_config(config.path())
217    ///     .execute(&claude)
218    ///     .await?;
219    /// // temp file is cleaned up when `config` is dropped
220    /// # Ok(())
221    /// # }
222    /// ```
223    #[cfg(feature = "tempfile")]
224    pub fn build_temp(&self) -> Result<TempMcpConfig> {
225        use std::io::Write;
226
227        let json = self.to_json()?;
228        let mut file = tempfile::Builder::new()
229            .suffix(".mcp.json")
230            .tempfile()
231            .map_err(|e| crate::error::Error::Io {
232                message: "failed to create temp MCP config file".to_string(),
233                source: e,
234                working_dir: None,
235            })?;
236
237        file.write_all(json.as_bytes())
238            .map_err(|e| crate::error::Error::Io {
239                message: "failed to write temp MCP config".to_string(),
240                source: e,
241                working_dir: None,
242            })?;
243
244        Ok(TempMcpConfig { file })
245    }
246}
247
248/// A temporary MCP config file that is cleaned up when dropped.
249///
250/// Created by [`McpConfigBuilder::build_temp()`]. Use [`path()`](TempMcpConfig::path)
251/// to get the file path for passing to [`QueryCommand::mcp_config()`](crate::QueryCommand::mcp_config).
252#[cfg(feature = "tempfile")]
253#[derive(Debug)]
254pub struct TempMcpConfig {
255    file: tempfile::NamedTempFile,
256}
257
258#[cfg(feature = "tempfile")]
259impl TempMcpConfig {
260    /// Get the path to the temporary config file.
261    ///
262    /// Returns a string suitable for passing to `QueryCommand::mcp_config()`.
263    #[must_use]
264    pub fn path(&self) -> &str {
265        self.file
266            .path()
267            .to_str()
268            .expect("temp file path is valid UTF-8")
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn test_http_server_config() {
278        let config = McpConfigBuilder::new().http_server("my-hub", "http://127.0.0.1:9090");
279
280        let json = config.to_json().unwrap();
281        assert!(json.contains("my-hub"));
282        assert!(json.contains("http://127.0.0.1:9090"));
283        assert!(json.contains(r#""type": "http""#));
284    }
285
286    #[test]
287    fn test_stdio_server_config() {
288        let config = McpConfigBuilder::new().stdio_server(
289            "my-tool",
290            "npx",
291            ["my-mcp-server", "--port", "3000"],
292        );
293
294        let json = config.to_json().unwrap();
295        assert!(json.contains("my-tool"));
296        assert!(json.contains("npx"));
297        assert!(json.contains("my-mcp-server"));
298        assert!(json.contains(r#""type": "stdio""#));
299    }
300
301    #[test]
302    #[cfg(feature = "tempfile")]
303    fn test_build_temp() {
304        let config = McpConfigBuilder::new()
305            .http_server("hub", "http://localhost:9090")
306            .stdio_server("tool", "echo", ["hello"]);
307
308        let temp = config.build_temp().unwrap();
309        let path = temp.path();
310        assert!(path.ends_with(".mcp.json"));
311
312        let contents = std::fs::read_to_string(path).unwrap();
313        assert!(contents.contains("hub"));
314        assert!(contents.contains("localhost:9090"));
315    }
316
317    #[test]
318    fn test_multiple_servers() {
319        let config = McpConfigBuilder::new()
320            .http_server("hub", "http://localhost:9090")
321            .stdio_server("tool", "node", ["server.js"]);
322
323        let json = config.to_json().unwrap();
324        assert!(json.contains("hub"));
325        assert!(json.contains("tool"));
326    }
327}