gestura_core_mcp/
config.rs

1//! MCP server configuration types.
2//!
3//! These types represent the “Claude Desktop / Claude Code” compatible MCP
4//! configuration schema and are used by the MCP client registry.
5
6use 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/// MCP tool entry (basic) — **DEPRECATED**: use [`McpServerEntry`] instead.
19///
20/// Kept only for backward-compatible deserialization of older config files.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22pub struct McpTool {
23    pub name: String,
24    pub endpoint: String,
25}
26
27impl McpTool {
28    /// Convert a legacy `McpTool` into the full `McpServerEntry`.
29    ///
30    /// Heuristic: if `endpoint` looks like a URL (starts with `http://` or
31    /// `https://`), assume HTTP transport; otherwise treat as stdio command.
32    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            // Treat as a stdio command (e.g. "npx -y @some/server")
44            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// ============================================================================
66// MCP Server Configuration (full spec — Claude Code compatible)
67// ============================================================================
68
69/// Transport type for MCP server connections.
70///
71/// Matches the `type` field in `.mcp.json` configuration files.
72#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
73#[serde(rename_all = "lowercase")]
74pub enum McpTransportType {
75    /// Standard I/O transport — server runs as a local child process.
76    #[default]
77    Stdio,
78    /// Streamable HTTP transport (recommended for remote servers).
79    Http,
80    /// Server-Sent Events transport (deprecated, but still functional).
81    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
109/// Infer the MCP transport type from an endpoint URL.
110///
111/// Returns `Some(McpTransportType::Http)` when the endpoint starts with
112/// `http://` or `https://`, `None` otherwise (e.g. for stdio commands).
113pub 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/// Configuration scope for MCP servers.
123///
124/// Determines where the server configuration is stored and its precedence.
125#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
126#[serde(rename_all = "lowercase")]
127pub enum McpScope {
128    /// User-level configuration (`~/.gestura/config.yaml`).
129    #[default]
130    User,
131    /// Project-level configuration (`.mcp.json` in repo root).
132    Project,
133    /// Local-only configuration (`.gestura.json` in project dir).
134    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/// Full MCP server entry compatible with `.mcp.json`.
163///
164/// # Examples
165///
166/// ```
167/// use gestura_core_mcp::config::{McpServerEntry, McpTransportType};
168///
169/// let _stdio_server = McpServerEntry {
170///     name: "postgres".to_string(),
171///     transport: McpTransportType::Stdio,
172///     command: Some("npx".to_string()),
173///     args: vec!["-y".to_string(), "@anthropic-ai/mcp-server-postgres".to_string()],
174///     ..Default::default()
175/// };
176///
177/// let _http_server = McpServerEntry {
178///     name: "github".to_string(),
179///     transport: McpTransportType::Http,
180///     url: Some("https://example.invalid/mcp".to_string()),
181///     ..Default::default()
182/// };
183/// ```
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct McpServerEntry {
186    /// Unique server name/identifier.
187    pub name: String,
188
189    /// Transport type (`stdio`, `http`, `sse`).
190    #[serde(rename = "type", default)]
191    pub transport: McpTransportType,
192
193    /// Whether this server is enabled.
194    #[serde(default = "default_true")]
195    pub enabled: bool,
196
197    // -- stdio-specific fields --
198    /// Command to execute (stdio transport).
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub command: Option<String>,
201    /// Arguments to pass to `command`.
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub args: Vec<String>,
204    /// Environment variables.
205    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
206    pub env: HashMap<String, String>,
207
208    // -- http/sse-specific fields --
209    /// HTTP/SSE URL.
210    #[serde(default, skip_serializing_if = "Option::is_none", alias = "endpoint")]
211    pub url: Option<String>,
212    /// HTTP headers to send.
213    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
214    pub headers: HashMap<String, String>,
215
216    // -- common fields --
217    /// Configuration scope (user/project/local).
218    #[serde(default)]
219    pub scope: McpScope,
220    /// Connection timeout in seconds.
221    #[serde(default = "default_mcp_timeout")]
222    pub timeout_secs: u64,
223    /// Auto-reconnect on failure.
224    #[serde(default = "default_true")]
225    pub auto_reconnect: bool,
226
227    // -- session behavior --
228    /// Whether tools from this server are enabled by default when a new session
229    /// is created.  Individual session overrides (stored in
230    /// `SessionToolSettings::enabled_tools`) take precedence.
231    #[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    /// Return the effective URI for this server.
256    ///
257    /// For HTTP/SSE this is the `url` field. For stdio, a synthetic
258    /// `stdio://<command>` URI is returned for display/logging purposes.
259    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    /// Convert to the discovery-layer `McpServerConfig`.
270    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/// Represents an `.mcp.json` configuration file.
282#[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    /// Read a `.mcp.json` file from `path`.
290    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        // Ensure each entry's `name` matches the map key.
296        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    /// Write the file to `path` as pretty-printed JSON.
305    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    /// Flatten the map into a `Vec<McpServerEntry>` with a given scope.
312    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    /// Recursively resolve `.mcp.json` paths for user, project, and local scopes.
326    /// Returns a list of (scope, path) to load, from lowest to highest precedence.
327    pub fn resolve_scope_paths() -> Vec<(McpScope, std::path::PathBuf)> {
328        let mut paths = Vec::new();
329
330        // 1. User scope (~/.mcp.json) - Lowest Precedence
331        if let Some(home) = dirs::home_dir() {
332            paths.push((McpScope::User, home.join(".mcp.json")));
333        }
334
335        // 2. Project scope (.mcp.json in current directory)
336        if let Ok(cwd) = std::env::current_dir() {
337            paths.push((McpScope::Project, cwd.join(".mcp.json")));
338
339            // 3. Local scope (.gestura.json in current directory) - Highest Precedence
340            paths.push((McpScope::Local, cwd.join(".gestura.json")));
341        }
342
343        paths
344    }
345
346    /// Load and merge `.mcp.json` files across User, Project, and Local scopes.
347    ///
348    /// The resulting `Vec<McpServerEntry>` represents the flattened and merged
349    /// configuration where `Local` > `Project` > `User`. Only active entries
350    /// are retained per name.
351    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                    // Higher precedence overwrites lower precedence.
361                    merged.insert(entry.name.clone(), entry);
362                }
363            }
364        }
365
366        merged.into_values().collect()
367    }
368
369    /// Async version of `load_aggregated`.
370    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
394/// Import MCP servers from Claude Desktop config.
395pub 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}