1use std::collections::HashMap;
226use std::collections::HashSet;
227use std::path::Path;
228
229use anyhow::{Context, Result};
230use serde::{Deserialize, Serialize};
231
232#[derive(Debug, Deserialize, Serialize)]
234pub struct ProxyConfig {
235 pub proxy: ProxySettings,
237 #[serde(default)]
239 pub backends: Vec<BackendConfig>,
240 pub auth: Option<AuthConfig>,
242 #[serde(default)]
244 pub performance: PerformanceConfig,
245 #[serde(default)]
247 pub security: SecurityConfig,
248 #[serde(default)]
250 pub cache: CacheBackendConfig,
251 #[serde(default)]
253 pub observability: ObservabilityConfig,
254 #[serde(default)]
256 pub composite_tools: Vec<CompositeToolConfig>,
257 #[serde(skip)]
259 pub source_path: Option<std::path::PathBuf>,
260}
261
262#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
264#[serde(rename_all = "lowercase")]
265pub enum CompositeStrategy {
266 #[default]
268 Parallel,
269}
270
271#[derive(Debug, Clone, Deserialize, Serialize)]
287pub struct CompositeToolConfig {
288 pub name: String,
290 pub description: String,
292 pub tools: Vec<String>,
294 #[serde(default)]
296 pub strategy: CompositeStrategy,
297}
298
299#[derive(Debug, Deserialize, Serialize)]
301pub struct ProxySettings {
302 pub name: String,
304 #[serde(default = "default_version")]
306 pub version: String,
307 #[serde(default = "default_separator")]
309 pub separator: String,
310 pub listen: ListenConfig,
312 pub instructions: Option<String>,
314 #[serde(default = "default_shutdown_timeout")]
316 pub shutdown_timeout_seconds: u64,
317 #[serde(default)]
319 pub hot_reload: bool,
320 pub import_backends: Option<String>,
323 pub rate_limit: Option<GlobalRateLimitConfig>,
325 #[serde(default)]
329 pub tool_discovery: bool,
330 #[serde(default)]
338 pub tool_exposure: ToolExposure,
339}
340
341#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
358#[serde(rename_all = "lowercase")]
359pub enum ToolExposure {
360 #[default]
362 Direct,
363 Search,
366}
367
368#[derive(Debug, Deserialize, Serialize, Clone)]
370pub struct GlobalRateLimitConfig {
371 pub requests: usize,
373 #[serde(default = "default_rate_period")]
375 pub period_seconds: u64,
376}
377
378#[derive(Debug, Deserialize, Serialize)]
380pub struct ListenConfig {
381 #[serde(default = "default_host")]
383 pub host: String,
384 #[serde(default = "default_port")]
386 pub port: u16,
387}
388
389#[derive(Debug, Deserialize, Serialize)]
391pub struct BackendConfig {
392 pub name: String,
394 pub transport: TransportType,
396 pub command: Option<String>,
398 #[serde(default)]
400 pub args: Vec<String>,
401 pub url: Option<String>,
403 #[serde(default)]
405 pub env: HashMap<String, String>,
406 pub timeout: Option<TimeoutConfig>,
408 pub circuit_breaker: Option<CircuitBreakerConfig>,
410 pub rate_limit: Option<RateLimitConfig>,
412 pub concurrency: Option<ConcurrencyConfig>,
414 pub retry: Option<RetryConfig>,
416 pub outlier_detection: Option<OutlierDetectionConfig>,
418 pub hedging: Option<HedgingConfig>,
420 pub mirror_of: Option<String>,
423 #[serde(default = "default_mirror_percent")]
425 pub mirror_percent: u32,
426 pub cache: Option<BackendCacheConfig>,
428 pub bearer_token: Option<String>,
431 #[serde(default)]
434 pub forward_auth: bool,
435 #[serde(default)]
437 pub aliases: Vec<AliasConfig>,
438 #[serde(default)]
441 pub default_args: serde_json::Map<String, serde_json::Value>,
442 #[serde(default)]
444 pub inject_args: Vec<InjectArgsConfig>,
445 #[serde(default)]
447 pub param_overrides: Vec<ParamOverrideConfig>,
448 #[serde(default)]
450 pub expose_tools: Vec<String>,
451 #[serde(default)]
453 pub hide_tools: Vec<String>,
454 #[serde(default)]
456 pub expose_resources: Vec<String>,
457 #[serde(default)]
459 pub hide_resources: Vec<String>,
460 #[serde(default)]
462 pub expose_prompts: Vec<String>,
463 #[serde(default)]
465 pub hide_prompts: Vec<String>,
466 #[serde(default)]
468 pub hide_destructive: bool,
469 #[serde(default)]
471 pub read_only_only: bool,
472 pub failover_for: Option<String>,
476 #[serde(default)]
481 pub priority: u32,
482 pub canary_of: Option<String>,
486 #[serde(default = "default_weight")]
489 pub weight: u32,
490}
491
492#[derive(Debug, Deserialize, Serialize)]
494#[serde(rename_all = "lowercase")]
495pub enum TransportType {
496 Stdio,
498 Http,
500 Websocket,
502}
503
504#[derive(Debug, Deserialize, Serialize)]
506pub struct TimeoutConfig {
507 pub seconds: u64,
509}
510
511#[derive(Debug, Deserialize, Serialize)]
513pub struct CircuitBreakerConfig {
514 #[serde(default = "default_failure_rate")]
516 pub failure_rate_threshold: f64,
517 #[serde(default = "default_min_calls")]
519 pub minimum_calls: usize,
520 #[serde(default = "default_wait_duration")]
522 pub wait_duration_seconds: u64,
523 #[serde(default = "default_half_open_calls")]
525 pub permitted_calls_in_half_open: usize,
526}
527
528#[derive(Debug, Deserialize, Serialize)]
530pub struct RateLimitConfig {
531 pub requests: usize,
533 #[serde(default = "default_rate_period")]
535 pub period_seconds: u64,
536}
537
538#[derive(Debug, Deserialize, Serialize)]
540pub struct ConcurrencyConfig {
541 pub max_concurrent: usize,
543}
544
545#[derive(Debug, Clone, Deserialize, Serialize)]
547pub struct RetryConfig {
548 #[serde(default = "default_max_retries")]
550 pub max_retries: u32,
551 #[serde(default = "default_initial_backoff_ms")]
553 pub initial_backoff_ms: u64,
554 #[serde(default = "default_max_backoff_ms")]
556 pub max_backoff_ms: u64,
557 pub budget_percent: Option<f64>,
562 #[serde(default = "default_min_retries_per_sec")]
565 pub min_retries_per_sec: u32,
566}
567
568#[derive(Debug, Clone, Deserialize, Serialize)]
572pub struct OutlierDetectionConfig {
573 #[serde(default = "default_consecutive_errors")]
575 pub consecutive_errors: u32,
576 #[serde(default = "default_interval_seconds")]
578 pub interval_seconds: u64,
579 #[serde(default = "default_base_ejection_seconds")]
581 pub base_ejection_seconds: u64,
582 #[serde(default = "default_max_ejection_percent")]
584 pub max_ejection_percent: u32,
585}
586
587#[derive(Debug, Clone, Deserialize, Serialize)]
589pub struct InjectArgsConfig {
590 pub tool: String,
592 pub args: serde_json::Map<String, serde_json::Value>,
595 #[serde(default)]
597 pub overwrite: bool,
598}
599
600#[derive(Debug, Clone, Deserialize, Serialize)]
615pub struct ParamOverrideConfig {
616 pub tool: String,
618 #[serde(default)]
622 pub hide: Vec<String>,
623 #[serde(default)]
626 pub defaults: serde_json::Map<String, serde_json::Value>,
627 #[serde(default)]
631 pub rename: HashMap<String, String>,
632}
633
634#[derive(Debug, Clone, Deserialize, Serialize)]
640pub struct HedgingConfig {
641 #[serde(default = "default_hedge_delay_ms")]
644 pub delay_ms: u64,
645 #[serde(default = "default_max_hedges")]
647 pub max_hedges: usize,
648}
649
650#[derive(Debug, Deserialize, Serialize)]
652#[serde(tag = "type", rename_all = "lowercase")]
653pub enum AuthConfig {
654 Bearer {
656 #[serde(default)]
658 tokens: Vec<String>,
659 #[serde(default)]
661 scoped_tokens: Vec<BearerTokenConfig>,
662 },
663 Jwt {
665 issuer: String,
667 audience: String,
669 jwks_uri: String,
671 #[serde(default)]
673 roles: Vec<RoleConfig>,
674 role_mapping: Option<RoleMappingConfig>,
676 },
677 OAuth {
683 issuer: String,
686 audience: String,
688 #[serde(default)]
690 client_id: Option<String>,
691 #[serde(default)]
694 client_secret: Option<String>,
695 #[serde(default)]
697 token_validation: TokenValidationStrategy,
698 #[serde(default)]
700 jwks_uri: Option<String>,
701 #[serde(default)]
703 introspection_endpoint: Option<String>,
704 #[serde(default)]
711 required_scopes: Vec<String>,
712 #[serde(default)]
714 roles: Vec<RoleConfig>,
715 role_mapping: Option<RoleMappingConfig>,
717 },
718}
719
720#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
722#[serde(rename_all = "lowercase")]
723pub enum TokenValidationStrategy {
724 #[default]
726 Jwt,
727 Introspection,
730 Both,
733}
734
735#[derive(Debug, Clone, Deserialize, Serialize)]
758pub struct BearerTokenConfig {
759 pub token: String,
761 #[serde(default)]
764 pub allow_tools: Vec<String>,
765 #[serde(default)]
767 pub deny_tools: Vec<String>,
768}
769
770#[derive(Debug, Deserialize, Serialize)]
772pub struct RoleConfig {
773 pub name: String,
775 #[serde(default)]
777 pub allow_tools: Vec<String>,
778 #[serde(default)]
780 pub deny_tools: Vec<String>,
781}
782
783#[derive(Debug, Deserialize, Serialize)]
785pub struct RoleMappingConfig {
786 pub claim: String,
788 pub mapping: HashMap<String, String>,
790 #[serde(default)]
802 pub default_deny: bool,
803}
804
805#[derive(Debug, Deserialize, Serialize)]
807pub struct AliasConfig {
808 pub from: String,
810 pub to: String,
812}
813
814#[derive(Debug, Deserialize, Serialize)]
816pub struct BackendCacheConfig {
817 #[serde(default)]
819 pub resource_ttl_seconds: u64,
820 #[serde(default)]
822 pub tool_ttl_seconds: u64,
823 #[serde(default = "default_max_cache_entries")]
825 pub max_entries: u64,
826}
827
828#[derive(Debug, Deserialize, Serialize, Clone)]
842pub struct CacheBackendConfig {
843 #[serde(default = "default_cache_backend")]
845 pub backend: String,
846 pub url: Option<String>,
848 #[serde(default = "default_cache_prefix")]
850 pub prefix: String,
851}
852
853impl Default for CacheBackendConfig {
854 fn default() -> Self {
855 Self {
856 backend: default_cache_backend(),
857 url: None,
858 prefix: default_cache_prefix(),
859 }
860 }
861}
862
863fn default_cache_backend() -> String {
864 "memory".to_string()
865}
866
867fn default_cache_prefix() -> String {
868 "mcp-proxy:".to_string()
869}
870
871#[derive(Debug, Default, Deserialize, Serialize)]
873pub struct PerformanceConfig {
874 #[serde(default)]
876 pub coalesce_requests: bool,
877}
878
879#[derive(Debug, Default, Deserialize, Serialize)]
881pub struct SecurityConfig {
882 pub max_argument_size: Option<usize>,
884 pub admin_token: Option<String>,
892}
893
894#[derive(Debug, Default, Deserialize, Serialize)]
896pub struct ObservabilityConfig {
897 #[serde(default)]
899 pub audit: bool,
900 #[serde(default = "default_log_level")]
902 pub log_level: String,
903 #[serde(default)]
905 pub json_logs: bool,
906 #[serde(default)]
908 pub metrics: MetricsConfig,
909 #[serde(default)]
911 pub tracing: TracingConfig,
912 #[serde(default)]
914 pub access_log: AccessLogConfig,
915}
916
917#[derive(Debug, Default, Deserialize, Serialize)]
919pub struct AccessLogConfig {
920 #[serde(default)]
922 pub enabled: bool,
923}
924
925#[derive(Debug, Default, Deserialize, Serialize)]
927pub struct MetricsConfig {
928 #[serde(default)]
930 pub enabled: bool,
931}
932
933#[derive(Debug, Default, Deserialize, Serialize)]
935pub struct TracingConfig {
936 #[serde(default)]
938 pub enabled: bool,
939 #[serde(default = "default_otlp_endpoint")]
941 pub endpoint: String,
942 #[serde(default = "default_service_name")]
944 pub service_name: String,
945}
946
947fn default_version() -> String {
950 "0.1.0".to_string()
951}
952
953fn default_separator() -> String {
954 "/".to_string()
955}
956
957fn default_host() -> String {
958 "127.0.0.1".to_string()
959}
960
961fn default_port() -> u16 {
962 8080
963}
964
965fn default_log_level() -> String {
966 "info".to_string()
967}
968
969fn default_failure_rate() -> f64 {
970 0.5
971}
972
973fn default_min_calls() -> usize {
974 5
975}
976
977fn default_wait_duration() -> u64 {
978 30
979}
980
981fn default_half_open_calls() -> usize {
982 3
983}
984
985fn default_rate_period() -> u64 {
986 1
987}
988
989fn default_max_retries() -> u32 {
990 3
991}
992
993fn default_initial_backoff_ms() -> u64 {
994 100
995}
996
997fn default_max_backoff_ms() -> u64 {
998 5000
999}
1000
1001fn default_min_retries_per_sec() -> u32 {
1002 10
1003}
1004
1005fn default_consecutive_errors() -> u32 {
1006 5
1007}
1008
1009fn default_interval_seconds() -> u64 {
1010 10
1011}
1012
1013fn default_base_ejection_seconds() -> u64 {
1014 30
1015}
1016
1017fn default_max_ejection_percent() -> u32 {
1018 50
1019}
1020
1021fn default_hedge_delay_ms() -> u64 {
1022 200
1023}
1024
1025fn default_max_hedges() -> usize {
1026 1
1027}
1028
1029fn default_mirror_percent() -> u32 {
1030 100
1031}
1032
1033fn default_weight() -> u32 {
1034 100
1035}
1036
1037fn default_max_cache_entries() -> u64 {
1038 1000
1039}
1040
1041fn default_shutdown_timeout() -> u64 {
1042 30
1043}
1044
1045fn default_otlp_endpoint() -> String {
1046 "http://localhost:4317".to_string()
1047}
1048
1049fn default_service_name() -> String {
1050 "mcp-proxy".to_string()
1051}
1052
1053#[derive(Debug, Clone)]
1055pub struct BackendFilter {
1056 pub namespace: String,
1058 pub tool_filter: NameFilter,
1060 pub resource_filter: NameFilter,
1062 pub prompt_filter: NameFilter,
1064 pub hide_destructive: bool,
1066 pub read_only_only: bool,
1068}
1069
1070#[derive(Debug, Clone)]
1075pub enum CompiledPattern {
1076 Glob(String),
1078 Regex(regex::Regex),
1080}
1081
1082impl CompiledPattern {
1083 fn compile(pattern: &str) -> Result<Self> {
1086 if let Some(re_pat) = pattern.strip_prefix("re:") {
1087 let re = regex::Regex::new(re_pat)
1088 .with_context(|| format!("invalid regex in filter pattern: {pattern}"))?;
1089 Ok(Self::Regex(re))
1090 } else {
1091 Ok(Self::Glob(pattern.to_string()))
1092 }
1093 }
1094
1095 fn matches(&self, name: &str) -> bool {
1097 match self {
1098 Self::Glob(pat) => glob_match::glob_match(pat, name),
1099 Self::Regex(re) => re.is_match(name),
1100 }
1101 }
1102}
1103
1104#[derive(Debug, Clone)]
1112pub enum NameFilter {
1113 PassAll,
1115 AllowList(Vec<CompiledPattern>),
1117 DenyList(Vec<CompiledPattern>),
1119}
1120
1121impl NameFilter {
1122 pub fn allow_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1131 let compiled: Result<Vec<_>> = patterns
1132 .into_iter()
1133 .map(|p| CompiledPattern::compile(&p))
1134 .collect();
1135 Ok(Self::AllowList(compiled?))
1136 }
1137
1138 pub fn deny_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1147 let compiled: Result<Vec<_>> = patterns
1148 .into_iter()
1149 .map(|p| CompiledPattern::compile(&p))
1150 .collect();
1151 Ok(Self::DenyList(compiled?))
1152 }
1153
1154 pub fn allows(&self, name: &str) -> bool {
1186 match self {
1187 Self::PassAll => true,
1188 Self::AllowList(patterns) => patterns.iter().any(|p| p.matches(name)),
1189 Self::DenyList(patterns) => !patterns.iter().any(|p| p.matches(name)),
1190 }
1191 }
1192}
1193
1194impl BackendConfig {
1195 pub fn build_filter(&self, separator: &str) -> Result<Option<BackendFilter>> {
1202 if self.canary_of.is_some() || self.failover_for.is_some() {
1205 return Ok(Some(BackendFilter {
1206 namespace: format!("{}{}", self.name, separator),
1207 tool_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1208 resource_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1209 prompt_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1210 hide_destructive: false,
1211 read_only_only: false,
1212 }));
1213 }
1214
1215 let tool_filter = if !self.expose_tools.is_empty() {
1216 NameFilter::allow_list(self.expose_tools.iter().cloned())?
1217 } else if !self.hide_tools.is_empty() {
1218 NameFilter::deny_list(self.hide_tools.iter().cloned())?
1219 } else {
1220 NameFilter::PassAll
1221 };
1222
1223 let resource_filter = if !self.expose_resources.is_empty() {
1224 NameFilter::allow_list(self.expose_resources.iter().cloned())?
1225 } else if !self.hide_resources.is_empty() {
1226 NameFilter::deny_list(self.hide_resources.iter().cloned())?
1227 } else {
1228 NameFilter::PassAll
1229 };
1230
1231 let prompt_filter = if !self.expose_prompts.is_empty() {
1232 NameFilter::allow_list(self.expose_prompts.iter().cloned())?
1233 } else if !self.hide_prompts.is_empty() {
1234 NameFilter::deny_list(self.hide_prompts.iter().cloned())?
1235 } else {
1236 NameFilter::PassAll
1237 };
1238
1239 if matches!(tool_filter, NameFilter::PassAll)
1241 && matches!(resource_filter, NameFilter::PassAll)
1242 && matches!(prompt_filter, NameFilter::PassAll)
1243 && !self.hide_destructive
1244 && !self.read_only_only
1245 {
1246 return Ok(None);
1247 }
1248
1249 Ok(Some(BackendFilter {
1250 namespace: format!("{}{}", self.name, separator),
1251 tool_filter,
1252 resource_filter,
1253 prompt_filter,
1254 hide_destructive: self.hide_destructive,
1255 read_only_only: self.read_only_only,
1256 }))
1257 }
1258}
1259
1260impl ProxyConfig {
1261 pub fn load(path: &Path) -> Result<Self> {
1266 let content =
1267 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1268
1269 let mut config: Self = match path.extension().and_then(|e| e.to_str()) {
1270 #[cfg(feature = "yaml")]
1271 Some("yaml" | "yml") => serde_yaml::from_str(&content)
1272 .with_context(|| format!("parsing YAML {}", path.display()))?,
1273 #[cfg(not(feature = "yaml"))]
1274 Some("yaml" | "yml") => {
1275 anyhow::bail!(
1276 "YAML config requires the 'yaml' feature. Rebuild with: cargo install mcp-proxy --features yaml"
1277 );
1278 }
1279 _ => toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?,
1280 };
1281
1282 if let Some(ref mcp_json_path) = config.proxy.import_backends {
1284 let mcp_path = if std::path::Path::new(mcp_json_path).is_relative() {
1285 path.parent().unwrap_or(Path::new(".")).join(mcp_json_path)
1287 } else {
1288 std::path::PathBuf::from(mcp_json_path)
1289 };
1290
1291 let mcp_json = crate::mcp_json::McpJsonConfig::load(&mcp_path)
1292 .with_context(|| format!("importing backends from {}", mcp_path.display()))?;
1293
1294 let existing_names: HashSet<String> =
1295 config.backends.iter().map(|b| b.name.clone()).collect();
1296
1297 for backend in mcp_json.into_backends()? {
1298 if !existing_names.contains(&backend.name) {
1299 config.backends.push(backend);
1300 }
1301 }
1302 }
1303
1304 config.source_path = Some(path.to_path_buf());
1305 config.validate()?;
1306 Ok(config)
1307 }
1308
1309 pub fn from_mcp_json(path: &Path) -> Result<Self> {
1326 let mcp_json = crate::mcp_json::McpJsonConfig::load(path)?;
1327 let backends = mcp_json.into_backends()?;
1328
1329 let name = path
1331 .parent()
1332 .and_then(|p| p.file_name())
1333 .or_else(|| path.file_stem())
1334 .map(|s| s.to_string_lossy().into_owned())
1335 .unwrap_or_else(|| "mcp-proxy".to_string());
1336
1337 let config = Self {
1338 proxy: ProxySettings {
1339 name,
1340 version: default_version(),
1341 separator: default_separator(),
1342 listen: ListenConfig {
1343 host: default_host(),
1344 port: default_port(),
1345 },
1346 instructions: None,
1347 shutdown_timeout_seconds: default_shutdown_timeout(),
1348 hot_reload: false,
1349 import_backends: None,
1350 rate_limit: None,
1351 tool_discovery: false,
1352 tool_exposure: ToolExposure::default(),
1353 },
1354 backends,
1355 auth: None,
1356 performance: PerformanceConfig::default(),
1357 security: SecurityConfig::default(),
1358 cache: CacheBackendConfig::default(),
1359 observability: ObservabilityConfig::default(),
1360 composite_tools: Vec::new(),
1361 source_path: Some(path.to_path_buf()),
1362 };
1363
1364 config.validate()?;
1365 Ok(config)
1366 }
1367
1368 pub fn parse(toml: &str) -> Result<Self> {
1390 let config: Self = toml::from_str(toml).context("parsing config")?;
1391 config.validate()?;
1392 Ok(config)
1393 }
1394
1395 #[cfg(feature = "yaml")]
1417 pub fn parse_yaml(yaml: &str) -> Result<Self> {
1418 let config: Self = serde_yaml::from_str(yaml).context("parsing YAML config")?;
1419 config.validate()?;
1420 Ok(config)
1421 }
1422
1423 fn validate(&self) -> Result<()> {
1424 if self.backends.is_empty() {
1425 anyhow::bail!("at least one backend is required");
1426 }
1427
1428 match self.cache.backend.as_str() {
1430 "memory" => {}
1431 "redis" => {
1432 if self.cache.url.is_none() {
1433 anyhow::bail!(
1434 "cache.url is required when cache.backend = \"{}\"",
1435 self.cache.backend
1436 );
1437 }
1438 #[cfg(not(feature = "redis-cache"))]
1439 anyhow::bail!(
1440 "cache.backend = \"redis\" requires the 'redis-cache' feature. \
1441 Rebuild with: cargo install mcp-proxy --features redis-cache"
1442 );
1443 }
1444 "sqlite" => {
1445 if self.cache.url.is_none() {
1446 anyhow::bail!(
1447 "cache.url is required when cache.backend = \"{}\"",
1448 self.cache.backend
1449 );
1450 }
1451 #[cfg(not(feature = "sqlite-cache"))]
1452 anyhow::bail!(
1453 "cache.backend = \"sqlite\" requires the 'sqlite-cache' feature. \
1454 Rebuild with: cargo install mcp-proxy --features sqlite-cache"
1455 );
1456 }
1457 other => {
1458 anyhow::bail!(
1459 "unknown cache backend \"{}\", expected \"memory\", \"redis\", or \"sqlite\"",
1460 other
1461 );
1462 }
1463 }
1464
1465 if let Some(rl) = &self.proxy.rate_limit {
1467 if rl.requests == 0 {
1468 anyhow::bail!("proxy.rate_limit.requests must be > 0");
1469 }
1470 if rl.period_seconds == 0 {
1471 anyhow::bail!("proxy.rate_limit.period_seconds must be > 0");
1472 }
1473 }
1474
1475 if let Some(AuthConfig::Bearer {
1477 tokens,
1478 scoped_tokens,
1479 }) = &self.auth
1480 {
1481 if tokens.is_empty() && scoped_tokens.is_empty() {
1482 anyhow::bail!(
1483 "bearer auth requires at least one token in 'tokens' or 'scoped_tokens'"
1484 );
1485 }
1486 let mut seen_tokens = HashSet::new();
1488 for t in tokens {
1489 if !seen_tokens.insert(t.as_str()) {
1490 anyhow::bail!("duplicate bearer token in 'tokens'");
1491 }
1492 }
1493 for st in scoped_tokens {
1494 if !seen_tokens.insert(st.token.as_str()) {
1495 anyhow::bail!(
1496 "duplicate bearer token (appears in both 'tokens' and 'scoped_tokens' or duplicated within 'scoped_tokens')"
1497 );
1498 }
1499 if !st.allow_tools.is_empty() && !st.deny_tools.is_empty() {
1500 anyhow::bail!(
1501 "scoped_tokens: cannot specify both allow_tools and deny_tools for the same token"
1502 );
1503 }
1504 }
1505 }
1506
1507 if let Some(AuthConfig::OAuth {
1509 token_validation,
1510 client_id,
1511 client_secret,
1512 ..
1513 }) = &self.auth
1514 && matches!(
1515 token_validation,
1516 TokenValidationStrategy::Introspection | TokenValidationStrategy::Both
1517 )
1518 && (client_id.is_none() || client_secret.is_none())
1519 {
1520 anyhow::bail!("OAuth introspection requires both 'client_id' and 'client_secret'");
1521 }
1522
1523 if matches!(
1529 &self.auth,
1530 Some(AuthConfig::Jwt { .. }) | Some(AuthConfig::OAuth { .. })
1531 ) && self.security.admin_token.is_none()
1532 {
1533 anyhow::bail!(
1534 "security.admin_token is required when auth.type is 'jwt' or 'oauth': \
1535 the admin API has no token fallback for these auth types and would be \
1536 left unauthenticated. Set security.admin_token (supports ${{ENV_VAR}})."
1537 );
1538 }
1539
1540 let mut seen_names = HashSet::new();
1542 for backend in &self.backends {
1543 if !seen_names.insert(&backend.name) {
1544 anyhow::bail!("duplicate backend name '{}'", backend.name);
1545 }
1546 }
1547
1548 for backend in &self.backends {
1549 match backend.transport {
1550 TransportType::Stdio => {
1551 if backend.command.is_none() {
1552 anyhow::bail!(
1553 "backend '{}': stdio transport requires 'command'",
1554 backend.name
1555 );
1556 }
1557 }
1558 TransportType::Http => {
1559 if backend.url.is_none() {
1560 anyhow::bail!("backend '{}': http transport requires 'url'", backend.name);
1561 }
1562 }
1563 TransportType::Websocket => {
1564 if backend.url.is_none() {
1565 anyhow::bail!(
1566 "backend '{}': websocket transport requires 'url'",
1567 backend.name
1568 );
1569 }
1570 }
1571 }
1572
1573 if let Some(cb) = &backend.circuit_breaker
1574 && (cb.failure_rate_threshold <= 0.0 || cb.failure_rate_threshold > 1.0)
1575 {
1576 anyhow::bail!(
1577 "backend '{}': circuit_breaker.failure_rate_threshold must be in (0.0, 1.0]",
1578 backend.name
1579 );
1580 }
1581
1582 if let Some(rl) = &backend.rate_limit
1583 && rl.requests == 0
1584 {
1585 anyhow::bail!(
1586 "backend '{}': rate_limit.requests must be > 0",
1587 backend.name
1588 );
1589 }
1590
1591 if let Some(cc) = &backend.concurrency
1592 && cc.max_concurrent == 0
1593 {
1594 anyhow::bail!(
1595 "backend '{}': concurrency.max_concurrent must be > 0",
1596 backend.name
1597 );
1598 }
1599
1600 if !backend.expose_tools.is_empty() && !backend.hide_tools.is_empty() {
1601 anyhow::bail!(
1602 "backend '{}': cannot specify both expose_tools and hide_tools",
1603 backend.name
1604 );
1605 }
1606 if !backend.expose_resources.is_empty() && !backend.hide_resources.is_empty() {
1607 anyhow::bail!(
1608 "backend '{}': cannot specify both expose_resources and hide_resources",
1609 backend.name
1610 );
1611 }
1612 if !backend.expose_prompts.is_empty() && !backend.hide_prompts.is_empty() {
1613 anyhow::bail!(
1614 "backend '{}': cannot specify both expose_prompts and hide_prompts",
1615 backend.name
1616 );
1617 }
1618 }
1619
1620 let backend_names: HashSet<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
1622 for backend in &self.backends {
1623 if let Some(ref source) = backend.mirror_of {
1624 if !backend_names.contains(source.as_str()) {
1625 anyhow::bail!(
1626 "backend '{}': mirror_of references unknown backend '{}'",
1627 backend.name,
1628 source
1629 );
1630 }
1631 if source == &backend.name {
1632 anyhow::bail!(
1633 "backend '{}': mirror_of cannot reference itself",
1634 backend.name
1635 );
1636 }
1637 if backend.mirror_percent > 100 {
1638 anyhow::bail!(
1639 "backend '{}': mirror_percent must be 0-100, got {}",
1640 backend.name,
1641 backend.mirror_percent
1642 );
1643 }
1644 }
1645 }
1646
1647 for backend in &self.backends {
1649 if let Some(ref primary) = backend.failover_for {
1650 if !backend_names.contains(primary.as_str()) {
1651 anyhow::bail!(
1652 "backend '{}': failover_for references unknown backend '{}'",
1653 backend.name,
1654 primary
1655 );
1656 }
1657 if primary == &backend.name {
1658 anyhow::bail!(
1659 "backend '{}': failover_for cannot reference itself",
1660 backend.name
1661 );
1662 }
1663 }
1664 }
1665
1666 {
1668 let mut composite_names = HashSet::new();
1669 for ct in &self.composite_tools {
1670 if ct.name.is_empty() {
1671 anyhow::bail!("composite_tools: name must not be empty");
1672 }
1673 if ct.tools.is_empty() {
1674 anyhow::bail!(
1675 "composite_tools '{}': must reference at least one tool",
1676 ct.name
1677 );
1678 }
1679 if !composite_names.insert(&ct.name) {
1680 anyhow::bail!("duplicate composite_tools name '{}'", ct.name);
1681 }
1682 }
1683 }
1684
1685 for backend in &self.backends {
1687 if let Some(ref primary) = backend.canary_of {
1688 if !backend_names.contains(primary.as_str()) {
1689 anyhow::bail!(
1690 "backend '{}': canary_of references unknown backend '{}'",
1691 backend.name,
1692 primary
1693 );
1694 }
1695 if primary == &backend.name {
1696 anyhow::bail!(
1697 "backend '{}': canary_of cannot reference itself",
1698 backend.name
1699 );
1700 }
1701 if backend.weight == 0 || backend.weight > 100 {
1702 anyhow::bail!(
1703 "backend '{}': weight must be 1-100, got {}",
1704 backend.name,
1705 backend.weight
1706 );
1707 }
1708 }
1709 }
1710
1711 #[cfg(not(feature = "discovery"))]
1713 if self.proxy.tool_exposure == ToolExposure::Search {
1714 anyhow::bail!(
1715 "tool_exposure = \"search\" requires the 'discovery' feature. \
1716 Rebuild with: cargo install mcp-proxy --features discovery"
1717 );
1718 }
1719
1720 for backend in &self.backends {
1722 let mut seen_tools = HashSet::new();
1723 for po in &backend.param_overrides {
1724 if po.tool.is_empty() {
1725 anyhow::bail!(
1726 "backend '{}': param_overrides.tool must not be empty",
1727 backend.name
1728 );
1729 }
1730 if !seen_tools.insert(&po.tool) {
1731 anyhow::bail!(
1732 "backend '{}': duplicate param_overrides for tool '{}'",
1733 backend.name,
1734 po.tool
1735 );
1736 }
1737 for hidden in &po.hide {
1740 if po.rename.contains_key(hidden) {
1741 anyhow::bail!(
1742 "backend '{}': param_overrides for tool '{}': \
1743 parameter '{}' cannot be both hidden and renamed",
1744 backend.name,
1745 po.tool,
1746 hidden
1747 );
1748 }
1749 }
1750 let mut rename_targets = HashSet::new();
1752 for target in po.rename.values() {
1753 if !rename_targets.insert(target) {
1754 anyhow::bail!(
1755 "backend '{}': param_overrides for tool '{}': \
1756 duplicate rename target '{}'",
1757 backend.name,
1758 po.tool,
1759 target
1760 );
1761 }
1762 }
1763 }
1764 }
1765
1766 Ok(())
1767 }
1768
1769 pub fn resolve_env_vars(&mut self) {
1772 for backend in &mut self.backends {
1773 for value in backend.env.values_mut() {
1774 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1775 && let Ok(env_val) = std::env::var(var_name)
1776 {
1777 *value = env_val;
1778 }
1779 }
1780 if let Some(ref mut token) = backend.bearer_token
1781 && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1782 && let Ok(env_val) = std::env::var(var_name)
1783 {
1784 *token = env_val;
1785 }
1786 }
1787
1788 if let Some(AuthConfig::Bearer {
1790 tokens,
1791 scoped_tokens,
1792 }) = &mut self.auth
1793 {
1794 for token in tokens.iter_mut() {
1795 if let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1796 && let Ok(env_val) = std::env::var(var_name)
1797 {
1798 *token = env_val;
1799 }
1800 }
1801 for st in scoped_tokens.iter_mut() {
1802 if let Some(var_name) = st
1803 .token
1804 .strip_prefix("${")
1805 .and_then(|s| s.strip_suffix('}'))
1806 && let Ok(env_val) = std::env::var(var_name)
1807 {
1808 st.token = env_val;
1809 }
1810 }
1811 }
1812
1813 if let Some(ref mut token) = self.security.admin_token
1815 && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1816 && let Ok(env_val) = std::env::var(var_name)
1817 {
1818 *token = env_val;
1819 }
1820
1821 if let Some(AuthConfig::OAuth { client_secret, .. }) = &mut self.auth
1823 && let Some(secret) = client_secret
1824 && let Some(var_name) = secret.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1825 && let Ok(env_val) = std::env::var(var_name)
1826 {
1827 *secret = env_val;
1828 }
1829 }
1830
1831 pub fn check_env_vars(&self) -> Vec<String> {
1858 fn is_unset_env_ref(value: &str) -> Option<&str> {
1859 let var_name = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))?;
1860 if std::env::var(var_name).is_err() {
1861 Some(var_name)
1862 } else {
1863 None
1864 }
1865 }
1866
1867 let mut warnings = Vec::new();
1868
1869 for backend in &self.backends {
1870 if let Some(ref token) = backend.bearer_token
1872 && let Some(var) = is_unset_env_ref(token)
1873 {
1874 warnings.push(format!(
1875 "backend '{}': bearer_token references unset env var '{}'",
1876 backend.name, var
1877 ));
1878 }
1879 for (key, value) in &backend.env {
1881 if let Some(var) = is_unset_env_ref(value) {
1882 warnings.push(format!(
1883 "backend '{}': env.{} references unset env var '{}'",
1884 backend.name, key, var
1885 ));
1886 }
1887 }
1888 }
1889
1890 match &self.auth {
1891 Some(AuthConfig::Bearer {
1892 tokens,
1893 scoped_tokens,
1894 }) => {
1895 for (i, token) in tokens.iter().enumerate() {
1896 if let Some(var) = is_unset_env_ref(token) {
1897 warnings.push(format!(
1898 "auth.bearer: tokens[{}] references unset env var '{}'",
1899 i, var
1900 ));
1901 }
1902 }
1903 for (i, st) in scoped_tokens.iter().enumerate() {
1904 if let Some(var) = is_unset_env_ref(&st.token) {
1905 warnings.push(format!(
1906 "auth.bearer: scoped_tokens[{}] references unset env var '{}'",
1907 i, var
1908 ));
1909 }
1910 }
1911 }
1912 Some(AuthConfig::OAuth {
1913 client_secret: Some(secret),
1914 ..
1915 }) => {
1916 if let Some(var) = is_unset_env_ref(secret) {
1917 warnings.push(format!(
1918 "auth.oauth: client_secret references unset env var '{}'",
1919 var
1920 ));
1921 }
1922 }
1923 _ => {}
1924 }
1925
1926 warnings
1927 }
1928}
1929
1930#[cfg(test)]
1931mod tests {
1932 use super::*;
1933
1934 fn minimal_config() -> &'static str {
1935 r#"
1936 [proxy]
1937 name = "test"
1938 [proxy.listen]
1939
1940 [[backends]]
1941 name = "echo"
1942 transport = "stdio"
1943 command = "echo"
1944 "#
1945 }
1946
1947 #[test]
1948 fn test_parse_minimal_config() {
1949 let config = ProxyConfig::parse(minimal_config()).unwrap();
1950 assert_eq!(config.proxy.name, "test");
1951 assert_eq!(config.proxy.version, "0.1.0"); assert_eq!(config.proxy.separator, "/"); assert_eq!(config.proxy.listen.host, "127.0.0.1"); assert_eq!(config.proxy.listen.port, 8080); assert_eq!(config.proxy.shutdown_timeout_seconds, 30); assert!(!config.proxy.hot_reload); assert_eq!(config.backends.len(), 1);
1958 assert_eq!(config.backends[0].name, "echo");
1959 assert!(config.auth.is_none());
1960 assert!(!config.observability.audit);
1961 assert!(!config.observability.metrics.enabled);
1962 }
1963
1964 #[test]
1965 fn test_parse_full_config() {
1966 let toml = r#"
1967 [proxy]
1968 name = "full-gw"
1969 version = "2.0.0"
1970 separator = "."
1971 shutdown_timeout_seconds = 60
1972 hot_reload = true
1973 instructions = "A test proxy"
1974 [proxy.listen]
1975 host = "0.0.0.0"
1976 port = 9090
1977
1978 [[backends]]
1979 name = "files"
1980 transport = "stdio"
1981 command = "file-server"
1982 args = ["--root", "/tmp"]
1983 expose_tools = ["read_file"]
1984
1985 [backends.env]
1986 LOG_LEVEL = "debug"
1987
1988 [backends.timeout]
1989 seconds = 30
1990
1991 [backends.concurrency]
1992 max_concurrent = 5
1993
1994 [backends.rate_limit]
1995 requests = 100
1996 period_seconds = 10
1997
1998 [backends.circuit_breaker]
1999 failure_rate_threshold = 0.5
2000 minimum_calls = 10
2001 wait_duration_seconds = 60
2002 permitted_calls_in_half_open = 2
2003
2004 [backends.cache]
2005 resource_ttl_seconds = 300
2006 tool_ttl_seconds = 60
2007 max_entries = 500
2008
2009 [[backends.aliases]]
2010 from = "read_file"
2011 to = "read"
2012
2013 [[backends]]
2014 name = "remote"
2015 transport = "http"
2016 url = "http://localhost:3000"
2017
2018 [observability]
2019 audit = true
2020 log_level = "debug"
2021 json_logs = true
2022
2023 [observability.metrics]
2024 enabled = true
2025
2026 [observability.tracing]
2027 enabled = true
2028 endpoint = "http://jaeger:4317"
2029 service_name = "test-gw"
2030
2031 [performance]
2032 coalesce_requests = true
2033
2034 [security]
2035 max_argument_size = 1048576
2036 "#;
2037
2038 let config = ProxyConfig::parse(toml).unwrap();
2039 assert_eq!(config.proxy.name, "full-gw");
2040 assert_eq!(config.proxy.version, "2.0.0");
2041 assert_eq!(config.proxy.separator, ".");
2042 assert_eq!(config.proxy.shutdown_timeout_seconds, 60);
2043 assert!(config.proxy.hot_reload);
2044 assert_eq!(config.proxy.instructions.as_deref(), Some("A test proxy"));
2045 assert_eq!(config.proxy.listen.host, "0.0.0.0");
2046 assert_eq!(config.proxy.listen.port, 9090);
2047
2048 assert_eq!(config.backends.len(), 2);
2049
2050 let files = &config.backends[0];
2051 assert_eq!(files.command.as_deref(), Some("file-server"));
2052 assert_eq!(files.args, vec!["--root", "/tmp"]);
2053 assert_eq!(files.expose_tools, vec!["read_file"]);
2054 assert_eq!(files.env.get("LOG_LEVEL").unwrap(), "debug");
2055 assert_eq!(files.timeout.as_ref().unwrap().seconds, 30);
2056 assert_eq!(files.concurrency.as_ref().unwrap().max_concurrent, 5);
2057 assert_eq!(files.rate_limit.as_ref().unwrap().requests, 100);
2058 assert_eq!(files.cache.as_ref().unwrap().resource_ttl_seconds, 300);
2059 assert_eq!(files.cache.as_ref().unwrap().tool_ttl_seconds, 60);
2060 assert_eq!(files.cache.as_ref().unwrap().max_entries, 500);
2061 assert_eq!(files.aliases.len(), 1);
2062 assert_eq!(files.aliases[0].from, "read_file");
2063 assert_eq!(files.aliases[0].to, "read");
2064
2065 let cb = files.circuit_breaker.as_ref().unwrap();
2066 assert_eq!(cb.failure_rate_threshold, 0.5);
2067 assert_eq!(cb.minimum_calls, 10);
2068 assert_eq!(cb.wait_duration_seconds, 60);
2069 assert_eq!(cb.permitted_calls_in_half_open, 2);
2070
2071 let remote = &config.backends[1];
2072 assert_eq!(remote.url.as_deref(), Some("http://localhost:3000"));
2073
2074 assert!(config.observability.audit);
2075 assert_eq!(config.observability.log_level, "debug");
2076 assert!(config.observability.json_logs);
2077 assert!(config.observability.metrics.enabled);
2078 assert!(config.observability.tracing.enabled);
2079 assert_eq!(config.observability.tracing.endpoint, "http://jaeger:4317");
2080
2081 assert!(config.performance.coalesce_requests);
2082 assert_eq!(config.security.max_argument_size, Some(1048576));
2083 }
2084
2085 #[test]
2086 fn test_parse_bearer_auth() {
2087 let toml = r#"
2088 [proxy]
2089 name = "auth-gw"
2090 [proxy.listen]
2091
2092 [[backends]]
2093 name = "echo"
2094 transport = "stdio"
2095 command = "echo"
2096
2097 [auth]
2098 type = "bearer"
2099 tokens = ["token-1", "token-2"]
2100 "#;
2101
2102 let config = ProxyConfig::parse(toml).unwrap();
2103 match &config.auth {
2104 Some(AuthConfig::Bearer { tokens, .. }) => {
2105 assert_eq!(tokens, &["token-1", "token-2"]);
2106 }
2107 other => panic!("expected Bearer auth, got: {:?}", other),
2108 }
2109 }
2110
2111 #[test]
2112 fn test_parse_jwt_auth_with_rbac() {
2113 let toml = r#"
2114 [proxy]
2115 name = "jwt-gw"
2116 [proxy.listen]
2117
2118 [[backends]]
2119 name = "echo"
2120 transport = "stdio"
2121 command = "echo"
2122
2123 [auth]
2124 type = "jwt"
2125 issuer = "https://auth.example.com"
2126 audience = "mcp-proxy"
2127 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2128
2129 [[auth.roles]]
2130 name = "reader"
2131 allow_tools = ["echo/read"]
2132
2133 [[auth.roles]]
2134 name = "admin"
2135
2136 [auth.role_mapping]
2137 claim = "scope"
2138 mapping = { "mcp:read" = "reader", "mcp:admin" = "admin" }
2139
2140 [security]
2141 admin_token = "admin-secret"
2142 "#;
2143
2144 let config = ProxyConfig::parse(toml).unwrap();
2145 match &config.auth {
2146 Some(AuthConfig::Jwt {
2147 issuer,
2148 audience,
2149 jwks_uri,
2150 roles,
2151 role_mapping,
2152 }) => {
2153 assert_eq!(issuer, "https://auth.example.com");
2154 assert_eq!(audience, "mcp-proxy");
2155 assert_eq!(jwks_uri, "https://auth.example.com/.well-known/jwks.json");
2156 assert_eq!(roles.len(), 2);
2157 assert_eq!(roles[0].name, "reader");
2158 assert_eq!(roles[0].allow_tools, vec!["echo/read"]);
2159 let mapping = role_mapping.as_ref().unwrap();
2160 assert_eq!(mapping.claim, "scope");
2161 assert_eq!(mapping.mapping.get("mcp:read").unwrap(), "reader");
2162 }
2163 other => panic!("expected Jwt auth, got: {:?}", other),
2164 }
2165 }
2166
2167 #[test]
2172 fn test_reject_no_backends() {
2173 let toml = r#"
2174 [proxy]
2175 name = "empty"
2176 [proxy.listen]
2177 "#;
2178
2179 let err = ProxyConfig::parse(toml).unwrap_err();
2180 assert!(
2181 format!("{err}").contains("at least one backend"),
2182 "unexpected error: {err}"
2183 );
2184 }
2185
2186 #[test]
2187 fn test_reject_stdio_without_command() {
2188 let toml = r#"
2189 [proxy]
2190 name = "bad"
2191 [proxy.listen]
2192
2193 [[backends]]
2194 name = "broken"
2195 transport = "stdio"
2196 "#;
2197
2198 let err = ProxyConfig::parse(toml).unwrap_err();
2199 assert!(
2200 format!("{err}").contains("stdio transport requires 'command'"),
2201 "unexpected error: {err}"
2202 );
2203 }
2204
2205 #[test]
2206 fn test_reject_http_without_url() {
2207 let toml = r#"
2208 [proxy]
2209 name = "bad"
2210 [proxy.listen]
2211
2212 [[backends]]
2213 name = "broken"
2214 transport = "http"
2215 "#;
2216
2217 let err = ProxyConfig::parse(toml).unwrap_err();
2218 assert!(
2219 format!("{err}").contains("http transport requires 'url'"),
2220 "unexpected error: {err}"
2221 );
2222 }
2223
2224 #[test]
2225 fn test_reject_invalid_circuit_breaker_threshold() {
2226 let toml = r#"
2227 [proxy]
2228 name = "bad"
2229 [proxy.listen]
2230
2231 [[backends]]
2232 name = "svc"
2233 transport = "stdio"
2234 command = "echo"
2235
2236 [backends.circuit_breaker]
2237 failure_rate_threshold = 1.5
2238 "#;
2239
2240 let err = ProxyConfig::parse(toml).unwrap_err();
2241 assert!(
2242 format!("{err}").contains("failure_rate_threshold must be in (0.0, 1.0]"),
2243 "unexpected error: {err}"
2244 );
2245 }
2246
2247 #[test]
2248 fn test_reject_zero_rate_limit() {
2249 let toml = r#"
2250 [proxy]
2251 name = "bad"
2252 [proxy.listen]
2253
2254 [[backends]]
2255 name = "svc"
2256 transport = "stdio"
2257 command = "echo"
2258
2259 [backends.rate_limit]
2260 requests = 0
2261 "#;
2262
2263 let err = ProxyConfig::parse(toml).unwrap_err();
2264 assert!(
2265 format!("{err}").contains("rate_limit.requests must be > 0"),
2266 "unexpected error: {err}"
2267 );
2268 }
2269
2270 #[test]
2271 fn test_reject_zero_concurrency() {
2272 let toml = r#"
2273 [proxy]
2274 name = "bad"
2275 [proxy.listen]
2276
2277 [[backends]]
2278 name = "svc"
2279 transport = "stdio"
2280 command = "echo"
2281
2282 [backends.concurrency]
2283 max_concurrent = 0
2284 "#;
2285
2286 let err = ProxyConfig::parse(toml).unwrap_err();
2287 assert!(
2288 format!("{err}").contains("concurrency.max_concurrent must be > 0"),
2289 "unexpected error: {err}"
2290 );
2291 }
2292
2293 #[test]
2294 fn test_reject_expose_and_hide_tools() {
2295 let toml = r#"
2296 [proxy]
2297 name = "bad"
2298 [proxy.listen]
2299
2300 [[backends]]
2301 name = "svc"
2302 transport = "stdio"
2303 command = "echo"
2304 expose_tools = ["read"]
2305 hide_tools = ["write"]
2306 "#;
2307
2308 let err = ProxyConfig::parse(toml).unwrap_err();
2309 assert!(
2310 format!("{err}").contains("cannot specify both expose_tools and hide_tools"),
2311 "unexpected error: {err}"
2312 );
2313 }
2314
2315 #[test]
2316 fn test_reject_expose_and_hide_resources() {
2317 let toml = r#"
2318 [proxy]
2319 name = "bad"
2320 [proxy.listen]
2321
2322 [[backends]]
2323 name = "svc"
2324 transport = "stdio"
2325 command = "echo"
2326 expose_resources = ["file:///a"]
2327 hide_resources = ["file:///b"]
2328 "#;
2329
2330 let err = ProxyConfig::parse(toml).unwrap_err();
2331 assert!(
2332 format!("{err}").contains("cannot specify both expose_resources and hide_resources"),
2333 "unexpected error: {err}"
2334 );
2335 }
2336
2337 #[test]
2338 fn test_reject_expose_and_hide_prompts() {
2339 let toml = r#"
2340 [proxy]
2341 name = "bad"
2342 [proxy.listen]
2343
2344 [[backends]]
2345 name = "svc"
2346 transport = "stdio"
2347 command = "echo"
2348 expose_prompts = ["help"]
2349 hide_prompts = ["admin"]
2350 "#;
2351
2352 let err = ProxyConfig::parse(toml).unwrap_err();
2353 assert!(
2354 format!("{err}").contains("cannot specify both expose_prompts and hide_prompts"),
2355 "unexpected error: {err}"
2356 );
2357 }
2358
2359 #[test]
2364 fn test_resolve_env_vars() {
2365 unsafe { std::env::set_var("MCP_GW_TEST_TOKEN", "secret-123") };
2367
2368 let toml = r#"
2369 [proxy]
2370 name = "env-test"
2371 [proxy.listen]
2372
2373 [[backends]]
2374 name = "svc"
2375 transport = "stdio"
2376 command = "echo"
2377
2378 [backends.env]
2379 API_TOKEN = "${MCP_GW_TEST_TOKEN}"
2380 STATIC_VAL = "unchanged"
2381 "#;
2382
2383 let mut config = ProxyConfig::parse(toml).unwrap();
2384 config.resolve_env_vars();
2385
2386 assert_eq!(
2387 config.backends[0].env.get("API_TOKEN").unwrap(),
2388 "secret-123"
2389 );
2390 assert_eq!(
2391 config.backends[0].env.get("STATIC_VAL").unwrap(),
2392 "unchanged"
2393 );
2394
2395 unsafe { std::env::remove_var("MCP_GW_TEST_TOKEN") };
2397 }
2398
2399 #[test]
2400 fn test_parse_bearer_token_and_forward_auth() {
2401 let toml = r#"
2402 [proxy]
2403 name = "token-gw"
2404 [proxy.listen]
2405
2406 [[backends]]
2407 name = "github"
2408 transport = "http"
2409 url = "http://localhost:3000"
2410 bearer_token = "ghp_abc123"
2411 forward_auth = true
2412
2413 [[backends]]
2414 name = "db"
2415 transport = "http"
2416 url = "http://localhost:5432"
2417 "#;
2418
2419 let config = ProxyConfig::parse(toml).unwrap();
2420 assert_eq!(
2421 config.backends[0].bearer_token.as_deref(),
2422 Some("ghp_abc123")
2423 );
2424 assert!(config.backends[0].forward_auth);
2425 assert!(config.backends[1].bearer_token.is_none());
2426 assert!(!config.backends[1].forward_auth);
2427 }
2428
2429 #[test]
2430 fn test_resolve_bearer_token_env_var() {
2431 unsafe { std::env::set_var("MCP_GW_TEST_BEARER", "resolved-token") };
2432
2433 let toml = r#"
2434 [proxy]
2435 name = "env-token"
2436 [proxy.listen]
2437
2438 [[backends]]
2439 name = "api"
2440 transport = "http"
2441 url = "http://localhost:3000"
2442 bearer_token = "${MCP_GW_TEST_BEARER}"
2443 "#;
2444
2445 let mut config = ProxyConfig::parse(toml).unwrap();
2446 config.resolve_env_vars();
2447
2448 assert_eq!(
2449 config.backends[0].bearer_token.as_deref(),
2450 Some("resolved-token")
2451 );
2452
2453 unsafe { std::env::remove_var("MCP_GW_TEST_BEARER") };
2454 }
2455
2456 #[test]
2457 fn test_parse_outlier_detection() {
2458 let toml = r#"
2459 [proxy]
2460 name = "od-gw"
2461 [proxy.listen]
2462
2463 [[backends]]
2464 name = "flaky"
2465 transport = "http"
2466 url = "http://localhost:8080"
2467
2468 [backends.outlier_detection]
2469 consecutive_errors = 3
2470 interval_seconds = 5
2471 base_ejection_seconds = 60
2472 max_ejection_percent = 25
2473 "#;
2474
2475 let config = ProxyConfig::parse(toml).unwrap();
2476 let od = config.backends[0]
2477 .outlier_detection
2478 .as_ref()
2479 .expect("should have outlier_detection");
2480 assert_eq!(od.consecutive_errors, 3);
2481 assert_eq!(od.interval_seconds, 5);
2482 assert_eq!(od.base_ejection_seconds, 60);
2483 assert_eq!(od.max_ejection_percent, 25);
2484 }
2485
2486 #[test]
2487 fn test_parse_outlier_detection_defaults() {
2488 let toml = r#"
2489 [proxy]
2490 name = "od-gw"
2491 [proxy.listen]
2492
2493 [[backends]]
2494 name = "flaky"
2495 transport = "http"
2496 url = "http://localhost:8080"
2497
2498 [backends.outlier_detection]
2499 "#;
2500
2501 let config = ProxyConfig::parse(toml).unwrap();
2502 let od = config.backends[0]
2503 .outlier_detection
2504 .as_ref()
2505 .expect("should have outlier_detection");
2506 assert_eq!(od.consecutive_errors, 5);
2507 assert_eq!(od.interval_seconds, 10);
2508 assert_eq!(od.base_ejection_seconds, 30);
2509 assert_eq!(od.max_ejection_percent, 50);
2510 }
2511
2512 #[test]
2513 fn test_parse_mirror_config() {
2514 let toml = r#"
2515 [proxy]
2516 name = "mirror-gw"
2517 [proxy.listen]
2518
2519 [[backends]]
2520 name = "api"
2521 transport = "http"
2522 url = "http://localhost:8080"
2523
2524 [[backends]]
2525 name = "api-v2"
2526 transport = "http"
2527 url = "http://localhost:8081"
2528 mirror_of = "api"
2529 mirror_percent = 10
2530 "#;
2531
2532 let config = ProxyConfig::parse(toml).unwrap();
2533 assert!(config.backends[0].mirror_of.is_none());
2534 assert_eq!(config.backends[1].mirror_of.as_deref(), Some("api"));
2535 assert_eq!(config.backends[1].mirror_percent, 10);
2536 }
2537
2538 #[test]
2539 fn test_mirror_percent_defaults_to_100() {
2540 let toml = r#"
2541 [proxy]
2542 name = "mirror-gw"
2543 [proxy.listen]
2544
2545 [[backends]]
2546 name = "api"
2547 transport = "http"
2548 url = "http://localhost:8080"
2549
2550 [[backends]]
2551 name = "api-v2"
2552 transport = "http"
2553 url = "http://localhost:8081"
2554 mirror_of = "api"
2555 "#;
2556
2557 let config = ProxyConfig::parse(toml).unwrap();
2558 assert_eq!(config.backends[1].mirror_percent, 100);
2559 }
2560
2561 #[test]
2562 fn test_reject_mirror_unknown_backend() {
2563 let toml = r#"
2564 [proxy]
2565 name = "bad"
2566 [proxy.listen]
2567
2568 [[backends]]
2569 name = "api-v2"
2570 transport = "http"
2571 url = "http://localhost:8081"
2572 mirror_of = "nonexistent"
2573 "#;
2574
2575 let err = ProxyConfig::parse(toml).unwrap_err();
2576 assert!(
2577 format!("{err}").contains("mirror_of references unknown backend"),
2578 "unexpected error: {err}"
2579 );
2580 }
2581
2582 #[test]
2583 fn test_reject_mirror_percent_over_100() {
2584 let toml = r#"
2585 [proxy]
2586 name = "bad"
2587 [proxy.listen]
2588
2589 [[backends]]
2590 name = "primary"
2591 transport = "stdio"
2592 command = "echo"
2593
2594 [[backends]]
2595 name = "mirror"
2596 transport = "stdio"
2597 command = "echo"
2598 mirror_of = "primary"
2599 mirror_percent = 101
2600 "#;
2601 let err = ProxyConfig::parse(toml).unwrap_err();
2602 assert!(
2603 format!("{err}").contains("mirror_percent must be 0-100"),
2604 "unexpected error: {err}"
2605 );
2606 }
2607
2608 #[test]
2609 fn test_reject_canary_weight_over_100() {
2610 let toml = r#"
2611 [proxy]
2612 name = "bad"
2613 [proxy.listen]
2614
2615 [[backends]]
2616 name = "primary"
2617 transport = "stdio"
2618 command = "echo"
2619
2620 [[backends]]
2621 name = "canary"
2622 transport = "stdio"
2623 command = "echo"
2624 canary_of = "primary"
2625 weight = 101
2626 "#;
2627 let err = ProxyConfig::parse(toml).unwrap_err();
2628 assert!(
2629 format!("{err}").contains("weight must be 1-100"),
2630 "unexpected error: {err}"
2631 );
2632 }
2633
2634 #[test]
2635 fn test_reject_mirror_self() {
2636 let toml = r#"
2637 [proxy]
2638 name = "bad"
2639 [proxy.listen]
2640
2641 [[backends]]
2642 name = "api"
2643 transport = "http"
2644 url = "http://localhost:8080"
2645 mirror_of = "api"
2646 "#;
2647
2648 let err = ProxyConfig::parse(toml).unwrap_err();
2649 assert!(
2650 format!("{err}").contains("mirror_of cannot reference itself"),
2651 "unexpected error: {err}"
2652 );
2653 }
2654
2655 #[test]
2656 fn test_parse_hedging_config() {
2657 let toml = r#"
2658 [proxy]
2659 name = "hedge-gw"
2660 [proxy.listen]
2661
2662 [[backends]]
2663 name = "api"
2664 transport = "http"
2665 url = "http://localhost:8080"
2666
2667 [backends.hedging]
2668 delay_ms = 150
2669 max_hedges = 2
2670 "#;
2671
2672 let config = ProxyConfig::parse(toml).unwrap();
2673 let hedge = config.backends[0]
2674 .hedging
2675 .as_ref()
2676 .expect("should have hedging");
2677 assert_eq!(hedge.delay_ms, 150);
2678 assert_eq!(hedge.max_hedges, 2);
2679 }
2680
2681 #[test]
2682 fn test_parse_hedging_defaults() {
2683 let toml = r#"
2684 [proxy]
2685 name = "hedge-gw"
2686 [proxy.listen]
2687
2688 [[backends]]
2689 name = "api"
2690 transport = "http"
2691 url = "http://localhost:8080"
2692
2693 [backends.hedging]
2694 "#;
2695
2696 let config = ProxyConfig::parse(toml).unwrap();
2697 let hedge = config.backends[0]
2698 .hedging
2699 .as_ref()
2700 .expect("should have hedging");
2701 assert_eq!(hedge.delay_ms, 200);
2702 assert_eq!(hedge.max_hedges, 1);
2703 }
2704
2705 #[test]
2710 fn test_build_filter_allowlist() {
2711 let toml = r#"
2712 [proxy]
2713 name = "filter"
2714 [proxy.listen]
2715
2716 [[backends]]
2717 name = "svc"
2718 transport = "stdio"
2719 command = "echo"
2720 expose_tools = ["read", "list"]
2721 "#;
2722
2723 let config = ProxyConfig::parse(toml).unwrap();
2724 let filter = config.backends[0]
2725 .build_filter(&config.proxy.separator)
2726 .unwrap()
2727 .expect("should have filter");
2728 assert_eq!(filter.namespace, "svc/");
2729 assert!(filter.tool_filter.allows("read"));
2730 assert!(filter.tool_filter.allows("list"));
2731 assert!(!filter.tool_filter.allows("delete"));
2732 }
2733
2734 #[test]
2735 fn test_build_filter_denylist() {
2736 let toml = r#"
2737 [proxy]
2738 name = "filter"
2739 [proxy.listen]
2740
2741 [[backends]]
2742 name = "svc"
2743 transport = "stdio"
2744 command = "echo"
2745 hide_tools = ["delete", "write"]
2746 "#;
2747
2748 let config = ProxyConfig::parse(toml).unwrap();
2749 let filter = config.backends[0]
2750 .build_filter(&config.proxy.separator)
2751 .unwrap()
2752 .expect("should have filter");
2753 assert!(filter.tool_filter.allows("read"));
2754 assert!(!filter.tool_filter.allows("delete"));
2755 assert!(!filter.tool_filter.allows("write"));
2756 }
2757
2758 #[test]
2759 fn test_parse_inject_args() {
2760 let toml = r#"
2761 [proxy]
2762 name = "inject-gw"
2763 [proxy.listen]
2764
2765 [[backends]]
2766 name = "db"
2767 transport = "http"
2768 url = "http://localhost:8080"
2769
2770 [backends.default_args]
2771 timeout = 30
2772
2773 [[backends.inject_args]]
2774 tool = "query"
2775 args = { read_only = true, max_rows = 1000 }
2776
2777 [[backends.inject_args]]
2778 tool = "dangerous_op"
2779 args = { dry_run = true }
2780 overwrite = true
2781 "#;
2782
2783 let config = ProxyConfig::parse(toml).unwrap();
2784 let backend = &config.backends[0];
2785
2786 assert_eq!(backend.default_args.len(), 1);
2787 assert_eq!(backend.default_args["timeout"], 30);
2788
2789 assert_eq!(backend.inject_args.len(), 2);
2790 assert_eq!(backend.inject_args[0].tool, "query");
2791 assert_eq!(backend.inject_args[0].args["read_only"], true);
2792 assert_eq!(backend.inject_args[0].args["max_rows"], 1000);
2793 assert!(!backend.inject_args[0].overwrite);
2794
2795 assert_eq!(backend.inject_args[1].tool, "dangerous_op");
2796 assert_eq!(backend.inject_args[1].args["dry_run"], true);
2797 assert!(backend.inject_args[1].overwrite);
2798 }
2799
2800 #[test]
2801 fn test_parse_inject_args_defaults_to_empty() {
2802 let config = ProxyConfig::parse(minimal_config()).unwrap();
2803 assert!(config.backends[0].default_args.is_empty());
2804 assert!(config.backends[0].inject_args.is_empty());
2805 }
2806
2807 #[test]
2808 fn test_build_filter_none_when_no_filtering() {
2809 let config = ProxyConfig::parse(minimal_config()).unwrap();
2810 assert!(
2811 config.backends[0]
2812 .build_filter(&config.proxy.separator)
2813 .unwrap()
2814 .is_none()
2815 );
2816 }
2817
2818 #[test]
2819 fn test_validate_rejects_duplicate_backend_names() {
2820 let toml = r#"
2821 [proxy]
2822 name = "test"
2823 [proxy.listen]
2824
2825 [[backends]]
2826 name = "echo"
2827 transport = "stdio"
2828 command = "echo"
2829
2830 [[backends]]
2831 name = "echo"
2832 transport = "stdio"
2833 command = "cat"
2834 "#;
2835 let err = ProxyConfig::parse(toml).unwrap_err();
2836 assert!(
2837 err.to_string().contains("duplicate backend name"),
2838 "expected duplicate error, got: {}",
2839 err
2840 );
2841 }
2842
2843 #[test]
2844 fn test_validate_global_rate_limit_zero_requests() {
2845 let toml = r#"
2846 [proxy]
2847 name = "test"
2848 [proxy.listen]
2849 [proxy.rate_limit]
2850 requests = 0
2851
2852 [[backends]]
2853 name = "echo"
2854 transport = "stdio"
2855 command = "echo"
2856 "#;
2857 let err = ProxyConfig::parse(toml).unwrap_err();
2858 assert!(err.to_string().contains("requests must be > 0"));
2859 }
2860
2861 #[test]
2862 fn test_validate_jwt_requires_admin_token() {
2863 let toml = r#"
2866 [proxy]
2867 name = "jwt-gw"
2868 [proxy.listen]
2869
2870 [[backends]]
2871 name = "echo"
2872 transport = "stdio"
2873 command = "echo"
2874
2875 [auth]
2876 type = "jwt"
2877 issuer = "https://auth.example.com"
2878 audience = "mcp-proxy"
2879 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2880 "#;
2881 let err = ProxyConfig::parse(toml).unwrap_err();
2882 assert!(
2883 err.to_string().contains("admin_token"),
2884 "expected admin_token error, got: {err}"
2885 );
2886 }
2887
2888 #[test]
2889 fn test_validate_jwt_with_admin_token_ok() {
2890 let toml = r#"
2892 [proxy]
2893 name = "jwt-gw"
2894 [proxy.listen]
2895
2896 [[backends]]
2897 name = "echo"
2898 transport = "stdio"
2899 command = "echo"
2900
2901 [auth]
2902 type = "jwt"
2903 issuer = "https://auth.example.com"
2904 audience = "mcp-proxy"
2905 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2906
2907 [security]
2908 admin_token = "admin-secret"
2909 "#;
2910 assert!(ProxyConfig::parse(toml).is_ok());
2911 }
2912
2913 #[test]
2914 fn test_validate_oauth_requires_admin_token() {
2915 let toml = r#"
2918 [proxy]
2919 name = "oauth-gw"
2920 [proxy.listen]
2921
2922 [[backends]]
2923 name = "echo"
2924 transport = "stdio"
2925 command = "echo"
2926
2927 [auth]
2928 type = "oauth"
2929 issuer = "https://auth.example.com"
2930 audience = "mcp-proxy"
2931 "#;
2932 let err = ProxyConfig::parse(toml).unwrap_err();
2933 assert!(
2934 err.to_string().contains("admin_token"),
2935 "expected admin_token error, got: {err}"
2936 );
2937 }
2938
2939 #[test]
2940 fn test_parse_global_rate_limit() {
2941 let toml = r#"
2942 [proxy]
2943 name = "test"
2944 [proxy.listen]
2945 [proxy.rate_limit]
2946 requests = 500
2947 period_seconds = 1
2948
2949 [[backends]]
2950 name = "echo"
2951 transport = "stdio"
2952 command = "echo"
2953 "#;
2954 let config = ProxyConfig::parse(toml).unwrap();
2955 let rl = config.proxy.rate_limit.unwrap();
2956 assert_eq!(rl.requests, 500);
2957 assert_eq!(rl.period_seconds, 1);
2958 }
2959
2960 #[test]
2961 fn test_name_filter_glob_wildcard() {
2962 let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
2963 assert!(filter.allows("read_file"));
2964 assert!(filter.allows("write_file"));
2965 assert!(!filter.allows("query"));
2966 assert!(!filter.allows("file_read"));
2967 }
2968
2969 #[test]
2970 fn test_name_filter_glob_prefix() {
2971 let filter = NameFilter::allow_list(["list_*".to_string()]).unwrap();
2972 assert!(filter.allows("list_files"));
2973 assert!(filter.allows("list_users"));
2974 assert!(!filter.allows("get_files"));
2975 }
2976
2977 #[test]
2978 fn test_name_filter_glob_question_mark() {
2979 let filter = NameFilter::allow_list(["get_?".to_string()]).unwrap();
2980 assert!(filter.allows("get_a"));
2981 assert!(filter.allows("get_1"));
2982 assert!(!filter.allows("get_ab"));
2983 assert!(!filter.allows("get_"));
2984 }
2985
2986 #[test]
2987 fn test_name_filter_glob_deny_list() {
2988 let filter = NameFilter::deny_list(["*_delete*".to_string()]).unwrap();
2989 assert!(filter.allows("read_file"));
2990 assert!(filter.allows("create_issue"));
2991 assert!(!filter.allows("force_delete_all"));
2992 assert!(!filter.allows("soft_delete"));
2993 }
2994
2995 #[test]
2996 fn test_name_filter_glob_exact_match_still_works() {
2997 let filter = NameFilter::allow_list(["read_file".to_string()]).unwrap();
2998 assert!(filter.allows("read_file"));
2999 assert!(!filter.allows("write_file"));
3000 }
3001
3002 #[test]
3003 fn test_name_filter_glob_multiple_patterns() {
3004 let filter = NameFilter::allow_list(["read_*".to_string(), "list_*".to_string()]).unwrap();
3005 assert!(filter.allows("read_file"));
3006 assert!(filter.allows("list_users"));
3007 assert!(!filter.allows("delete_file"));
3008 }
3009
3010 #[test]
3011 fn test_name_filter_regex_allow_list() {
3012 let filter =
3013 NameFilter::allow_list(["re:^list_.*$".to_string(), "re:^get_\\w+$".to_string()])
3014 .unwrap();
3015 assert!(filter.allows("list_files"));
3016 assert!(filter.allows("list_users"));
3017 assert!(filter.allows("get_item"));
3018 assert!(!filter.allows("delete_file"));
3019 assert!(!filter.allows("create_issue"));
3020 }
3021
3022 #[test]
3023 fn test_name_filter_regex_deny_list() {
3024 let filter = NameFilter::deny_list(["re:^delete_".to_string()]).unwrap();
3025 assert!(filter.allows("read_file"));
3026 assert!(filter.allows("list_users"));
3027 assert!(!filter.allows("delete_file"));
3028 assert!(!filter.allows("delete_all"));
3029 }
3030
3031 #[test]
3032 fn test_name_filter_mixed_glob_and_regex() {
3033 let filter =
3034 NameFilter::allow_list(["read_*".to_string(), "re:^list_\\w+$".to_string()]).unwrap();
3035 assert!(filter.allows("read_file"));
3036 assert!(filter.allows("read_dir"));
3037 assert!(filter.allows("list_users"));
3038 assert!(!filter.allows("delete_file"));
3039 }
3040
3041 #[test]
3042 fn test_name_filter_regex_invalid_pattern() {
3043 let result = NameFilter::allow_list(["re:[invalid".to_string()]);
3044 assert!(result.is_err(), "invalid regex should produce an error");
3045 }
3046
3047 #[test]
3048 fn test_name_filter_regex_partial_match() {
3049 let filter = NameFilter::allow_list(["re:list".to_string()]).unwrap();
3051 assert!(filter.allows("list_files"));
3052 assert!(filter.allows("my_list_tool"));
3053 assert!(!filter.allows("read_file"));
3054 }
3055
3056 #[test]
3057 fn test_config_parse_regex_filter() {
3058 let toml = r#"
3059 [proxy]
3060 name = "regex-gw"
3061 [proxy.listen]
3062
3063 [[backends]]
3064 name = "svc"
3065 transport = "stdio"
3066 command = "echo"
3067 expose_tools = ["*_issue", "re:^list_.*$"]
3068 "#;
3069
3070 let config = ProxyConfig::parse(toml).unwrap();
3071 let filter = config.backends[0]
3072 .build_filter(&config.proxy.separator)
3073 .unwrap()
3074 .expect("should have filter");
3075 assert!(filter.tool_filter.allows("create_issue"));
3076 assert!(filter.tool_filter.allows("list_files"));
3077 assert!(filter.tool_filter.allows("list_users"));
3078 assert!(!filter.tool_filter.allows("delete_file"));
3079 }
3080
3081 #[test]
3082 fn test_parse_param_overrides() {
3083 let toml = r#"
3084 [proxy]
3085 name = "override-gw"
3086 [proxy.listen]
3087
3088 [[backends]]
3089 name = "fs"
3090 transport = "http"
3091 url = "http://localhost:8080"
3092
3093 [[backends.param_overrides]]
3094 tool = "list_directory"
3095 hide = ["path"]
3096 rename = { recursive = "deep_search" }
3097
3098 [backends.param_overrides.defaults]
3099 path = "/home/docs"
3100 "#;
3101
3102 let config = ProxyConfig::parse(toml).unwrap();
3103 assert_eq!(config.backends[0].param_overrides.len(), 1);
3104 let po = &config.backends[0].param_overrides[0];
3105 assert_eq!(po.tool, "list_directory");
3106 assert_eq!(po.hide, vec!["path"]);
3107 assert_eq!(po.defaults.get("path").unwrap(), "/home/docs");
3108 assert_eq!(po.rename.get("recursive").unwrap(), "deep_search");
3109 }
3110
3111 #[test]
3112 fn test_reject_param_override_empty_tool() {
3113 let toml = r#"
3114 [proxy]
3115 name = "bad"
3116 [proxy.listen]
3117
3118 [[backends]]
3119 name = "fs"
3120 transport = "http"
3121 url = "http://localhost:8080"
3122
3123 [[backends.param_overrides]]
3124 tool = ""
3125 hide = ["path"]
3126 "#;
3127
3128 let err = ProxyConfig::parse(toml).unwrap_err();
3129 assert!(
3130 format!("{err}").contains("tool must not be empty"),
3131 "unexpected error: {err}"
3132 );
3133 }
3134
3135 #[test]
3136 fn test_reject_param_override_duplicate_tool() {
3137 let toml = r#"
3138 [proxy]
3139 name = "bad"
3140 [proxy.listen]
3141
3142 [[backends]]
3143 name = "fs"
3144 transport = "http"
3145 url = "http://localhost:8080"
3146
3147 [[backends.param_overrides]]
3148 tool = "list_directory"
3149 hide = ["path"]
3150
3151 [[backends.param_overrides]]
3152 tool = "list_directory"
3153 hide = ["pattern"]
3154 "#;
3155
3156 let err = ProxyConfig::parse(toml).unwrap_err();
3157 assert!(
3158 format!("{err}").contains("duplicate param_overrides"),
3159 "unexpected error: {err}"
3160 );
3161 }
3162
3163 #[test]
3164 fn test_reject_param_override_hide_and_rename_same_param() {
3165 let toml = r#"
3166 [proxy]
3167 name = "bad"
3168 [proxy.listen]
3169
3170 [[backends]]
3171 name = "fs"
3172 transport = "http"
3173 url = "http://localhost:8080"
3174
3175 [[backends.param_overrides]]
3176 tool = "list_directory"
3177 hide = ["path"]
3178 rename = { path = "dir" }
3179 "#;
3180
3181 let err = ProxyConfig::parse(toml).unwrap_err();
3182 assert!(
3183 format!("{err}").contains("cannot be both hidden and renamed"),
3184 "unexpected error: {err}"
3185 );
3186 }
3187
3188 #[test]
3189 fn test_reject_param_override_duplicate_rename_target() {
3190 let toml = r#"
3191 [proxy]
3192 name = "bad"
3193 [proxy.listen]
3194
3195 [[backends]]
3196 name = "fs"
3197 transport = "http"
3198 url = "http://localhost:8080"
3199
3200 [[backends.param_overrides]]
3201 tool = "list_directory"
3202 rename = { path = "location", dir = "location" }
3203 "#;
3204
3205 let err = ProxyConfig::parse(toml).unwrap_err();
3206 assert!(
3207 format!("{err}").contains("duplicate rename target"),
3208 "unexpected error: {err}"
3209 );
3210 }
3211
3212 #[test]
3213 fn test_cache_backend_defaults_to_memory() {
3214 let config = ProxyConfig::parse(minimal_config()).unwrap();
3215 assert_eq!(config.cache.backend, "memory");
3216 assert!(config.cache.url.is_none());
3217 }
3218
3219 #[test]
3220 fn test_cache_backend_redis_requires_url() {
3221 let toml = r#"
3222 [proxy]
3223 name = "test"
3224 [proxy.listen]
3225 [cache]
3226 backend = "redis"
3227
3228 [[backends]]
3229 name = "echo"
3230 transport = "stdio"
3231 command = "echo"
3232 "#;
3233 let err = ProxyConfig::parse(toml).unwrap_err();
3234 assert!(err.to_string().contains("cache.url is required"));
3235 }
3236
3237 #[test]
3238 fn test_cache_backend_unknown_rejected() {
3239 let toml = r#"
3240 [proxy]
3241 name = "test"
3242 [proxy.listen]
3243 [cache]
3244 backend = "memcached"
3245
3246 [[backends]]
3247 name = "echo"
3248 transport = "stdio"
3249 command = "echo"
3250 "#;
3251 let err = ProxyConfig::parse(toml).unwrap_err();
3252 assert!(err.to_string().contains("unknown cache backend"));
3253 }
3254
3255 const REDIS_CACHE_CONFIG: &str = r#"
3256 [proxy]
3257 name = "test"
3258 [proxy.listen]
3259 [cache]
3260 backend = "redis"
3261 url = "redis://localhost:6379"
3262 prefix = "myapp:"
3263
3264 [[backends]]
3265 name = "echo"
3266 transport = "stdio"
3267 command = "echo"
3268 "#;
3269
3270 #[cfg(feature = "redis-cache")]
3271 #[test]
3272 fn test_cache_backend_redis_with_url() {
3273 let config = ProxyConfig::parse(REDIS_CACHE_CONFIG).unwrap();
3274 assert_eq!(config.cache.backend, "redis");
3275 assert_eq!(config.cache.url.as_deref(), Some("redis://localhost:6379"));
3276 assert_eq!(config.cache.prefix, "myapp:");
3277 }
3278
3279 #[cfg(not(feature = "redis-cache"))]
3280 #[test]
3281 fn test_cache_backend_redis_rejected_without_feature() {
3282 let err = ProxyConfig::parse(REDIS_CACHE_CONFIG).unwrap_err();
3283 assert!(
3284 err.to_string()
3285 .contains("requires the 'redis-cache' feature")
3286 );
3287 }
3288
3289 const SQLITE_CACHE_CONFIG: &str = r#"
3290 [proxy]
3291 name = "test"
3292 [proxy.listen]
3293 [cache]
3294 backend = "sqlite"
3295 url = "cache.db"
3296
3297 [[backends]]
3298 name = "echo"
3299 transport = "stdio"
3300 command = "echo"
3301 "#;
3302
3303 #[cfg(feature = "sqlite-cache")]
3304 #[test]
3305 fn test_cache_backend_sqlite_with_url() {
3306 let config = ProxyConfig::parse(SQLITE_CACHE_CONFIG).unwrap();
3307 assert_eq!(config.cache.backend, "sqlite");
3308 assert_eq!(config.cache.url.as_deref(), Some("cache.db"));
3309 }
3310
3311 #[cfg(not(feature = "sqlite-cache"))]
3312 #[test]
3313 fn test_cache_backend_sqlite_rejected_without_feature() {
3314 let err = ProxyConfig::parse(SQLITE_CACHE_CONFIG).unwrap_err();
3315 assert!(
3316 err.to_string()
3317 .contains("requires the 'sqlite-cache' feature")
3318 );
3319 }
3320
3321 #[test]
3322 fn test_parse_bearer_scoped_tokens() {
3323 let toml = r#"
3324 [proxy]
3325 name = "scoped"
3326 [proxy.listen]
3327
3328 [[backends]]
3329 name = "echo"
3330 transport = "stdio"
3331 command = "echo"
3332
3333 [auth]
3334 type = "bearer"
3335
3336 [[auth.scoped_tokens]]
3337 token = "frontend-token"
3338 allow_tools = ["echo/read_file"]
3339
3340 [[auth.scoped_tokens]]
3341 token = "admin-token"
3342 "#;
3343
3344 let config = ProxyConfig::parse(toml).unwrap();
3345 match &config.auth {
3346 Some(AuthConfig::Bearer {
3347 tokens,
3348 scoped_tokens,
3349 }) => {
3350 assert!(tokens.is_empty());
3351 assert_eq!(scoped_tokens.len(), 2);
3352 assert_eq!(scoped_tokens[0].token, "frontend-token");
3353 assert_eq!(scoped_tokens[0].allow_tools, vec!["echo/read_file"]);
3354 assert!(scoped_tokens[1].allow_tools.is_empty());
3355 }
3356 other => panic!("expected Bearer auth, got: {other:?}"),
3357 }
3358 }
3359
3360 #[test]
3361 fn test_parse_bearer_mixed_tokens() {
3362 let toml = r#"
3363 [proxy]
3364 name = "mixed"
3365 [proxy.listen]
3366
3367 [[backends]]
3368 name = "echo"
3369 transport = "stdio"
3370 command = "echo"
3371
3372 [auth]
3373 type = "bearer"
3374 tokens = ["simple-token"]
3375
3376 [[auth.scoped_tokens]]
3377 token = "scoped-token"
3378 deny_tools = ["echo/delete"]
3379 "#;
3380
3381 let config = ProxyConfig::parse(toml).unwrap();
3382 match &config.auth {
3383 Some(AuthConfig::Bearer {
3384 tokens,
3385 scoped_tokens,
3386 }) => {
3387 assert_eq!(tokens, &["simple-token"]);
3388 assert_eq!(scoped_tokens.len(), 1);
3389 assert_eq!(scoped_tokens[0].deny_tools, vec!["echo/delete"]);
3390 }
3391 other => panic!("expected Bearer auth, got: {other:?}"),
3392 }
3393 }
3394
3395 #[test]
3396 fn test_bearer_empty_tokens_rejected() {
3397 let toml = r#"
3398 [proxy]
3399 name = "empty"
3400 [proxy.listen]
3401
3402 [[backends]]
3403 name = "echo"
3404 transport = "stdio"
3405 command = "echo"
3406
3407 [auth]
3408 type = "bearer"
3409 "#;
3410
3411 let err = ProxyConfig::parse(toml).unwrap_err();
3412 assert!(
3413 err.to_string().contains("at least one token"),
3414 "unexpected error: {err}"
3415 );
3416 }
3417
3418 #[test]
3419 fn test_bearer_duplicate_across_lists_rejected() {
3420 let toml = r#"
3421 [proxy]
3422 name = "dup"
3423 [proxy.listen]
3424
3425 [[backends]]
3426 name = "echo"
3427 transport = "stdio"
3428 command = "echo"
3429
3430 [auth]
3431 type = "bearer"
3432 tokens = ["shared-token"]
3433
3434 [[auth.scoped_tokens]]
3435 token = "shared-token"
3436 allow_tools = ["echo/read"]
3437 "#;
3438
3439 let err = ProxyConfig::parse(toml).unwrap_err();
3440 assert!(
3441 err.to_string().contains("duplicate bearer token"),
3442 "unexpected error: {err}"
3443 );
3444 }
3445
3446 #[test]
3447 fn test_bearer_allow_and_deny_rejected() {
3448 let toml = r#"
3449 [proxy]
3450 name = "both"
3451 [proxy.listen]
3452
3453 [[backends]]
3454 name = "echo"
3455 transport = "stdio"
3456 command = "echo"
3457
3458 [auth]
3459 type = "bearer"
3460
3461 [[auth.scoped_tokens]]
3462 token = "conflict"
3463 allow_tools = ["echo/read"]
3464 deny_tools = ["echo/write"]
3465 "#;
3466
3467 let err = ProxyConfig::parse(toml).unwrap_err();
3468 assert!(
3469 err.to_string().contains("cannot specify both"),
3470 "unexpected error: {err}"
3471 );
3472 }
3473
3474 #[test]
3475 fn test_parse_websocket_transport() {
3476 let toml = r#"
3477 [proxy]
3478 name = "ws-proxy"
3479 [proxy.listen]
3480
3481 [[backends]]
3482 name = "ws-backend"
3483 transport = "websocket"
3484 url = "ws://localhost:9090/ws"
3485 "#;
3486
3487 let config = ProxyConfig::parse(toml).unwrap();
3488 assert!(matches!(
3489 config.backends[0].transport,
3490 TransportType::Websocket
3491 ));
3492 assert_eq!(
3493 config.backends[0].url.as_deref(),
3494 Some("ws://localhost:9090/ws")
3495 );
3496 }
3497
3498 #[test]
3499 fn test_websocket_transport_requires_url() {
3500 let toml = r#"
3501 [proxy]
3502 name = "ws-proxy"
3503 [proxy.listen]
3504
3505 [[backends]]
3506 name = "ws-backend"
3507 transport = "websocket"
3508 "#;
3509
3510 let err = ProxyConfig::parse(toml).unwrap_err();
3511 assert!(
3512 err.to_string()
3513 .contains("websocket transport requires 'url'"),
3514 "unexpected error: {err}"
3515 );
3516 }
3517
3518 #[test]
3519 fn test_websocket_with_bearer_token() {
3520 let toml = r#"
3521 [proxy]
3522 name = "ws-proxy"
3523 [proxy.listen]
3524
3525 [[backends]]
3526 name = "ws-backend"
3527 transport = "websocket"
3528 url = "wss://secure.example.com/mcp"
3529 bearer_token = "my-secret"
3530 "#;
3531
3532 let config = ProxyConfig::parse(toml).unwrap();
3533 assert_eq!(
3534 config.backends[0].bearer_token.as_deref(),
3535 Some("my-secret")
3536 );
3537 }
3538
3539 #[test]
3540 fn test_tool_discovery_defaults_false() {
3541 let config = ProxyConfig::parse(minimal_config()).unwrap();
3542 assert!(!config.proxy.tool_discovery);
3543 }
3544
3545 #[test]
3546 fn test_tool_discovery_enabled() {
3547 let toml = r#"
3548 [proxy]
3549 name = "discovery"
3550 tool_discovery = true
3551 [proxy.listen]
3552
3553 [[backends]]
3554 name = "echo"
3555 transport = "stdio"
3556 command = "echo"
3557 "#;
3558
3559 let config = ProxyConfig::parse(toml).unwrap();
3560 assert!(config.proxy.tool_discovery);
3561 }
3562
3563 #[test]
3564 fn test_parse_oauth_config() {
3565 let toml = r#"
3566 [proxy]
3567 name = "oauth-proxy"
3568 [proxy.listen]
3569
3570 [[backends]]
3571 name = "echo"
3572 transport = "stdio"
3573 command = "echo"
3574
3575 [auth]
3576 type = "oauth"
3577 issuer = "https://accounts.google.com"
3578 audience = "mcp-proxy"
3579
3580 [security]
3581 admin_token = "admin-secret"
3582 "#;
3583
3584 let config = ProxyConfig::parse(toml).unwrap();
3585 match &config.auth {
3586 Some(AuthConfig::OAuth {
3587 issuer,
3588 audience,
3589 token_validation,
3590 ..
3591 }) => {
3592 assert_eq!(issuer, "https://accounts.google.com");
3593 assert_eq!(audience, "mcp-proxy");
3594 assert_eq!(token_validation, &TokenValidationStrategy::Jwt);
3595 }
3596 other => panic!("expected OAuth auth, got: {other:?}"),
3597 }
3598 }
3599
3600 #[test]
3601 fn test_parse_oauth_with_introspection() {
3602 let toml = r#"
3603 [proxy]
3604 name = "oauth-proxy"
3605 [proxy.listen]
3606
3607 [[backends]]
3608 name = "echo"
3609 transport = "stdio"
3610 command = "echo"
3611
3612 [auth]
3613 type = "oauth"
3614 issuer = "https://auth.example.com"
3615 audience = "mcp-proxy"
3616 client_id = "my-client"
3617 client_secret = "my-secret"
3618 token_validation = "introspection"
3619
3620 [security]
3621 admin_token = "admin-secret"
3622 "#;
3623
3624 let config = ProxyConfig::parse(toml).unwrap();
3625 match &config.auth {
3626 Some(AuthConfig::OAuth {
3627 token_validation,
3628 client_id,
3629 client_secret,
3630 ..
3631 }) => {
3632 assert_eq!(token_validation, &TokenValidationStrategy::Introspection);
3633 assert_eq!(client_id.as_deref(), Some("my-client"));
3634 assert_eq!(client_secret.as_deref(), Some("my-secret"));
3635 }
3636 other => panic!("expected OAuth auth, got: {other:?}"),
3637 }
3638 }
3639
3640 #[test]
3641 fn test_oauth_introspection_requires_credentials() {
3642 let toml = r#"
3643 [proxy]
3644 name = "oauth-proxy"
3645 [proxy.listen]
3646
3647 [[backends]]
3648 name = "echo"
3649 transport = "stdio"
3650 command = "echo"
3651
3652 [auth]
3653 type = "oauth"
3654 issuer = "https://auth.example.com"
3655 audience = "mcp-proxy"
3656 token_validation = "introspection"
3657 "#;
3658
3659 let err = ProxyConfig::parse(toml).unwrap_err();
3660 assert!(
3661 err.to_string().contains("client_id"),
3662 "unexpected error: {err}"
3663 );
3664 }
3665
3666 #[test]
3667 fn test_parse_oauth_with_overrides() {
3668 let toml = r#"
3669 [proxy]
3670 name = "oauth-proxy"
3671 [proxy.listen]
3672
3673 [[backends]]
3674 name = "echo"
3675 transport = "stdio"
3676 command = "echo"
3677
3678 [auth]
3679 type = "oauth"
3680 issuer = "https://auth.example.com"
3681 audience = "mcp-proxy"
3682 jwks_uri = "https://auth.example.com/custom/jwks"
3683 introspection_endpoint = "https://auth.example.com/custom/introspect"
3684 client_id = "my-client"
3685 client_secret = "my-secret"
3686 token_validation = "both"
3687 required_scopes = ["read", "write"]
3688
3689 [security]
3690 admin_token = "admin-secret"
3691 "#;
3692
3693 let config = ProxyConfig::parse(toml).unwrap();
3694 match &config.auth {
3695 Some(AuthConfig::OAuth {
3696 jwks_uri,
3697 introspection_endpoint,
3698 token_validation,
3699 required_scopes,
3700 ..
3701 }) => {
3702 assert_eq!(
3703 jwks_uri.as_deref(),
3704 Some("https://auth.example.com/custom/jwks")
3705 );
3706 assert_eq!(
3707 introspection_endpoint.as_deref(),
3708 Some("https://auth.example.com/custom/introspect")
3709 );
3710 assert_eq!(token_validation, &TokenValidationStrategy::Both);
3711 assert_eq!(required_scopes, &["read", "write"]);
3712 }
3713 other => panic!("expected OAuth auth, got: {other:?}"),
3714 }
3715 }
3716
3717 #[test]
3718 fn test_check_env_vars_warns_on_unset() {
3719 let toml = r#"
3720 [proxy]
3721 name = "env-check"
3722 [proxy.listen]
3723
3724 [[backends]]
3725 name = "svc"
3726 transport = "stdio"
3727 command = "echo"
3728 bearer_token = "${TOTALLY_UNSET_VAR_1}"
3729
3730 [backends.env]
3731 API_KEY = "${TOTALLY_UNSET_VAR_2}"
3732 STATIC = "plain-value"
3733
3734 [auth]
3735 type = "bearer"
3736 tokens = ["${TOTALLY_UNSET_VAR_3}", "literal-token"]
3737
3738 [[auth.scoped_tokens]]
3739 token = "${TOTALLY_UNSET_VAR_4}"
3740 allow_tools = ["svc/echo"]
3741 "#;
3742
3743 let config = ProxyConfig::parse(toml).unwrap();
3744 let warnings = config.check_env_vars();
3745
3746 assert_eq!(warnings.len(), 4, "warnings: {warnings:?}");
3747 assert!(warnings[0].contains("TOTALLY_UNSET_VAR_1"));
3748 assert!(warnings[0].contains("bearer_token"));
3749 assert!(warnings[1].contains("TOTALLY_UNSET_VAR_2"));
3750 assert!(warnings[1].contains("env.API_KEY"));
3751 assert!(warnings[2].contains("TOTALLY_UNSET_VAR_3"));
3752 assert!(warnings[2].contains("tokens[0]"));
3753 assert!(warnings[3].contains("TOTALLY_UNSET_VAR_4"));
3754 assert!(warnings[3].contains("scoped_tokens[0]"));
3755 }
3756
3757 #[test]
3758 fn test_check_env_vars_no_warnings_when_set() {
3759 unsafe { std::env::set_var("MCP_CHECK_TEST_VAR", "value") };
3761
3762 let toml = r#"
3763 [proxy]
3764 name = "env-check"
3765 [proxy.listen]
3766
3767 [[backends]]
3768 name = "svc"
3769 transport = "stdio"
3770 command = "echo"
3771 bearer_token = "${MCP_CHECK_TEST_VAR}"
3772 "#;
3773
3774 let config = ProxyConfig::parse(toml).unwrap();
3775 let warnings = config.check_env_vars();
3776 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3777
3778 unsafe { std::env::remove_var("MCP_CHECK_TEST_VAR") };
3780 }
3781
3782 #[test]
3783 fn test_check_env_vars_no_warnings_for_literals() {
3784 let toml = r#"
3785 [proxy]
3786 name = "env-check"
3787 [proxy.listen]
3788
3789 [[backends]]
3790 name = "svc"
3791 transport = "stdio"
3792 command = "echo"
3793 bearer_token = "literal-token"
3794 "#;
3795
3796 let config = ProxyConfig::parse(toml).unwrap();
3797 let warnings = config.check_env_vars();
3798 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3799 }
3800
3801 #[test]
3802 fn test_check_env_vars_oauth_client_secret() {
3803 let toml = r#"
3804 [proxy]
3805 name = "oauth-check"
3806 [proxy.listen]
3807
3808 [[backends]]
3809 name = "svc"
3810 transport = "http"
3811 url = "http://localhost:3000"
3812
3813 [auth]
3814 type = "oauth"
3815 issuer = "https://auth.example.com"
3816 audience = "mcp-proxy"
3817 client_id = "my-client"
3818 client_secret = "${TOTALLY_UNSET_OAUTH_SECRET}"
3819 token_validation = "introspection"
3820
3821 [security]
3822 admin_token = "admin-secret"
3823 "#;
3824
3825 let config = ProxyConfig::parse(toml).unwrap();
3826 let warnings = config.check_env_vars();
3827 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
3828 assert!(warnings[0].contains("TOTALLY_UNSET_OAUTH_SECRET"));
3829 assert!(warnings[0].contains("client_secret"));
3830 }
3831
3832 #[cfg(feature = "yaml")]
3833 #[test]
3834 fn test_parse_yaml_config() {
3835 let yaml = r#"
3836proxy:
3837 name: yaml-proxy
3838 listen:
3839 host: "127.0.0.1"
3840 port: 8080
3841backends:
3842 - name: echo
3843 transport: stdio
3844 command: echo
3845"#;
3846 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3847 assert_eq!(config.proxy.name, "yaml-proxy");
3848 assert_eq!(config.backends.len(), 1);
3849 assert_eq!(config.backends[0].name, "echo");
3850 }
3851
3852 #[cfg(feature = "yaml")]
3853 #[test]
3854 fn test_parse_yaml_with_auth() {
3855 let yaml = r#"
3856proxy:
3857 name: auth-proxy
3858 listen:
3859 host: "127.0.0.1"
3860 port: 9090
3861backends:
3862 - name: api
3863 transport: stdio
3864 command: echo
3865auth:
3866 type: bearer
3867 tokens:
3868 - token-1
3869 - token-2
3870"#;
3871 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3872 match &config.auth {
3873 Some(AuthConfig::Bearer { tokens, .. }) => {
3874 assert_eq!(tokens, &["token-1", "token-2"]);
3875 }
3876 other => panic!("expected Bearer auth, got: {other:?}"),
3877 }
3878 }
3879
3880 #[cfg(feature = "yaml")]
3881 #[test]
3882 fn test_parse_yaml_with_middleware() {
3883 let yaml = r#"
3884proxy:
3885 name: mw-proxy
3886 listen:
3887 host: "127.0.0.1"
3888 port: 8080
3889backends:
3890 - name: api
3891 transport: stdio
3892 command: echo
3893 timeout:
3894 seconds: 30
3895 rate_limit:
3896 requests: 100
3897 period_seconds: 1
3898 expose_tools:
3899 - read_file
3900 - list_directory
3901"#;
3902 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3903 assert_eq!(config.backends[0].timeout.as_ref().unwrap().seconds, 30);
3904 assert_eq!(
3905 config.backends[0].rate_limit.as_ref().unwrap().requests,
3906 100
3907 );
3908 assert_eq!(
3909 config.backends[0].expose_tools,
3910 vec!["read_file", "list_directory"]
3911 );
3912 }
3913
3914 #[test]
3915 fn test_from_mcp_json() {
3916 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json");
3917 let project_dir = dir.join("my-project");
3918 std::fs::create_dir_all(&project_dir).unwrap();
3919
3920 let mcp_json_path = project_dir.join(".mcp.json");
3921 std::fs::write(
3922 &mcp_json_path,
3923 r#"{
3924 "mcpServers": {
3925 "github": {
3926 "command": "npx",
3927 "args": ["-y", "@modelcontextprotocol/server-github"]
3928 },
3929 "api": {
3930 "url": "http://localhost:9000"
3931 }
3932 }
3933 }"#,
3934 )
3935 .unwrap();
3936
3937 let config = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap();
3938
3939 assert_eq!(config.proxy.name, "my-project");
3941 assert_eq!(config.proxy.listen.host, "127.0.0.1");
3943 assert_eq!(config.proxy.listen.port, 8080);
3944 assert_eq!(config.proxy.version, "0.1.0");
3945 assert_eq!(config.proxy.separator, "/");
3946 assert!(config.auth.is_none());
3948 assert!(config.composite_tools.is_empty());
3949 assert_eq!(config.backends.len(), 2);
3951 assert_eq!(config.backends[0].name, "api");
3952 assert_eq!(config.backends[1].name, "github");
3953
3954 std::fs::remove_dir_all(&dir).unwrap();
3955 }
3956
3957 #[test]
3958 fn test_from_mcp_json_empty_rejects() {
3959 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json_empty");
3960 std::fs::create_dir_all(&dir).unwrap();
3961
3962 let mcp_json_path = dir.join(".mcp.json");
3963 std::fs::write(&mcp_json_path, r#"{ "mcpServers": {} }"#).unwrap();
3964
3965 let err = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap_err();
3966 assert!(
3967 err.to_string().contains("at least one backend"),
3968 "unexpected error: {err}"
3969 );
3970
3971 std::fs::remove_dir_all(&dir).unwrap();
3972 }
3973
3974 #[test]
3975 fn test_priority_defaults_to_zero() {
3976 let toml = r#"
3977 [proxy]
3978 name = "test"
3979 [proxy.listen]
3980
3981 [[backends]]
3982 name = "api"
3983 transport = "stdio"
3984 command = "echo"
3985 "#;
3986
3987 let config = ProxyConfig::parse(toml).unwrap();
3988 assert_eq!(config.backends[0].priority, 0);
3989 }
3990
3991 #[test]
3992 fn test_priority_parsed_from_config() {
3993 let toml = r#"
3994 [proxy]
3995 name = "test"
3996 [proxy.listen]
3997
3998 [[backends]]
3999 name = "api"
4000 transport = "stdio"
4001 command = "echo"
4002
4003 [[backends]]
4004 name = "api-backup-1"
4005 transport = "stdio"
4006 command = "echo"
4007 failover_for = "api"
4008 priority = 10
4009
4010 [[backends]]
4011 name = "api-backup-2"
4012 transport = "stdio"
4013 command = "echo"
4014 failover_for = "api"
4015 priority = 5
4016 "#;
4017
4018 let config = ProxyConfig::parse(toml).unwrap();
4019 assert_eq!(config.backends[0].priority, 0);
4020 assert_eq!(config.backends[1].priority, 10);
4021 assert_eq!(config.backends[2].priority, 5);
4022 }
4023}