ceylon_runtime/config/
mcp_config.rs

1//! MCP tool configuration from TOML files.
2
3use serde::{Deserialize, Serialize};
4
5/// Transport type for MCP connections.
6///
7/// Specifies how to connect to an MCP server.
8#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
9#[serde(rename_all = "lowercase")]
10pub enum McpTransportType {
11    /// STDIO transport - spawns a child process and communicates via stdin/stdout
12    #[default]
13    Stdio,
14    /// HTTP/SSE transport - connects to a remote MCP server
15    Http,
16}
17
18/// Configuration for a single MCP tool source.
19///
20/// Defines how to connect to an MCP server and use its tools.
21///
22/// # Examples
23///
24/// ## STDIO Transport (npx package)
25/// ```toml
26/// [[agents.mcp_tools]]
27/// type = "stdio"
28/// command = "npx"
29/// args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
30/// ```
31///
32/// ## STDIO Transport (Python MCP server)
33/// ```toml
34/// [[agents.mcp_tools]]
35/// type = "stdio"
36/// command = "python"
37/// args = ["-m", "mcp_server_time"]
38/// ```
39///
40/// ## HTTP Transport
41/// ```toml
42/// [[agents.mcp_tools]]
43/// type = "http"
44/// url = "http://localhost:3000/mcp"
45/// auth_token = "${MCP_AUTH_TOKEN}"
46/// ```
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48pub struct McpToolConfig {
49    /// Transport type: "stdio" or "http"
50    #[serde(rename = "type", default)]
51    pub transport_type: McpTransportType,
52
53    /// Command to execute (for stdio transport).
54    /// Examples: "npx", "python", "node", or path to an executable.
55    #[serde(default)]
56    pub command: Option<String>,
57
58    /// Arguments for the command (for stdio transport).
59    #[serde(default)]
60    pub args: Vec<String>,
61
62    /// Environment variables for the child process (for stdio transport).
63    /// Format: [["KEY", "VALUE"], ["KEY2", "VALUE2"]]
64    #[serde(default)]
65    pub env: Option<Vec<(String, String)>>,
66
67    /// URL for HTTP transport.
68    /// Example: "http://localhost:3000/mcp"
69    #[serde(default)]
70    pub url: Option<String>,
71
72    /// Authentication token for HTTP transport (optional).
73    /// Can use environment variable syntax: "${MCP_AUTH_TOKEN}"
74    #[serde(default)]
75    pub auth_token: Option<String>,
76
77    /// Optional name for this tool source (for logging/debugging).
78    #[serde(default)]
79    pub name: Option<String>,
80}
81
82impl McpToolConfig {
83    /// Create a new STDIO transport configuration.
84    ///
85    /// # Arguments
86    /// * `command` - Command to execute (e.g., "npx", "python")
87    /// * `args` - Arguments to pass to the command
88    pub fn stdio(command: impl Into<String>, args: Vec<String>) -> Self {
89        Self {
90            transport_type: McpTransportType::Stdio,
91            command: Some(command.into()),
92            args,
93            ..Default::default()
94        }
95    }
96
97    /// Create an npx-based MCP tool configuration.
98    ///
99    /// # Arguments
100    /// * `package` - NPM package name (e.g., "@modelcontextprotocol/server-filesystem")
101    /// * `extra_args` - Additional arguments after the package name
102    pub fn npx(package: impl Into<String>, extra_args: Vec<String>) -> Self {
103        let mut args = vec!["-y".to_string(), package.into()];
104        args.extend(extra_args);
105        Self::stdio("npx", args)
106    }
107
108    /// Create an HTTP transport configuration.
109    ///
110    /// # Arguments
111    /// * `url` - URL of the MCP server
112    pub fn http(url: impl Into<String>) -> Self {
113        Self {
114            transport_type: McpTransportType::Http,
115            url: Some(url.into()),
116            ..Default::default()
117        }
118    }
119
120    /// Set the name for this tool source.
121    pub fn with_name(mut self, name: impl Into<String>) -> Self {
122        self.name = Some(name.into());
123        self
124    }
125
126    /// Set environment variables for the process.
127    pub fn with_env(mut self, env: Vec<(String, String)>) -> Self {
128        self.env = Some(env);
129        self
130    }
131
132    /// Set authentication token for HTTP transport.
133    pub fn with_auth_token(mut self, token: impl Into<String>) -> Self {
134        self.auth_token = Some(token.into());
135        self
136    }
137
138    /// Validate the configuration based on transport type.
139    pub fn validate(&self) -> Result<(), String> {
140        match self.transport_type {
141            McpTransportType::Stdio => {
142                if self.command.is_none() {
143                    return Err("STDIO transport requires 'command' field".to_string());
144                }
145            }
146            McpTransportType::Http => {
147                if self.url.is_none() {
148                    return Err("HTTP transport requires 'url' field".to_string());
149                }
150            }
151        }
152        Ok(())
153    }
154}
155
156// Conditional implementation when mcp feature is enabled
157#[cfg(feature = "mcp")]
158impl McpToolConfig {
159    /// Convert this configuration to an MCP transport for runtime use.
160    ///
161    /// This method is only available when the `mcp` feature is enabled.
162    pub fn to_transport(&self) -> Result<ceylon_mcp::McpTransport, String> {
163        self.validate()?;
164
165        match self.transport_type {
166            McpTransportType::Stdio => Ok(ceylon_mcp::McpTransport::Stdio {
167                command: self.command.clone().unwrap(),
168                args: self.args.clone(),
169                env: self.env.clone(),
170            }),
171            McpTransportType::Http => Ok(ceylon_mcp::McpTransport::Http {
172                url: self.url.clone().unwrap(),
173                auth_token: self.auth_token.clone(),
174            }),
175        }
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn test_parse_stdio_config() {
185        let toml = r#"
186            type = "stdio"
187            command = "npx"
188            args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
189        "#;
190
191        let config: McpToolConfig = toml::from_str(toml).unwrap();
192        assert_eq!(config.transport_type, McpTransportType::Stdio);
193        assert_eq!(config.command, Some("npx".to_string()));
194        assert_eq!(config.args.len(), 3);
195        assert!(config.validate().is_ok());
196    }
197
198    #[test]
199    fn test_parse_http_config() {
200        let toml = r#"
201            type = "http"
202            url = "http://localhost:3000/mcp"
203            auth_token = "secret-token"
204        "#;
205
206        let config: McpToolConfig = toml::from_str(toml).unwrap();
207        assert_eq!(config.transport_type, McpTransportType::Http);
208        assert_eq!(config.url, Some("http://localhost:3000/mcp".to_string()));
209        assert_eq!(config.auth_token, Some("secret-token".to_string()));
210        assert!(config.validate().is_ok());
211    }
212
213    #[test]
214    fn test_builder_methods() {
215        let config = McpToolConfig::npx(
216            "@modelcontextprotocol/server-filesystem",
217            vec!["/home".into()],
218        )
219        .with_name("filesystem-tools");
220
221        assert_eq!(config.transport_type, McpTransportType::Stdio);
222        assert_eq!(config.command, Some("npx".to_string()));
223        assert_eq!(
224            config.args,
225            vec!["-y", "@modelcontextprotocol/server-filesystem", "/home"]
226        );
227        assert_eq!(config.name, Some("filesystem-tools".to_string()));
228    }
229
230    #[test]
231    fn test_validation_fails_without_command() {
232        let config = McpToolConfig {
233            transport_type: McpTransportType::Stdio,
234            command: None,
235            ..Default::default()
236        };
237        assert!(config.validate().is_err());
238    }
239
240    #[test]
241    fn test_validation_fails_without_url() {
242        let config = McpToolConfig {
243            transport_type: McpTransportType::Http,
244            url: None,
245            ..Default::default()
246        };
247        assert!(config.validate().is_err());
248    }
249}