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