Skip to main content

hermes_agent_cli_core/
mcp.rs

1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6use std::time::Instant;
7
8/// MCP server configuration stored in mcp.json
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct McpServer {
11    pub name: String,
12    pub url: String,
13    #[serde(default = "default_enabled")]
14    pub enabled: bool,
15    #[serde(default = "default_added_at")]
16    pub added_at: String,
17}
18
19fn default_enabled() -> bool {
20    true
21}
22
23fn default_added_at() -> String {
24    chrono::Utc::now().to_rfc3339()
25}
26
27/// Storage for MCP server configurations
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct McpStore {
30    #[serde(default)]
31    pub servers: Vec<McpServer>,
32}
33
34impl McpStore {
35    /// Load MCP store from HERMES_HOME/mcp.json
36    pub fn load() -> Result<Self> {
37        let path = Self::mcp_path();
38        if !path.exists() {
39            return Ok(McpStore::default());
40        }
41        let content = fs::read_to_string(&path)
42            .with_context(|| format!("failed to read MCP store from {:?}", path))?;
43        let store: McpStore = serde_json::from_str(&content)
44            .with_context(|| format!("failed to parse MCP store from {:?}", path))?;
45        Ok(store)
46    }
47
48    /// Save MCP store to HERMES_HOME/mcp.json
49    pub fn save(&self) -> Result<()> {
50        let path = Self::mcp_path();
51        if let Some(parent) = path.parent() {
52            fs::create_dir_all(parent)
53                .with_context(|| format!("failed to create MCP directory {:?}", parent))?;
54        }
55        let content =
56            serde_json::to_string_pretty(self).context("failed to serialize MCP store")?;
57        fs::write(&path, content)
58            .with_context(|| format!("failed to write MCP store to {:?}", path))?;
59        Ok(())
60    }
61
62    /// Get MCP path
63    pub fn mcp_path() -> PathBuf {
64        if let Ok(home) = std::env::var("HERMES_HOME") {
65            return PathBuf::from(home).join("mcp.json");
66        }
67        if let Ok(profile) = std::env::var("HERMES_PROFILE") {
68            if let Some(proj_dirs) =
69                ProjectDirs::from("ai", "hermes", &format!("hermes-{}", profile))
70            {
71                return proj_dirs.config_dir().join("mcp.json");
72            }
73        }
74        if let Some(proj_dirs) = ProjectDirs::from("ai", "hermes", "hermes-cli") {
75            return proj_dirs.config_dir().join("mcp.json");
76        }
77        if let Ok(home) = std::env::var("USERPROFILE") {
78            return PathBuf::from(home).join(".hermes").join("mcp.json");
79        }
80        PathBuf::from(".hermes").join("mcp.json")
81    }
82
83    /// Add an MCP server
84    pub fn add_server(&mut self, name: &str, url: &str) -> Result<()> {
85        // Check if name already exists
86        if self.servers.iter().any(|s| s.name == name) {
87            anyhow::bail!(
88                "MCP server '{}' already exists. Use a different name or remove it first.",
89                name
90            );
91        }
92
93        self.servers.push(McpServer {
94            name: name.to_string(),
95            url: url.to_string(),
96            enabled: true,
97            added_at: chrono::Utc::now().to_rfc3339(),
98        });
99        Ok(())
100    }
101
102    /// Remove an MCP server by name
103    pub fn remove_server(&mut self, name: &str) -> Result<()> {
104        let len = self.servers.len();
105        self.servers.retain(|s| s.name != name);
106        if self.servers.len() == len {
107            anyhow::bail!("MCP server '{}' not found", name);
108        }
109        Ok(())
110    }
111
112    /// Get a server by name
113    pub fn get_server(&self, name: &str) -> Option<&McpServer> {
114        self.servers.iter().find(|s| s.name == name)
115    }
116
117    /// List all servers
118    pub fn list_servers(&self) -> &[McpServer] {
119        &self.servers
120    }
121}
122
123/// Test connectivity to an MCP server
124pub fn test_server(server: &McpServer) -> Result<TestResult> {
125    let start = Instant::now();
126
127    if server.url.starts_with("stdio://") {
128        // For stdio:// URLs, check if the binary exists
129        let path = server.url.trim_start_matches("stdio://");
130        let path = if path.contains(' ') {
131            // If there's a space, it's likely "path/to/binary --args"
132            path.split_whitespace().next().unwrap_or(path)
133        } else {
134            path
135        };
136
137        if std::path::Path::new(path).exists() {
138            Ok(TestResult {
139                success: true,
140                response_time_ms: 0,
141                message: format!(
142                    "Binary '{}' exists (stdio transport, actual connection not tested)",
143                    path
144                ),
145            })
146        } else {
147            Ok(TestResult {
148                success: false,
149                response_time_ms: 0,
150                message: format!("Binary '{}' not found", path),
151            })
152        }
153    } else if server.url.starts_with("http://") || server.url.starts_with("https://") {
154        // For HTTP URLs, we report success since the URL prefix was already validated
155        let url = &server.url;
156        let duration = start.elapsed();
157
158        Ok(TestResult {
159            success: true,
160            response_time_ms: duration.as_millis() as u64,
161            message: format!("URL '{}' is valid (connection test not fully implemented)", url),
162        })
163    } else {
164        Ok(TestResult {
165            success: false,
166            response_time_ms: start.elapsed().as_millis() as u64,
167            message: format!(
168                "Unknown transport scheme in URL '{}'. Supported: http://, https://, stdio://",
169                server.url
170            ),
171        })
172    }
173}
174
175/// Result of testing an MCP server connection
176#[derive(Debug)]
177pub struct TestResult {
178    pub success: bool,
179    pub response_time_ms: u64,
180    pub message: String,
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_mcp_store_default() {
189        let store = McpStore::default();
190        assert!(store.servers.is_empty());
191    }
192
193    #[test]
194    fn test_mcp_store_add_server() {
195        let mut store = McpStore::default();
196        store.add_server("test", "http://localhost:3000").unwrap();
197        assert_eq!(store.servers.len(), 1);
198        assert_eq!(store.servers[0].name, "test");
199        assert_eq!(store.servers[0].url, "http://localhost:3000");
200        assert!(store.servers[0].enabled);
201    }
202
203    #[test]
204    fn test_mcp_store_add_duplicate() {
205        let mut store = McpStore::default();
206        store.add_server("test", "http://localhost:3000").unwrap();
207        let result = store.add_server("test", "http://localhost:4000");
208        assert!(result.is_err());
209    }
210
211    #[test]
212    fn test_mcp_store_remove_server() {
213        let mut store = McpStore::default();
214        store.add_server("test", "http://localhost:3000").unwrap();
215        store.remove_server("test").unwrap();
216        assert!(store.servers.is_empty());
217    }
218
219    #[test]
220    fn test_mcp_store_remove_not_found() {
221        let mut store = McpStore::default();
222        let result = store.remove_server("nonexistent");
223        assert!(result.is_err());
224    }
225
226    #[test]
227    fn test_mcp_server_serialization() {
228        let server = McpServer {
229            name: "test".to_string(),
230            url: "http://localhost:3000".to_string(),
231            enabled: true,
232            added_at: "2026-01-01T00:00:00Z".to_string(),
233        };
234        let json = serde_json::to_string_pretty(&server).unwrap();
235        assert!(json.contains("\"name\": \"test\""));
236        assert!(json.contains("\"url\": \"http://localhost:3000\""));
237        assert!(json.contains("\"enabled\": true"));
238    }
239}