Skip to main content

mcp_utils/client/
config.rs

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