Skip to main content

spn_core/
mcp.rs

1//! MCP (Model Context Protocol) configuration types.
2//!
3//! These types are shared between nika (MCP client) and spn (MCP config manager).
4
5/// Type of MCP server transport.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
7pub enum McpServerType {
8    /// Standard I/O transport (spawned process)
9    #[default]
10    Stdio,
11    /// Server-Sent Events over HTTP
12    Sse,
13    /// WebSocket transport
14    WebSocket,
15}
16
17/// MCP server configuration.
18///
19/// This represents a single MCP server that can be connected to.
20#[derive(Debug, Clone, Default)]
21pub struct McpServer {
22    /// Server name/identifier
23    pub name: String,
24    /// Transport type
25    pub server_type: McpServerType,
26    /// Command to run (for stdio transport)
27    pub command: Option<String>,
28    /// Command arguments
29    pub args: Vec<String>,
30    /// Environment variables to set
31    pub env: Vec<(String, String)>,
32    /// URL for SSE/WebSocket transports
33    pub url: Option<String>,
34    /// Whether this server is enabled
35    pub enabled: bool,
36}
37
38impl McpServer {
39    /// Create a new stdio MCP server.
40    ///
41    /// # Example
42    ///
43    /// ```
44    /// use spn_core::McpServer;
45    ///
46    /// let server = McpServer::stdio("neo4j", "npx", vec!["-y", "@anthropic/mcp-neo4j"]);
47    /// assert_eq!(server.name, "neo4j");
48    /// assert!(server.enabled);
49    /// ```
50    pub fn stdio(name: impl Into<String>, command: impl Into<String>, args: Vec<&str>) -> Self {
51        Self {
52            name: name.into(),
53            server_type: McpServerType::Stdio,
54            command: Some(command.into()),
55            args: args.into_iter().map(String::from).collect(),
56            env: Vec::new(),
57            url: None,
58            enabled: true,
59        }
60    }
61
62    /// Create a new SSE MCP server.
63    ///
64    /// # Example
65    ///
66    /// ```
67    /// use spn_core::McpServer;
68    ///
69    /// let server = McpServer::sse("remote", "http://localhost:3000/mcp");
70    /// assert_eq!(server.name, "remote");
71    /// ```
72    pub fn sse(name: impl Into<String>, url: impl Into<String>) -> Self {
73        Self {
74            name: name.into(),
75            server_type: McpServerType::Sse,
76            command: None,
77            args: Vec::new(),
78            env: Vec::new(),
79            url: Some(url.into()),
80            enabled: true,
81        }
82    }
83
84    /// Add an environment variable.
85    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
86        self.env.push((key.into(), value.into()));
87        self
88    }
89
90    /// Set enabled state.
91    pub fn with_enabled(mut self, enabled: bool) -> Self {
92        self.enabled = enabled;
93        self
94    }
95}
96
97/// Source of an MCP configuration.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum McpSource {
100    /// From spn global config (~/.spn/config.toml)
101    SpnGlobal,
102    /// From project spn.yaml
103    SpnProject,
104    /// From Claude Code config
105    ClaudeCode,
106    /// From Cursor config
107    Cursor,
108    /// From VS Code config
109    VsCode,
110    /// Discovered at runtime
111    Discovered,
112}
113
114/// Complete MCP configuration containing multiple servers.
115#[derive(Debug, Clone, Default)]
116pub struct McpConfig {
117    /// List of configured MCP servers
118    pub servers: Vec<McpServer>,
119    /// Source of this configuration
120    pub source: Option<McpSource>,
121}
122
123impl McpConfig {
124    /// Create an empty MCP configuration.
125    pub fn new() -> Self {
126        Self::default()
127    }
128
129    /// Add a server to the configuration.
130    pub fn add_server(&mut self, server: McpServer) {
131        self.servers.push(server);
132    }
133
134    /// Find a server by name.
135    pub fn find_server(&self, name: &str) -> Option<&McpServer> {
136        self.servers.iter().find(|s| s.name == name)
137    }
138
139    /// Get all enabled servers.
140    pub fn enabled_servers(&self) -> impl Iterator<Item = &McpServer> {
141        self.servers.iter().filter(|s| s.enabled)
142    }
143
144    /// Merge another configuration into this one.
145    ///
146    /// Servers with the same name are overwritten.
147    pub fn merge(&mut self, other: McpConfig) {
148        for server in other.servers {
149            // Remove existing server with same name
150            self.servers.retain(|s| s.name != server.name);
151            self.servers.push(server);
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_stdio_server() {
162        let server = McpServer::stdio("test", "node", vec!["server.js"]);
163        assert_eq!(server.name, "test");
164        assert_eq!(server.server_type, McpServerType::Stdio);
165        assert_eq!(server.command, Some("node".to_string()));
166        assert_eq!(server.args, vec!["server.js"]);
167        assert!(server.enabled);
168    }
169
170    #[test]
171    fn test_sse_server() {
172        let server = McpServer::sse("remote", "http://localhost:3000");
173        assert_eq!(server.server_type, McpServerType::Sse);
174        assert_eq!(server.url, Some("http://localhost:3000".to_string()));
175    }
176
177    #[test]
178    fn test_server_with_env() {
179        let server = McpServer::stdio("neo4j", "npx", vec!["-y", "@anthropic/mcp-neo4j"])
180            .with_env("NEO4J_URI", "bolt://localhost:7687")
181            .with_env("NEO4J_PASSWORD", "secret");
182
183        assert_eq!(server.env.len(), 2);
184        assert_eq!(
185            server.env[0],
186            ("NEO4J_URI".to_string(), "bolt://localhost:7687".to_string())
187        );
188    }
189
190    #[test]
191    fn test_config_add_find() {
192        let mut config = McpConfig::new();
193        config.add_server(McpServer::stdio("neo4j", "npx", vec![]));
194        config.add_server(McpServer::stdio("github", "npx", vec![]));
195
196        assert!(config.find_server("neo4j").is_some());
197        assert!(config.find_server("github").is_some());
198        assert!(config.find_server("unknown").is_none());
199    }
200
201    #[test]
202    fn test_config_enabled_servers() {
203        let mut config = McpConfig::new();
204        config.add_server(McpServer::stdio("enabled1", "cmd", vec![]));
205        config.add_server(McpServer::stdio("disabled", "cmd", vec![]).with_enabled(false));
206        config.add_server(McpServer::stdio("enabled2", "cmd", vec![]));
207
208        let enabled: Vec<_> = config.enabled_servers().collect();
209        assert_eq!(enabled.len(), 2);
210        assert!(enabled.iter().all(|s| s.enabled));
211    }
212
213    #[test]
214    fn test_config_merge() {
215        let mut config1 = McpConfig::new();
216        config1.add_server(McpServer::stdio("neo4j", "old-cmd", vec![]));
217        config1.add_server(McpServer::stdio("github", "gh-cmd", vec![]));
218
219        let mut config2 = McpConfig::new();
220        config2.add_server(McpServer::stdio("neo4j", "new-cmd", vec![])); // Override
221        config2.add_server(McpServer::stdio("slack", "slack-cmd", vec![])); // New
222
223        config1.merge(config2);
224
225        assert_eq!(config1.servers.len(), 3);
226        assert_eq!(
227            config1.find_server("neo4j").unwrap().command,
228            Some("new-cmd".to_string())
229        );
230    }
231}