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 #[serde(rename = "type", default)]
32 pub type_: StdioType,
33
34 pub command: String,
36
37 #[serde(default)]
39 pub args: Vec<String>,
40
41 #[serde(default)]
43 pub env: HashMap<String, String>,
44
45 #[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 #[serde(rename = "type")]
69 pub type_: RemoteType,
70
71 pub url: String,
73
74 #[serde(default)]
76 pub headers: HashMap<String, String>,
77
78 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub oauth: Option<McpOAuthConfig>,
81
82 #[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 #[serde(rename = "type")]
92 pub type_: InMemoryType,
93
94 #[serde(default)]
96 pub args: Vec<String>,
97
98 #[serde(default)]
100 pub input: Option<Value>,
101
102 #[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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
143 pub include: Vec<String>,
144
145 #[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 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#[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 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}