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 #[serde(rename = "type", default)]
33 pub type_: StdioType,
34
35 pub command: String,
37
38 #[serde(default)]
40 pub args: Vec<String>,
41
42 #[serde(default)]
44 pub env: HashMap<String, String>,
45
46 #[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 #[serde(rename = "type")]
70 pub type_: RemoteType,
71
72 pub url: String,
74
75 #[serde(default)]
77 pub headers: HashMap<String, String>,
78
79 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub oauth: Option<McpOAuthConfig>,
82
83 #[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 #[serde(rename = "type")]
93 pub type_: InMemoryType,
94
95 #[serde(default)]
97 pub args: Vec<String>,
98
99 #[serde(default)]
101 pub input: Option<Value>,
102
103 #[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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub include: Vec<String>,
145
146 #[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 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#[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}