hermes_agent_cli_core/
mcp.rs1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::PathBuf;
6use std::time::Instant;
7
8#[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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
29pub struct McpStore {
30 #[serde(default)]
31 pub servers: Vec<McpServer>,
32}
33
34impl McpStore {
35 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 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 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 pub fn add_server(&mut self, name: &str, url: &str) -> Result<()> {
85 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 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 pub fn get_server(&self, name: &str) -> Option<&McpServer> {
114 self.servers.iter().find(|s| s.name == name)
115 }
116
117 pub fn list_servers(&self) -> &[McpServer] {
119 &self.servers
120 }
121}
122
123pub fn test_server(server: &McpServer) -> Result<TestResult> {
125 let start = Instant::now();
126
127 if server.url.starts_with("stdio://") {
128 let path = server.url.trim_start_matches("stdio://");
130 let path = if path.contains(' ') {
131 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 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#[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}