Skip to main content

zagens_runtime_adapters/mcp/
config.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use super::auth::McpAuthConfig;
6
7/// Full MCP configuration from mcp.json
8#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
9pub struct McpConfig {
10    #[serde(default)]
11    pub timeouts: McpTimeouts,
12    #[serde(default, alias = "mcpServers")]
13    pub servers: HashMap<String, McpServerConfig>,
14}
15
16/// Global timeout configuration
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
18#[allow(clippy::struct_field_names)]
19pub struct McpTimeouts {
20    #[serde(default = "default_connect_timeout")]
21    pub connect_timeout: u64,
22    #[serde(default = "default_execute_timeout")]
23    pub execute_timeout: u64,
24    #[serde(default = "default_read_timeout")]
25    pub read_timeout: u64,
26}
27
28fn default_connect_timeout() -> u64 {
29    30
30}
31fn default_execute_timeout() -> u64 {
32    60
33}
34fn default_read_timeout() -> u64 {
35    120
36}
37
38impl Default for McpTimeouts {
39    fn default() -> Self {
40        Self {
41            connect_timeout: default_connect_timeout(),
42            execute_timeout: default_execute_timeout(),
43            read_timeout: default_read_timeout(),
44        }
45    }
46}
47
48/// Which wire transport a server uses. Resolved from the explicit
49/// `transport`/`type` config field, falling back to inference from
50/// `command` (stdio) vs `url` (SSE, for backward compatibility).
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum McpTransportKind {
53    /// Local subprocess over stdin/stdout JSON-RPC lines.
54    Stdio,
55    /// Legacy HTTP+SSE transport (GET event stream + POST endpoint).
56    Sse,
57    /// 2025 Streamable HTTP transport (single endpoint POST + optional SSE).
58    Http,
59}
60
61impl McpTransportKind {
62    /// Stable lower-case label used in snapshots / UI.
63    #[must_use]
64    pub fn as_str(self) -> &'static str {
65        match self {
66            Self::Stdio => "stdio",
67            Self::Sse => "sse",
68            Self::Http => "http",
69        }
70    }
71}
72
73/// Configuration for a single MCP server
74#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
75pub struct McpServerConfig {
76    pub command: Option<String>,
77    #[serde(default)]
78    pub args: Vec<String>,
79    #[serde(default)]
80    pub env: HashMap<String, String>,
81    pub url: Option<String>,
82    /// Explicit transport selector (`stdio` | `sse` | `http`). When omitted,
83    /// the transport is inferred: `command` ⇒ stdio, `url` ⇒ sse. Set to
84    /// `http` to use the 2025 Streamable HTTP transport. Accepts the `type`
85    /// alias for compatibility with common `mcp.json` snippets.
86    #[serde(default, alias = "type", skip_serializing_if = "Option::is_none")]
87    pub transport: Option<String>,
88    /// Extra HTTP headers for remote transports (`sse` / `http`). Values may
89    /// use `${ENV_VAR}` placeholders — prefer env indirection over plaintext
90    /// secrets in `mcp.json`.
91    #[serde(default)]
92    pub headers: HashMap<String, String>,
93    /// Shorthand auth (`type`: `bearer` | `apiKey`). Merged before `headers`;
94    /// explicit `headers` entries override auth defaults.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub auth: Option<McpAuthConfig>,
97    #[serde(default)]
98    pub connect_timeout: Option<u64>,
99    #[serde(default)]
100    pub execute_timeout: Option<u64>,
101    #[serde(default)]
102    pub read_timeout: Option<u64>,
103    #[serde(default)]
104    pub disabled: bool,
105    #[serde(default = "default_enabled")]
106    pub enabled: bool,
107    #[serde(default)]
108    pub required: bool,
109    #[serde(default)]
110    pub enabled_tools: Vec<String>,
111    #[serde(default)]
112    pub disabled_tools: Vec<String>,
113}
114
115fn default_enabled() -> bool {
116    true
117}
118
119impl McpServerConfig {
120    pub fn effective_connect_timeout(&self, global: &McpTimeouts) -> u64 {
121        self.connect_timeout.unwrap_or(global.connect_timeout)
122    }
123
124    pub fn effective_execute_timeout(&self, global: &McpTimeouts) -> u64 {
125        self.execute_timeout.unwrap_or(global.execute_timeout)
126    }
127
128    pub fn effective_read_timeout(&self, global: &McpTimeouts) -> u64 {
129        self.read_timeout.unwrap_or(global.read_timeout)
130    }
131
132    pub fn is_enabled(&self) -> bool {
133        self.enabled && !self.disabled
134    }
135
136    /// Resolve the effective transport for this server.
137    ///
138    /// Honors the explicit `transport`/`type` field when present, otherwise
139    /// infers from `command` (stdio) vs `url` (SSE, preserving pre-Streamable
140    /// behavior). Returns an error when the config is ambiguous or incomplete.
141    pub fn transport_kind(&self) -> anyhow::Result<McpTransportKind> {
142        if let Some(raw) = &self.transport {
143            let kind = match raw.trim().to_ascii_lowercase().as_str() {
144                "stdio" => McpTransportKind::Stdio,
145                "sse" => McpTransportKind::Sse,
146                "http" | "streamable-http" | "streamable_http" | "streamablehttp"
147                | "http-stream" => McpTransportKind::Http,
148                other => anyhow::bail!(
149                    "unknown MCP transport '{other}' (expected one of: stdio, sse, http)"
150                ),
151            };
152            // Sanity-check that the required endpoint field is present.
153            match kind {
154                McpTransportKind::Stdio if self.command.is_none() => {
155                    anyhow::bail!("transport 'stdio' requires a 'command'")
156                }
157                McpTransportKind::Sse | McpTransportKind::Http if self.url.is_none() => {
158                    anyhow::bail!("transport '{}' requires a 'url'", kind.as_str())
159                }
160                _ => {}
161            }
162            return Ok(kind);
163        }
164
165        match (&self.command, &self.url) {
166            (Some(_), _) => Ok(McpTransportKind::Stdio),
167            (None, Some(_)) => Ok(McpTransportKind::Sse),
168            (None, None) => {
169                anyhow::bail!("MCP server config must have either 'command' or 'url'")
170            }
171        }
172    }
173
174    pub fn is_tool_enabled(&self, tool_name: &str) -> bool {
175        let allowed = if self.enabled_tools.is_empty() {
176            true
177        } else {
178            self.enabled_tools.iter().any(|t| t == tool_name)
179        };
180        if !allowed {
181            return false;
182        }
183        !self.disabled_tools.iter().any(|t| t == tool_name)
184    }
185}