Skip to main content

mcp_utils/client/
config.rs

1use aether_auth::OAuthClientRegistration;
2use reqwest::header::{AUTHORIZATION, HeaderName, HeaderValue, InvalidHeaderName, InvalidHeaderValue};
3use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use std::collections::{BTreeMap, HashMap};
8use std::num::NonZeroU16;
9use std::path::Path;
10use utils::matches_name_pattern;
11use utils::variables::{VarError, Vars};
12
13#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
14pub struct McpConfig {
15    #[serde(alias = "mcpServers")]
16    pub servers: BTreeMap<String, McpServerConfig>,
17}
18
19#[doc = include_str!("../docs/mcp_server_config.md")]
20#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)]
21#[serde(untagged)]
22pub enum McpServerConfig {
23    Stdio(StdioServerConfig),
24    Remote(RemoteServerConfig),
25    InMemory(InMemoryServerConfig),
26}
27
28#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)]
29#[serde(deny_unknown_fields)]
30pub struct StdioServerConfig {
31    /// Transport discriminant; always `stdio`.
32    #[serde(rename = "type", default)]
33    pub type_: StdioType,
34
35    /// Executable launched to run the MCP server over stdio.
36    pub command: String,
37
38    /// Command-line arguments passed to the executable.
39    #[serde(default)]
40    pub args: Vec<String>,
41
42    /// Environment variables set for the server process.
43    #[serde(default)]
44    pub env: HashMap<String, String>,
45
46    /// Controls which tools are deferred from the model-visible tool definitions.
47    #[serde(rename = "deferTools", alias = "proxy", default, skip_serializing_if = "ToolExposure::is_model_visible")]
48    pub defer_tools: ToolExposure,
49}
50
51pub const AETHER_OAUTH_CLIENT_METADATA_URL: &str = "https://aether-agent.io/oauth/client-metadata.json";
52pub const AETHER_OAUTH_CALLBACK_PORT: NonZeroU16 = NonZeroU16::new(3118).unwrap();
53
54#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq)]
55#[serde(rename_all = "camelCase", deny_unknown_fields)]
56pub struct McpOAuthConfig {
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub client_id: Option<String>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub client_metadata_url: Option<String>,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub callback_port: Option<NonZeroU16>,
63}
64
65#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)]
66#[serde(deny_unknown_fields)]
67pub struct RemoteServerConfig {
68    /// Transport discriminant; `http` (streamable HTTP) or `sse` (Server-Sent Events).
69    #[serde(rename = "type")]
70    pub type_: RemoteType,
71
72    /// Base URL of the remote MCP server.
73    pub url: String,
74
75    /// Extra HTTP headers sent with every request.
76    #[serde(default)]
77    pub headers: HashMap<String, String>,
78
79    /// OAuth settings for a pre-registered public client.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub oauth: Option<McpOAuthConfig>,
82
83    /// Controls which tools are deferred from the model-visible tool definitions.
84    #[serde(rename = "deferTools", alias = "proxy", default, skip_serializing_if = "ToolExposure::is_model_visible")]
85    pub defer_tools: ToolExposure,
86}
87
88#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, PartialEq)]
89#[serde(deny_unknown_fields)]
90pub struct InMemoryServerConfig {
91    /// Transport discriminant; always `in-memory`.
92    #[serde(rename = "type")]
93    pub type_: InMemoryType,
94
95    /// Arguments passed to the built-in (in-process) server.
96    #[serde(default)]
97    pub args: Vec<String>,
98
99    /// Optional JSON input passed to the built-in server at startup.
100    #[serde(default)]
101    pub input: Option<Value>,
102
103    /// Controls which tools are deferred from the model-visible tool definitions.
104    #[serde(rename = "deferTools", alias = "proxy", default, skip_serializing_if = "ToolExposure::is_model_visible")]
105    pub defer_tools: ToolExposure,
106}
107
108#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, JsonSchema, PartialEq)]
109pub enum StdioType {
110    #[default]
111    #[serde(rename = "stdio")]
112    Stdio,
113}
114
115#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
116pub enum RemoteType {
117    #[serde(rename = "http")]
118    Http,
119    #[serde(rename = "sse")]
120    Sse,
121}
122
123#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq)]
124pub enum InMemoryType {
125    #[serde(rename = "in-memory")]
126    InMemory,
127}
128
129/// Which of a server's tools are model-visible or deferred for progressive discovery.
130#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, JsonSchema)]
131#[serde(from = "ToolExposureConfig", into = "ToolExposureConfig")]
132#[schemars(with = "ToolExposureConfig")]
133pub enum ToolExposure {
134    #[default]
135    ModelVisible,
136    Deferred(DeferredToolRules),
137}
138
139#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
140#[serde(deny_unknown_fields)]
141pub struct DeferredToolRules {
142    /// Tool names to defer. An empty list includes every tool.
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub include: Vec<String>,
145
146    /// Tool names to keep model-visible. Exclude rules take precedence over include rules.
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub exclude: Vec<String>,
149}
150
151#[derive(Debug, Clone)]
152pub struct McpServer {
153    pub name: String,
154    pub transport: McpTransport,
155    pub tool_exposure: ToolExposure,
156}
157
158#[derive(Debug, Clone)]
159pub enum McpTransport {
160    Stdio { command: String, args: Vec<String>, env: HashMap<String, String> },
161    Http(McpHttpConfig),
162    InMemory { spec: InMemoryServerSpec },
163}
164
165#[derive(Clone, Debug)]
166pub struct InMemoryServerSpec {
167    pub factory: String,
168    pub args: Vec<String>,
169    pub input: Option<Value>,
170}
171
172#[derive(Debug, Clone)]
173pub struct McpHttpConfig {
174    pub transport: StreamableHttpClientTransportConfig,
175    pub oauth: Option<McpOAuthConfig>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct ResolvedOAuth {
180    pub client_registration: OAuthClientRegistration,
181    pub callback_port: NonZeroU16,
182}
183
184impl ResolvedOAuth {
185    pub fn redirect_uri(&self) -> String {
186        loopback_redirect_uri(self.callback_port.get())
187    }
188}
189
190pub fn loopback_redirect_uri(port: u16) -> String {
191    format!("http://localhost:{port}/")
192}
193
194impl McpHttpConfig {
195    pub(crate) fn has_explicit_authorization(&self) -> bool {
196        self.transport.auth_header.is_some() || self.transport.custom_headers.contains_key(&AUTHORIZATION)
197    }
198
199    pub fn resolved_oauth(&self) -> Option<ResolvedOAuth> {
200        if self.has_explicit_authorization() {
201            return None;
202        }
203        let oauth = self.oauth.as_ref();
204        let client_registration = match oauth {
205            Some(McpOAuthConfig { client_id: Some(client_id), .. }) => {
206                OAuthClientRegistration::PreRegistered(client_id.clone())
207            }
208            Some(McpOAuthConfig { client_metadata_url: Some(url), .. }) => {
209                OAuthClientRegistration::ClientMetadata(url.clone())
210            }
211            _ => OAuthClientRegistration::ClientMetadata(AETHER_OAUTH_CLIENT_METADATA_URL.to_string()),
212        };
213        Some(ResolvedOAuth {
214            client_registration,
215            callback_port: oauth.and_then(|oauth| oauth.callback_port).unwrap_or(AETHER_OAUTH_CALLBACK_PORT),
216        })
217    }
218}
219
220impl From<StreamableHttpClientTransportConfig> for McpHttpConfig {
221    fn from(transport: StreamableHttpClientTransportConfig) -> Self {
222        Self { transport, oauth: None }
223    }
224}
225
226impl ToolExposure {
227    pub fn deferred_all() -> Self {
228        Self::Deferred(DeferredToolRules::default())
229    }
230
231    pub fn is_model_visible(&self) -> bool {
232        matches!(self, Self::ModelVisible)
233    }
234
235    pub fn has_deferred_tools(&self) -> bool {
236        matches!(self, Self::Deferred(_))
237    }
238
239    pub fn is_model_visible_tool(&self, tool_name: &str) -> bool {
240        match self {
241            Self::ModelVisible => true,
242            Self::Deferred(rules) => !rules.matches(tool_name),
243        }
244    }
245
246    /// Defer every tool, preserving any existing rules.
247    pub fn defer_all_tools(&mut self) {
248        if self.is_model_visible() {
249            *self = Self::deferred_all();
250        }
251    }
252}
253
254impl DeferredToolRules {
255    pub fn new(include: &[&str], exclude: &[&str]) -> Self {
256        Self {
257            include: include.iter().map(ToString::to_string).collect(),
258            exclude: exclude.iter().map(ToString::to_string).collect(),
259        }
260    }
261
262    fn matches(&self, tool_name: &str) -> bool {
263        let included =
264            self.include.is_empty() || self.include.iter().any(|pattern| matches_name_pattern(pattern, tool_name));
265        let excluded = self.exclude.iter().any(|pattern| matches_name_pattern(pattern, tool_name));
266        included && !excluded
267    }
268}
269
270/// The `deferTools` config field's wire shape: a boolean or an include/exclude object.
271#[derive(Deserialize, Serialize, JsonSchema)]
272#[serde(untagged)]
273enum ToolExposureConfig {
274    Enabled(bool),
275    Rules(DeferredToolRules),
276}
277
278impl From<ToolExposureConfig> for ToolExposure {
279    fn from(repr: ToolExposureConfig) -> Self {
280        match repr {
281            ToolExposureConfig::Enabled(false) => Self::ModelVisible,
282            ToolExposureConfig::Enabled(true) => Self::deferred_all(),
283            ToolExposureConfig::Rules(rules) => Self::Deferred(rules),
284        }
285    }
286}
287
288impl From<ToolExposure> for ToolExposureConfig {
289    fn from(exposure: ToolExposure) -> Self {
290        match exposure {
291            ToolExposure::ModelVisible => Self::Enabled(false),
292            ToolExposure::Deferred(rules) if rules == DeferredToolRules::default() => Self::Enabled(true),
293            ToolExposure::Deferred(rules) => Self::Rules(rules),
294        }
295    }
296}
297
298impl McpServer {
299    pub fn new(name: impl Into<String>, transport: McpTransport, tool_exposure: ToolExposure) -> Self {
300        Self { name: name.into(), transport, tool_exposure }
301    }
302
303    pub fn with_exposure(mut self, exposure: ToolExposure) -> Self {
304        self.tool_exposure = exposure;
305        self
306    }
307
308    pub fn has_deferred_tools(&self) -> bool {
309        self.tool_exposure.has_deferred_tools()
310    }
311}
312
313#[derive(Debug, thiserror::Error)]
314pub enum ParseError {
315    #[error("Failed to read config file: {0}")]
316    IoError(#[from] std::io::Error),
317
318    #[error("Invalid JSON: {0}")]
319    JsonError(#[from] serde_json::Error),
320
321    #[error("Variable expansion failed: {0}")]
322    VarError(#[from] VarError),
323
324    #[error("Invalid HTTP header name {name:?}: {source}")]
325    InvalidHeaderName {
326        name: String,
327        #[source]
328        source: InvalidHeaderName,
329    },
330
331    #[error("Invalid HTTP header value for {name:?}: {source}")]
332    InvalidHeaderValue {
333        name: String,
334        #[source]
335        source: InvalidHeaderValue,
336    },
337}
338
339impl McpConfig {
340    pub fn new(servers: BTreeMap<String, McpServerConfig>) -> Self {
341        Self { servers }
342    }
343
344    pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self, ParseError> {
345        let content = std::fs::read_to_string(path)?;
346        Self::from_json(&content)
347    }
348
349    pub fn from_json_files<T: AsRef<Path>>(paths: &[T]) -> Result<Self, ParseError> {
350        let mut merged = BTreeMap::new();
351        for path in paths {
352            let raw = Self::from_json_file(path)?;
353            merged.extend(raw.servers);
354        }
355        Ok(Self::new(merged))
356    }
357
358    pub fn from_json(json: &str) -> Result<Self, ParseError> {
359        Ok(serde_json::from_str(json)?)
360    }
361
362    pub fn into_servers(self, vars: &Vars) -> Result<Vec<McpServer>, ParseError> {
363        self.into_servers_with_deferred_tools(vars, false)
364    }
365
366    pub fn into_servers_with_deferred_tools(
367        self,
368        vars: &Vars,
369        defer_all_tools: bool,
370    ) -> Result<Vec<McpServer>, ParseError> {
371        self.servers.into_iter().map(|(name, config)| config.into_server(name, vars, defer_all_tools)).collect()
372    }
373
374    pub fn defer_all_tools(&mut self) {
375        for server in self.servers.values_mut() {
376            server.defer_all_tools();
377        }
378    }
379}
380
381impl McpServerConfig {
382    pub fn defer_tools(&self) -> &ToolExposure {
383        match self {
384            McpServerConfig::Stdio(config) => &config.defer_tools,
385            McpServerConfig::Remote(config) => &config.defer_tools,
386            McpServerConfig::InMemory(config) => &config.defer_tools,
387        }
388    }
389
390    pub fn defer_all_tools(&mut self) {
391        let exposure = match self {
392            McpServerConfig::Stdio(config) => &mut config.defer_tools,
393            McpServerConfig::Remote(config) => &mut config.defer_tools,
394            McpServerConfig::InMemory(config) => &mut config.defer_tools,
395        };
396        exposure.defer_all_tools();
397    }
398
399    pub fn into_server(self, name: String, vars: &Vars, defer_all_tools: bool) -> Result<McpServer, ParseError> {
400        let mut exposure = self.defer_tools().clone();
401        if defer_all_tools {
402            exposure.defer_all_tools();
403        }
404        let transport = self.into_transport(name.clone(), vars)?;
405        Ok(McpServer { name, transport, tool_exposure: exposure })
406    }
407
408    fn into_transport(self, name: String, vars: &Vars) -> Result<McpTransport, ParseError> {
409        match self {
410            McpServerConfig::Stdio(StdioServerConfig { command, args, env, .. }) => Ok(McpTransport::Stdio {
411                command: vars.expand(&command)?,
412                args: args.into_iter().map(|a| vars.expand(&a)).collect::<Result<Vec<_>, _>>()?,
413                env: env
414                    .into_iter()
415                    .map(|(k, v)| Ok((k, vars.expand(&v)?)))
416                    .collect::<Result<HashMap<_, _>, VarError>>()?,
417            }),
418
419            McpServerConfig::Remote(RemoteServerConfig { url, headers, oauth, .. }) => {
420                let mut transport = StreamableHttpClientTransportConfig::with_uri(vars.expand(&url)?);
421                for (name, value) in headers {
422                    let header_name = name
423                        .parse::<HeaderName>()
424                        .map_err(|source| ParseError::InvalidHeaderName { name: name.clone(), source })?;
425
426                    let mut header_value = vars
427                        .expand(&value)?
428                        .parse::<HeaderValue>()
429                        .map_err(|source| ParseError::InvalidHeaderValue { name, source })?;
430
431                    if header_name == AUTHORIZATION {
432                        header_value.set_sensitive(true);
433                    }
434
435                    transport.custom_headers.insert(header_name, header_value);
436                }
437
438                let oauth = oauth
439                    .map(|oauth| -> Result<McpOAuthConfig, VarError> {
440                        Ok(McpOAuthConfig {
441                            client_id: oauth.client_id.map(|value| vars.expand(&value)).transpose()?,
442                            client_metadata_url: oauth
443                                .client_metadata_url
444                                .map(|value| vars.expand(&value))
445                                .transpose()?,
446                            callback_port: oauth.callback_port,
447                        })
448                    })
449                    .transpose()?;
450
451                Ok(McpTransport::Http(McpHttpConfig { transport, oauth }))
452            }
453
454            McpServerConfig::InMemory(InMemoryServerConfig { args, input, .. }) => {
455                let args = args.into_iter().map(|a| vars.expand(&a)).collect::<Result<Vec<_>, VarError>>()?;
456                Ok(McpTransport::InMemory { spec: InMemoryServerSpec { factory: name, args, input } })
457            }
458        }
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use std::fs;
466    use tempfile::tempdir;
467
468    fn write_config(dir: &Path, name: &str, json: &str) -> std::path::PathBuf {
469        let path = dir.join(name);
470        fs::write(&path, json).unwrap();
471        path
472    }
473
474    fn stdio_config(command: &str) -> String {
475        format!(r#"{{"servers": {{"coding": {{"type": "stdio", "command": "{command}"}}}}}}"#)
476    }
477
478    #[test]
479    fn from_json_accepts_mcp_servers_key() {
480        let config = McpConfig::from_json(r#"{"mcpServers": {"alpha": {"type": "stdio", "command": "a"}}}"#).unwrap();
481        assert_eq!(config.servers.len(), 1);
482        assert!(config.servers.contains_key("alpha"));
483    }
484
485    #[test]
486    fn from_json_defaults_missing_type_to_stdio() {
487        let config = McpConfig::from_json(
488            r#"{"mcpServers": {"devtools": {"command": "npx", "args": ["-y", "chrome-devtools-mcp"]}}}"#,
489        )
490        .unwrap();
491        match config.servers.get("devtools").unwrap() {
492            McpServerConfig::Stdio(StdioServerConfig { command, args, defer_tools: exposure, .. }) => {
493                assert_eq!(command, "npx");
494                assert_eq!(args, &["-y", "chrome-devtools-mcp"]);
495                assert!(exposure.is_model_visible());
496            }
497            other => panic!("expected Stdio server, got {other:?}"),
498        }
499    }
500
501    #[test]
502    fn from_json_accepts_legacy_server_proxy_true() {
503        let config =
504            McpConfig::from_json(r#"{"servers": {"playwright": {"type": "stdio", "command": "npx", "proxy": true}}}"#)
505                .unwrap();
506        assert!(config.servers.get("playwright").unwrap().defer_tools().has_deferred_tools());
507    }
508
509    #[test]
510    fn from_json_accepts_server_defer_tools_true() {
511        let config = McpConfig::from_json(
512            r#"{"servers": {"playwright": {"type": "stdio", "command": "npx", "deferTools": true}}}"#,
513        )
514        .unwrap();
515        assert!(config.servers.get("playwright").unwrap().defer_tools().has_deferred_tools());
516    }
517
518    #[test]
519    fn from_json_rejects_unknown_server_type() {
520        let result = McpConfig::from_json(r#"{"servers":{"tools":{"type":"deferTools","servers":{}}}}"#);
521        assert!(result.is_err());
522    }
523
524    #[test]
525    fn false_defer_tools_omits_during_serialization() {
526        let config =
527            McpConfig::from_json(r#"{"servers": {"coding": {"type": "stdio", "command": "a", "deferTools": false}}}"#)
528                .unwrap();
529        let serialized = serde_json::to_string(&config).unwrap();
530        assert!(!serialized.contains("deferTools"));
531    }
532
533    #[test]
534    fn true_defer_tools_serializes() {
535        let config =
536            McpConfig::from_json(r#"{"servers": {"coding": {"type": "stdio", "command": "a", "deferTools": true}}}"#)
537                .unwrap();
538        let serialized = serde_json::to_string(&config).unwrap();
539        assert!(serialized.contains("deferTools"));
540    }
541
542    #[test]
543    fn from_json_rejects_unknown_type() {
544        let result = McpConfig::from_json(r#"{"servers": {"bad": {"type": "htp", "url": "https://example.com"}}}"#);
545        assert!(result.is_err());
546    }
547
548    #[test]
549    fn from_json_files_empty_returns_empty_servers() {
550        let result = McpConfig::from_json_files::<&str>(&[]).unwrap();
551        assert!(result.servers.is_empty());
552    }
553
554    #[test]
555    fn from_json_files_single_file_matches_from_json_file() {
556        let dir = tempdir().unwrap();
557        let path = write_config(dir.path(), "a.json", &stdio_config("ls"));
558
559        let single = McpConfig::from_json_file(&path).unwrap();
560        let multi = McpConfig::from_json_files(&[&path]).unwrap();
561
562        assert_eq!(single.servers.len(), multi.servers.len());
563        assert!(multi.servers.contains_key("coding"));
564    }
565
566    #[test]
567    fn from_json_files_merges_disjoint_servers() {
568        let dir = tempdir().unwrap();
569        let a = write_config(dir.path(), "a.json", r#"{"servers": {"alpha": {"type": "stdio", "command": "a"}}}"#);
570        let b = write_config(dir.path(), "b.json", r#"{"servers": {"beta": {"type": "stdio", "command": "b"}}}"#);
571
572        let merged = McpConfig::from_json_files(&[a, b]).unwrap();
573        assert_eq!(merged.servers.len(), 2);
574        assert!(merged.servers.contains_key("alpha"));
575        assert!(merged.servers.contains_key("beta"));
576    }
577
578    #[test]
579    fn from_json_rejects_unknown_exposure_fields_for_all_transports() {
580        for server in [
581            r#"{"command":"x","direct_tool":["bash"]}"#,
582            r#"{"type":"http","url":"https://example.com","direct_tool":["bash"]}"#,
583            r#"{"type":"in-memory","direct_tool":["bash"]}"#,
584        ] {
585            let json = format!(r#"{{"servers":{{"bad":{server}}}}}"#);
586            assert!(McpConfig::from_json(&json).is_err(), "unknown field was accepted: {server}");
587        }
588    }
589
590    #[test]
591    fn from_json_files_last_file_wins_on_collision_including_exposure() {
592        let dir = tempdir().unwrap();
593        let a = write_config(
594            dir.path(),
595            "a.json",
596            r#"{"servers":{"coding":{"type":"stdio","command":"from_a","deferTools":{"exclude":["bash"]}}}}"#,
597        );
598        let b = write_config(dir.path(), "b.json", r#"{"servers":{"coding":{"type":"stdio","command":"from_b"}}}"#);
599
600        let merged_ab = McpConfig::from_json_files(&[&a, &b]).unwrap();
601        match merged_ab.servers.get("coding").unwrap() {
602            McpServerConfig::Stdio(StdioServerConfig { command, defer_tools: exposure, .. }) => {
603                assert_eq!(command, "from_b");
604                assert_eq!(exposure, &ToolExposure::ModelVisible);
605            }
606            other => panic!("expected Stdio, got {other:?}"),
607        }
608
609        let merged_ba = McpConfig::from_json_files(&[&b, &a]).unwrap();
610        match merged_ba.servers.get("coding").unwrap() {
611            McpServerConfig::Stdio(StdioServerConfig { command, defer_tools: exposure, .. }) => {
612                assert_eq!(command, "from_a");
613                assert_eq!(exposure, &ToolExposure::Deferred(DeferredToolRules::new(&[], &["bash"])));
614            }
615            other => panic!("expected Stdio, got {other:?}"),
616        }
617    }
618
619    #[test]
620    fn defer_all_tools_sets_every_server() {
621        let mut config = McpConfig::from_json(
622            r#"{"servers":{"a":{"type":"stdio","command":"a"},"b":{"type":"http","url":"https://example.com"}}}"#,
623        )
624        .unwrap();
625        config.defer_all_tools();
626        assert!(config.servers.values().all(|server| server.defer_tools().has_deferred_tools()));
627    }
628
629    #[test]
630    fn from_json_files_propagates_io_error_on_missing_file() {
631        let dir = tempdir().unwrap();
632        let missing = dir.path().join("does-not-exist.json");
633        let result = McpConfig::from_json_files(&[missing]);
634        assert!(matches!(result, Err(ParseError::IoError(_))));
635    }
636
637    #[test]
638    fn from_json_files_propagates_json_error_on_invalid_file() {
639        let dir = tempdir().unwrap();
640        let bad = write_config(dir.path(), "bad.json", "not valid json");
641        let result = McpConfig::from_json_files(&[bad]);
642        assert!(matches!(result, Err(ParseError::JsonError(_))));
643    }
644
645    #[tokio::test]
646    async fn into_servers_preserves_deferred_tool_flags() {
647        let json = r#"{
648            "servers": {
649                "github": {"type": "stdio", "command": "g"},
650                "playwright": {"type": "stdio", "command": "p", "deferTools": true}
651            }
652        }"#;
653        let config = McpConfig::from_json(json).unwrap();
654        let servers = config.into_servers(&Vars::new()).unwrap();
655
656        assert_eq!(servers.len(), 2);
657        assert!(!servers.iter().find(|s| s.name == "github").unwrap().has_deferred_tools());
658        assert!(servers.iter().find(|s| s.name == "playwright").unwrap().has_deferred_tools());
659    }
660
661    #[tokio::test]
662    async fn into_servers_with_deferred_tools_forces_deferred_tool_flags() {
663        let config =
664            McpConfig::from_json(r#"{"servers":{"github":{"type":"stdio","command":"g","deferTools":false}}}"#)
665                .unwrap();
666        let servers = config.into_servers_with_deferred_tools(&Vars::new(), true).unwrap();
667        assert!(servers[0].has_deferred_tools());
668    }
669
670    #[test]
671    fn defer_tools_accepts_boolean_or_rules_for_all_transport_shapes() {
672        let config = McpConfig::from_json(
673            r#"{"servers":{"all":{"command":"a","deferTools":true},"stdio":{"command":"x","deferTools":{"include":["lsp_*"],"exclude":["lsp_rename"]}},"http":{"type":"http","url":"https://example.com","deferTools":{"exclude":["bash"]}},"memory":{"type":"in-memory","deferTools":{"include":["read"]}}}}"#,
674        )
675        .unwrap();
676
677        assert_eq!(config.servers["all"].defer_tools(), &ToolExposure::deferred_all());
678        assert_eq!(
679            config.servers["stdio"].defer_tools(),
680            &ToolExposure::Deferred(DeferredToolRules::new(&["lsp_*"], &["lsp_rename"]))
681        );
682        assert_eq!(
683            config.servers["http"].defer_tools(),
684            &ToolExposure::Deferred(DeferredToolRules::new(&[], &["bash"]))
685        );
686        assert_eq!(
687            config.servers["memory"].defer_tools(),
688            &ToolExposure::Deferred(DeferredToolRules::new(&["read"], &[]))
689        );
690    }
691
692    #[test]
693    fn deferred_tool_rules_serialize_and_defaults_are_omitted() {
694        let config = McpConfig::from_json(
695            r#"{"servers":{"coding":{"command":"x","deferTools":{"exclude":["bash","lsp_*"]}},"direct":{"command":"y"},"full":{"command":"z","deferTools":true}}}"#,
696        )
697        .unwrap();
698        let value = serde_json::to_value(config).unwrap();
699
700        assert_eq!(value["servers"]["coding"]["deferTools"], serde_json::json!({"exclude":["bash", "lsp_*"]}));
701        assert!(value["servers"]["direct"].get("deferTools").is_none());
702        assert_eq!(value["servers"]["full"]["deferTools"], serde_json::json!(true));
703    }
704
705    #[test]
706    fn legacy_direct_tools_is_rejected() {
707        let result =
708            McpConfig::from_json(r#"{"servers":{"coding":{"command":"x","deferTools":true,"direct_tools":["bash"]}}}"#);
709        assert!(result.is_err());
710    }
711
712    #[tokio::test]
713    async fn deferred_tool_rules_partition_tools_with_exclude_winning() {
714        let config = McpConfig::from_json(
715            r#"{"servers":{"coding":{"command":"server","deferTools":{"include":["lsp_*","bash"],"exclude":["lsp_rename"]}}}}"#,
716        )
717        .unwrap();
718        let servers = config.into_servers(&Vars::new()).unwrap();
719        let exposure = &servers[0].tool_exposure;
720
721        assert!(!exposure.is_model_visible_tool("lsp_hover"));
722        assert!(exposure.is_model_visible_tool("lsp_rename"));
723        assert!(!exposure.is_model_visible_tool("bash"));
724        assert!(exposure.is_model_visible_tool("read_file"));
725    }
726
727    #[tokio::test]
728    async fn forced_deferral_preserves_per_server_rules() {
729        let config =
730            McpConfig::from_json(r#"{"servers":{"coding":{"command":"server","deferTools":{"exclude":["bash"]}}}}"#)
731                .unwrap();
732        let servers = config.into_servers_with_deferred_tools(&Vars::new(), true).unwrap();
733        assert!(servers[0].has_deferred_tools());
734        assert!(servers[0].tool_exposure.is_model_visible_tool("bash"));
735        assert!(!servers[0].tool_exposure.is_model_visible_tool("read_file"));
736    }
737
738    #[tokio::test]
739    async fn into_transport_expands_workspace_var_in_stdio_args() {
740        let config = McpConfig::from_json(
741            r#"{"servers":{"coding":{"type":"stdio","command":"server","args":["--root","${WORKSPACE}/src"]}}}"#,
742        )
743        .unwrap();
744        let vars = Vars::new().with("WORKSPACE", "/workspace");
745        let servers = config.into_servers(&vars).unwrap();
746
747        match &servers[0].transport {
748            McpTransport::Stdio { args, .. } => {
749                assert_eq!(args, &["--root", "/workspace/src"]);
750            }
751            other => panic!("expected Stdio transport, got {other:?}"),
752        }
753    }
754
755    #[tokio::test]
756    async fn into_transport_preserves_bearer_auth_header() -> Result<(), String> {
757        let config = McpConfig::from_json(
758            r#"{"servers":{"weather":{"type":"http","url":"http://127.0.0.1:9000/mcp","headers":{"Authorization":"Bearer secret-token"}}}}"#,
759        )
760        .map_err(|e| e.to_string())?;
761
762        let servers = config.into_servers(&Vars::new()).map_err(|e| e.to_string())?;
763        let McpTransport::Http(config) = &servers[0].transport else {
764            return Err(format!("expected Http transport, got {:?}", servers[0].transport));
765        };
766
767        assert!(config.transport.auth_header.is_none());
768        assert_eq!(config.transport.custom_headers[&AUTHORIZATION], "Bearer secret-token");
769        Ok(())
770    }
771
772    #[tokio::test]
773    async fn into_transport_keeps_non_bearer_auth_header_verbatim() -> Result<(), String> {
774        let config = McpConfig::from_json(
775            r#"{"servers":{"weather":{"type":"http","url":"http://127.0.0.1:9000/mcp","headers":{"Authorization":"Basic dXNlcjpwYXNz"}}}}"#,
776        )
777        .map_err(|e| e.to_string())?;
778        let servers = config.into_servers(&Vars::new()).map_err(|e| e.to_string())?;
779
780        let McpTransport::Http(config) = &servers[0].transport else {
781            return Err(format!("expected Http transport, got {:?}", servers[0].transport));
782        };
783        assert!(config.transport.auth_header.is_none());
784        assert_eq!(config.transport.custom_headers[&AUTHORIZATION], "Basic dXNlcjpwYXNz");
785        Ok(())
786    }
787
788    #[tokio::test]
789    async fn into_transport_expands_vars_in_auth_header() -> Result<(), String> {
790        let config = McpConfig::from_json(
791            r#"{"servers":{"weather":{"type":"http","url":"http://127.0.0.1:9000/mcp","headers":{"Authorization":"Bearer ${TOKEN}"}}}}"#,
792        )
793        .map_err(|e| e.to_string())?;
794        let vars = Vars::new().with("TOKEN", "expanded-token");
795        let servers = config.into_servers(&vars).map_err(|e| e.to_string())?;
796
797        let McpTransport::Http(config) = &servers[0].transport else {
798            return Err(format!("expected Http transport, got {:?}", servers[0].transport));
799        };
800        assert!(config.transport.auth_header.is_none());
801        assert_eq!(config.transport.custom_headers[&AUTHORIZATION], "Bearer expanded-token");
802        Ok(())
803    }
804}