1use crate::discovery::McpServerConfig;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9
10fn default_true() -> bool {
11 true
12}
13
14fn default_mcp_timeout() -> u64 {
15 30
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct McpTool {
23 pub name: String,
24 pub endpoint: String,
25}
26
27impl McpTool {
28 pub fn to_server_entry(&self) -> McpServerEntry {
33 let endpoint_lower = self.endpoint.to_lowercase();
34 if endpoint_lower.starts_with("http://") || endpoint_lower.starts_with("https://") {
35 McpServerEntry {
36 name: self.name.clone(),
37 transport: McpTransportType::Http,
38 enabled: true,
39 url: Some(self.endpoint.clone()),
40 ..McpServerEntry::default()
41 }
42 } else {
43 let parts: Vec<&str> = self.endpoint.split_whitespace().collect();
45 let (command, args) = if let Some((cmd, rest)) = parts.split_first() {
46 (
47 Some((*cmd).to_string()),
48 rest.iter().map(|s| (*s).to_string()).collect(),
49 )
50 } else {
51 (Some(self.endpoint.clone()), vec![])
52 };
53 McpServerEntry {
54 name: self.name.clone(),
55 transport: McpTransportType::Stdio,
56 enabled: true,
57 command,
58 args,
59 ..McpServerEntry::default()
60 }
61 }
62 }
63}
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
73#[serde(rename_all = "lowercase")]
74pub enum McpTransportType {
75 #[default]
77 Stdio,
78 Http,
80 Sse,
82}
83
84impl std::fmt::Display for McpTransportType {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 match self {
87 Self::Stdio => write!(f, "stdio"),
88 Self::Http => write!(f, "http"),
89 Self::Sse => write!(f, "sse"),
90 }
91 }
92}
93
94impl std::str::FromStr for McpTransportType {
95 type Err = String;
96 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
97 match s.to_lowercase().as_str() {
98 "stdio" => Ok(Self::Stdio),
99 "http" | "streamable-http" => Ok(Self::Http),
100 "sse" => Ok(Self::Sse),
101 other => Err(format!(
102 "Unknown MCP transport type '{}'. Expected: stdio, http, sse",
103 other
104 )),
105 }
106 }
107}
108
109pub fn infer_transport_from_endpoint(endpoint: Option<&str>) -> Option<McpTransportType> {
114 let ep = endpoint?.trim();
115 if ep.starts_with("http://") || ep.starts_with("https://") {
116 Some(McpTransportType::Http)
117 } else {
118 None
119 }
120}
121
122#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
126#[serde(rename_all = "lowercase")]
127pub enum McpScope {
128 #[default]
130 User,
131 Project,
133 Local,
135}
136
137impl std::fmt::Display for McpScope {
138 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139 match self {
140 Self::User => write!(f, "user"),
141 Self::Project => write!(f, "project"),
142 Self::Local => write!(f, "local"),
143 }
144 }
145}
146
147impl std::str::FromStr for McpScope {
148 type Err = String;
149 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
150 match s.to_lowercase().as_str() {
151 "user" => Ok(Self::User),
152 "project" => Ok(Self::Project),
153 "local" => Ok(Self::Local),
154 other => Err(format!(
155 "Unknown MCP scope '{}'. Expected: user, project, local",
156 other
157 )),
158 }
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct McpServerEntry {
186 pub name: String,
188
189 #[serde(rename = "type", default)]
191 pub transport: McpTransportType,
192
193 #[serde(default = "default_true")]
195 pub enabled: bool,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub command: Option<String>,
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub args: Vec<String>,
204 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
206 pub env: HashMap<String, String>,
207
208 #[serde(default, skip_serializing_if = "Option::is_none", alias = "endpoint")]
211 pub url: Option<String>,
212 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
214 pub headers: HashMap<String, String>,
215
216 #[serde(default)]
219 pub scope: McpScope,
220 #[serde(default = "default_mcp_timeout")]
222 pub timeout_secs: u64,
223 #[serde(default = "default_true")]
225 pub auto_reconnect: bool,
226
227 #[serde(default = "default_true")]
232 pub session_default_enabled: bool,
233}
234
235impl Default for McpServerEntry {
236 fn default() -> Self {
237 Self {
238 name: String::new(),
239 transport: McpTransportType::default(),
240 enabled: true,
241 command: None,
242 args: Vec::new(),
243 env: HashMap::new(),
244 url: None,
245 headers: HashMap::new(),
246 scope: McpScope::default(),
247 timeout_secs: 30,
248 auto_reconnect: true,
249 session_default_enabled: true,
250 }
251 }
252}
253
254impl McpServerEntry {
255 pub fn effective_uri(&self) -> String {
260 match self.transport {
261 McpTransportType::Http | McpTransportType::Sse => self.url.clone().unwrap_or_default(),
262 McpTransportType::Stdio => {
263 let cmd = self.command.as_deref().unwrap_or("unknown");
264 format!("stdio://{}", cmd)
265 }
266 }
267 }
268
269 pub fn to_discovery_config(&self) -> McpServerConfig {
271 McpServerConfig {
272 name: self.name.clone(),
273 uri: self.effective_uri(),
274 enabled: self.enabled,
275 timeout_secs: self.timeout_secs,
276 auto_reconnect: self.auto_reconnect,
277 }
278 }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, Default)]
283pub struct McpJsonFile {
284 #[serde(rename = "mcpServers", default)]
285 pub mcp_servers: HashMap<String, McpServerEntry>,
286}
287
288impl McpJsonFile {
289 pub fn load(path: &std::path::Path) -> std::result::Result<Self, String> {
291 let content = std::fs::read_to_string(path)
292 .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
293 let mut parsed: Self = serde_json::from_str(&content)
294 .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
295 for (key, entry) in parsed.mcp_servers.iter_mut() {
297 if entry.name.is_empty() {
298 entry.name = key.clone();
299 }
300 }
301 Ok(parsed)
302 }
303
304 pub fn save(&self, path: &std::path::Path) -> std::result::Result<(), String> {
306 let json = serde_json::to_string_pretty(self)
307 .map_err(|e| format!("Failed to serialize .mcp.json: {}", e))?;
308 std::fs::write(path, json).map_err(|e| format!("Failed to write {}: {}", path.display(), e))
309 }
310
311 pub fn into_entries(self, scope: McpScope) -> Vec<McpServerEntry> {
313 self.mcp_servers
314 .into_iter()
315 .map(|(key, mut entry)| {
316 if entry.name.is_empty() {
317 entry.name = key;
318 }
319 entry.scope = scope;
320 entry
321 })
322 .collect()
323 }
324
325 pub fn resolve_scope_paths() -> Vec<(McpScope, std::path::PathBuf)> {
328 let mut paths = Vec::new();
329
330 if let Some(home) = dirs::home_dir() {
332 paths.push((McpScope::User, home.join(".mcp.json")));
333 }
334
335 if let Ok(cwd) = std::env::current_dir() {
337 paths.push((McpScope::Project, cwd.join(".mcp.json")));
338
339 paths.push((McpScope::Local, cwd.join(".gestura.json")));
341 }
342
343 paths
344 }
345
346 pub fn load_aggregated() -> Vec<McpServerEntry> {
352 let mut merged: std::collections::HashMap<String, McpServerEntry> =
353 std::collections::HashMap::new();
354
355 for (scope, path) in Self::resolve_scope_paths() {
356 if path.exists()
357 && let Ok(mcp_file) = Self::load(&path)
358 {
359 for entry in mcp_file.into_entries(scope) {
360 merged.insert(entry.name.clone(), entry);
362 }
363 }
364 }
365
366 merged.into_values().collect()
367 }
368
369 pub async fn load_aggregated_async() -> Vec<McpServerEntry> {
371 let mut merged: std::collections::HashMap<String, McpServerEntry> =
372 std::collections::HashMap::new();
373
374 for (scope, path) in Self::resolve_scope_paths() {
375 if tokio::fs::try_exists(&path).await.unwrap_or(false)
376 && let Ok(content) = tokio::fs::read_to_string(&path).await
377 && let Ok(mut parsed) = serde_json::from_str::<Self>(&content)
378 {
379 for (key, entry) in parsed.mcp_servers.iter_mut() {
380 if entry.name.is_empty() {
381 entry.name = key.clone();
382 }
383 }
384 for entry in parsed.into_entries(scope) {
385 merged.insert(entry.name.clone(), entry);
386 }
387 }
388 }
389
390 merged.into_values().collect()
391 }
392}
393
394pub fn import_claude_desktop_servers() -> std::result::Result<Vec<McpServerEntry>, String> {
396 let config_path = claude_desktop_config_path()
397 .ok_or_else(|| "Could not determine Claude Desktop config path".to_string())?;
398
399 if !config_path.exists() {
400 return Err(format!(
401 "Claude Desktop config not found at {}",
402 config_path.display()
403 ));
404 }
405
406 let mcp_file = McpJsonFile::load(&config_path)?;
407 Ok(mcp_file.into_entries(McpScope::User))
408}
409
410fn claude_desktop_config_path() -> Option<std::path::PathBuf> {
411 #[cfg(target_os = "macos")]
412 {
413 dirs::home_dir()
414 .map(|h| h.join("Library/Application Support/Claude/claude_desktop_config.json"))
415 }
416 #[cfg(target_os = "linux")]
417 {
418 dirs::config_dir().map(|c| c.join("Claude/claude_desktop_config.json"))
419 }
420 #[cfg(target_os = "windows")]
421 {
422 dirs::config_dir().map(|c| c.join("Claude/claude_desktop_config.json"))
423 }
424 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
425 {
426 None
427 }
428}