1use std::collections::HashMap;
227use std::collections::HashSet;
228use std::path::Path;
229
230use anyhow::{Context, Result};
231use serde::{Deserialize, Serialize};
232
233#[derive(Debug, Deserialize, Serialize)]
235pub struct ProxyConfig {
236 pub proxy: ProxySettings,
238 #[serde(default)]
240 pub backends: Vec<BackendConfig>,
241 pub auth: Option<AuthConfig>,
243 #[serde(default)]
245 pub performance: PerformanceConfig,
246 #[serde(default)]
248 pub security: SecurityConfig,
249 #[serde(default)]
251 pub cache: CacheBackendConfig,
252 #[serde(default)]
254 pub observability: ObservabilityConfig,
255 #[serde(default)]
257 pub composite_tools: Vec<CompositeToolConfig>,
258 #[serde(skip)]
260 pub source_path: Option<std::path::PathBuf>,
261}
262
263#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
265#[serde(rename_all = "lowercase")]
266pub enum CompositeStrategy {
267 #[default]
269 Parallel,
270}
271
272#[derive(Debug, Clone, Deserialize, Serialize)]
288pub struct CompositeToolConfig {
289 pub name: String,
291 pub description: String,
293 pub tools: Vec<String>,
295 #[serde(default)]
297 pub strategy: CompositeStrategy,
298}
299
300#[derive(Debug, Deserialize, Serialize)]
302pub struct ProxySettings {
303 pub name: String,
305 #[serde(default = "default_version")]
307 pub version: String,
308 #[serde(default = "default_separator")]
310 pub separator: String,
311 pub listen: ListenConfig,
313 pub instructions: Option<String>,
315 #[serde(default = "default_shutdown_timeout")]
317 pub shutdown_timeout_seconds: u64,
318 #[serde(default)]
320 pub hot_reload: bool,
321 pub import_backends: Option<String>,
324 pub rate_limit: Option<GlobalRateLimitConfig>,
326 #[serde(default)]
330 pub tool_discovery: bool,
331 #[serde(default)]
339 pub tool_exposure: ToolExposure,
340}
341
342#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
359#[serde(rename_all = "lowercase")]
360pub enum ToolExposure {
361 #[default]
363 Direct,
364 Search,
367}
368
369#[derive(Debug, Deserialize, Serialize, Clone)]
371pub struct GlobalRateLimitConfig {
372 pub requests: usize,
374 #[serde(default = "default_rate_period")]
376 pub period_seconds: u64,
377}
378
379#[derive(Debug, Deserialize, Serialize)]
381pub struct ListenConfig {
382 #[serde(default = "default_host")]
384 pub host: String,
385 #[serde(default = "default_port")]
387 pub port: u16,
388}
389
390#[derive(Debug, Deserialize, Serialize)]
392#[serde(deny_unknown_fields)]
393pub struct BackendConfig {
394 pub name: String,
396 pub transport: TransportType,
398 pub command: Option<String>,
400 #[serde(default)]
402 pub args: Vec<String>,
403 pub url: Option<String>,
405 #[serde(default)]
407 pub env: HashMap<String, String>,
408 pub timeout: Option<TimeoutConfig>,
410 pub circuit_breaker: Option<CircuitBreakerConfig>,
412 pub rate_limit: Option<RateLimitConfig>,
414 pub concurrency: Option<ConcurrencyConfig>,
416 pub retry: Option<RetryConfig>,
418 pub outlier_detection: Option<OutlierDetectionConfig>,
420 pub hedging: Option<HedgingConfig>,
422 pub mirror_of: Option<String>,
425 #[serde(default = "default_mirror_percent")]
427 pub mirror_percent: u32,
428 pub cache: Option<BackendCacheConfig>,
430 pub bearer_token: Option<String>,
433 #[serde(default)]
437 pub headers: HashMap<String, String>,
438 #[serde(default)]
441 pub forward_auth: bool,
442 #[serde(default)]
444 pub aliases: Vec<AliasConfig>,
445 #[serde(default)]
448 pub default_args: serde_json::Map<String, serde_json::Value>,
449 #[serde(default)]
451 pub inject_args: Vec<InjectArgsConfig>,
452 #[serde(default)]
454 pub param_overrides: Vec<ParamOverrideConfig>,
455 #[serde(default)]
457 pub expose_tools: Vec<String>,
458 #[serde(default)]
460 pub hide_tools: Vec<String>,
461 #[serde(default)]
463 pub expose_resources: Vec<String>,
464 #[serde(default)]
466 pub hide_resources: Vec<String>,
467 #[serde(default)]
469 pub expose_prompts: Vec<String>,
470 #[serde(default)]
472 pub hide_prompts: Vec<String>,
473 #[serde(default)]
475 pub hide_destructive: bool,
476 #[serde(default)]
478 pub read_only_only: bool,
479 pub failover_for: Option<String>,
483 #[serde(default)]
488 pub priority: u32,
489 pub canary_of: Option<String>,
493 #[serde(default = "default_weight")]
496 pub weight: u32,
497}
498
499#[derive(Debug, Deserialize, Serialize)]
501#[serde(rename_all = "lowercase")]
502pub enum TransportType {
503 Stdio,
505 Http,
507 Websocket,
509}
510
511#[derive(Debug, Deserialize, Serialize)]
513pub struct TimeoutConfig {
514 pub seconds: u64,
516}
517
518#[derive(Debug, Deserialize, Serialize)]
520pub struct CircuitBreakerConfig {
521 #[serde(default = "default_failure_rate")]
523 pub failure_rate_threshold: f64,
524 #[serde(default = "default_min_calls")]
526 pub minimum_calls: usize,
527 #[serde(default = "default_wait_duration")]
529 pub wait_duration_seconds: u64,
530 #[serde(default = "default_half_open_calls")]
532 pub permitted_calls_in_half_open: usize,
533}
534
535#[derive(Debug, Deserialize, Serialize)]
537pub struct RateLimitConfig {
538 pub requests: usize,
540 #[serde(default = "default_rate_period")]
542 pub period_seconds: u64,
543}
544
545#[derive(Debug, Deserialize, Serialize)]
547pub struct ConcurrencyConfig {
548 pub max_concurrent: usize,
550}
551
552#[derive(Debug, Clone, Deserialize, Serialize)]
554pub struct RetryConfig {
555 #[serde(default = "default_max_retries")]
557 pub max_retries: u32,
558 #[serde(default = "default_initial_backoff_ms")]
560 pub initial_backoff_ms: u64,
561 #[serde(default = "default_max_backoff_ms")]
563 pub max_backoff_ms: u64,
564 pub budget_percent: Option<f64>,
569 #[serde(default = "default_min_retries_per_sec")]
572 pub min_retries_per_sec: u32,
573}
574
575#[derive(Debug, Clone, Deserialize, Serialize)]
579pub struct OutlierDetectionConfig {
580 #[serde(default = "default_consecutive_errors")]
582 pub consecutive_errors: u32,
583 #[serde(default = "default_interval_seconds")]
585 pub interval_seconds: u64,
586 #[serde(default = "default_base_ejection_seconds")]
588 pub base_ejection_seconds: u64,
589 #[serde(default = "default_max_ejection_percent")]
591 pub max_ejection_percent: u32,
592}
593
594#[derive(Debug, Clone, Deserialize, Serialize)]
596pub struct InjectArgsConfig {
597 pub tool: String,
599 pub args: serde_json::Map<String, serde_json::Value>,
602 #[serde(default)]
604 pub overwrite: bool,
605}
606
607#[derive(Debug, Clone, Deserialize, Serialize)]
622pub struct ParamOverrideConfig {
623 pub tool: String,
625 #[serde(default)]
629 pub hide: Vec<String>,
630 #[serde(default)]
633 pub defaults: serde_json::Map<String, serde_json::Value>,
634 #[serde(default)]
638 pub rename: HashMap<String, String>,
639}
640
641#[derive(Debug, Clone, Deserialize, Serialize)]
647pub struct HedgingConfig {
648 #[serde(default = "default_hedge_delay_ms")]
651 pub delay_ms: u64,
652 #[serde(default = "default_max_hedges")]
654 pub max_hedges: usize,
655}
656
657#[derive(Debug, Deserialize, Serialize)]
659#[serde(tag = "type", rename_all = "lowercase")]
660pub enum AuthConfig {
661 Bearer {
663 #[serde(default)]
665 tokens: Vec<String>,
666 #[serde(default)]
668 scoped_tokens: Vec<BearerTokenConfig>,
669 },
670 Jwt {
672 issuer: String,
674 audience: String,
676 jwks_uri: String,
678 #[serde(default)]
680 roles: Vec<RoleConfig>,
681 role_mapping: Option<RoleMappingConfig>,
683 },
684 OAuth {
690 issuer: String,
693 audience: String,
695 #[serde(default)]
697 client_id: Option<String>,
698 #[serde(default)]
701 client_secret: Option<String>,
702 #[serde(default)]
704 token_validation: TokenValidationStrategy,
705 #[serde(default)]
707 jwks_uri: Option<String>,
708 #[serde(default)]
710 introspection_endpoint: Option<String>,
711 #[serde(default)]
718 required_scopes: Vec<String>,
719 #[serde(default)]
721 roles: Vec<RoleConfig>,
722 role_mapping: Option<RoleMappingConfig>,
724 },
725}
726
727#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
729#[serde(rename_all = "lowercase")]
730pub enum TokenValidationStrategy {
731 #[default]
733 Jwt,
734 Introspection,
737 Both,
740}
741
742#[derive(Debug, Clone, Deserialize, Serialize)]
765pub struct BearerTokenConfig {
766 pub token: String,
768 #[serde(default)]
771 pub allow_tools: Vec<String>,
772 #[serde(default)]
774 pub deny_tools: Vec<String>,
775}
776
777#[derive(Debug, Deserialize, Serialize)]
779pub struct RoleConfig {
780 pub name: String,
782 #[serde(default)]
784 pub allow_tools: Vec<String>,
785 #[serde(default)]
787 pub deny_tools: Vec<String>,
788}
789
790#[derive(Debug, Deserialize, Serialize)]
792pub struct RoleMappingConfig {
793 pub claim: String,
795 pub mapping: HashMap<String, String>,
797 #[serde(default)]
809 pub default_deny: bool,
810}
811
812#[derive(Debug, Deserialize, Serialize)]
814pub struct AliasConfig {
815 pub from: String,
817 pub to: String,
819}
820
821#[derive(Debug, Deserialize, Serialize)]
823pub struct BackendCacheConfig {
824 #[serde(default)]
826 pub resource_ttl_seconds: u64,
827 #[serde(default)]
829 pub tool_ttl_seconds: u64,
830 #[serde(default = "default_max_cache_entries")]
832 pub max_entries: u64,
833}
834
835#[derive(Debug, Deserialize, Serialize, Clone)]
849pub struct CacheBackendConfig {
850 #[serde(default = "default_cache_backend")]
852 pub backend: String,
853 pub url: Option<String>,
855 #[serde(default = "default_cache_prefix")]
857 pub prefix: String,
858}
859
860impl Default for CacheBackendConfig {
861 fn default() -> Self {
862 Self {
863 backend: default_cache_backend(),
864 url: None,
865 prefix: default_cache_prefix(),
866 }
867 }
868}
869
870fn default_cache_backend() -> String {
871 "memory".to_string()
872}
873
874fn default_cache_prefix() -> String {
875 "mcp-proxy:".to_string()
876}
877
878#[derive(Debug, Default, Deserialize, Serialize)]
880pub struct PerformanceConfig {
881 #[serde(default)]
883 pub coalesce_requests: bool,
884}
885
886#[derive(Debug, Default, Deserialize, Serialize)]
888pub struct SecurityConfig {
889 pub max_argument_size: Option<usize>,
891 pub admin_token: Option<String>,
899}
900
901#[derive(Debug, Default, Deserialize, Serialize)]
903pub struct ObservabilityConfig {
904 #[serde(default)]
906 pub audit: bool,
907 #[serde(default = "default_log_level")]
909 pub log_level: String,
910 #[serde(default)]
912 pub json_logs: bool,
913 #[serde(default)]
915 pub metrics: MetricsConfig,
916 #[serde(default)]
918 pub tracing: TracingConfig,
919 #[serde(default)]
921 pub access_log: AccessLogConfig,
922}
923
924#[derive(Debug, Default, Deserialize, Serialize)]
926pub struct AccessLogConfig {
927 #[serde(default)]
929 pub enabled: bool,
930}
931
932#[derive(Debug, Default, Deserialize, Serialize)]
934pub struct MetricsConfig {
935 #[serde(default)]
937 pub enabled: bool,
938}
939
940#[derive(Debug, Default, Deserialize, Serialize)]
942pub struct TracingConfig {
943 #[serde(default)]
945 pub enabled: bool,
946 #[serde(default = "default_otlp_endpoint")]
948 pub endpoint: String,
949 #[serde(default = "default_service_name")]
951 pub service_name: String,
952}
953
954fn default_version() -> String {
957 "0.1.0".to_string()
958}
959
960fn default_separator() -> String {
961 "/".to_string()
962}
963
964fn default_host() -> String {
965 "127.0.0.1".to_string()
966}
967
968fn default_port() -> u16 {
969 8080
970}
971
972fn default_log_level() -> String {
973 "info".to_string()
974}
975
976fn default_failure_rate() -> f64 {
977 0.5
978}
979
980fn default_min_calls() -> usize {
981 5
982}
983
984fn default_wait_duration() -> u64 {
985 30
986}
987
988fn default_half_open_calls() -> usize {
989 3
990}
991
992fn default_rate_period() -> u64 {
993 1
994}
995
996fn default_max_retries() -> u32 {
997 3
998}
999
1000fn default_initial_backoff_ms() -> u64 {
1001 100
1002}
1003
1004fn default_max_backoff_ms() -> u64 {
1005 5000
1006}
1007
1008fn default_min_retries_per_sec() -> u32 {
1009 10
1010}
1011
1012fn default_consecutive_errors() -> u32 {
1013 5
1014}
1015
1016fn default_interval_seconds() -> u64 {
1017 10
1018}
1019
1020fn default_base_ejection_seconds() -> u64 {
1021 30
1022}
1023
1024fn default_max_ejection_percent() -> u32 {
1025 50
1026}
1027
1028fn default_hedge_delay_ms() -> u64 {
1029 200
1030}
1031
1032fn default_max_hedges() -> usize {
1033 1
1034}
1035
1036fn default_mirror_percent() -> u32 {
1037 100
1038}
1039
1040fn default_weight() -> u32 {
1041 100
1042}
1043
1044fn default_max_cache_entries() -> u64 {
1045 1000
1046}
1047
1048fn default_shutdown_timeout() -> u64 {
1049 30
1050}
1051
1052fn default_otlp_endpoint() -> String {
1053 "http://localhost:4317".to_string()
1054}
1055
1056fn default_service_name() -> String {
1057 "mcp-proxy".to_string()
1058}
1059
1060#[derive(Debug, Clone)]
1062pub struct BackendFilter {
1063 pub namespace: String,
1065 pub tool_filter: NameFilter,
1067 pub resource_filter: NameFilter,
1069 pub prompt_filter: NameFilter,
1071 pub hide_destructive: bool,
1073 pub read_only_only: bool,
1075}
1076
1077#[derive(Debug, Clone)]
1082pub enum CompiledPattern {
1083 Glob(String),
1085 Regex(regex::Regex),
1087}
1088
1089impl CompiledPattern {
1090 fn compile(pattern: &str) -> Result<Self> {
1093 if let Some(re_pat) = pattern.strip_prefix("re:") {
1094 let re = regex::Regex::new(re_pat)
1095 .with_context(|| format!("invalid regex in filter pattern: {pattern}"))?;
1096 Ok(Self::Regex(re))
1097 } else {
1098 Ok(Self::Glob(pattern.to_string()))
1099 }
1100 }
1101
1102 fn matches(&self, name: &str) -> bool {
1104 match self {
1105 Self::Glob(pat) => glob_match::glob_match(pat, name),
1106 Self::Regex(re) => re.is_match(name),
1107 }
1108 }
1109}
1110
1111#[derive(Debug, Clone)]
1119pub enum NameFilter {
1120 PassAll,
1122 AllowList(Vec<CompiledPattern>),
1124 DenyList(Vec<CompiledPattern>),
1126}
1127
1128impl NameFilter {
1129 pub fn allow_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1138 let compiled: Result<Vec<_>> = patterns
1139 .into_iter()
1140 .map(|p| CompiledPattern::compile(&p))
1141 .collect();
1142 Ok(Self::AllowList(compiled?))
1143 }
1144
1145 pub fn deny_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1154 let compiled: Result<Vec<_>> = patterns
1155 .into_iter()
1156 .map(|p| CompiledPattern::compile(&p))
1157 .collect();
1158 Ok(Self::DenyList(compiled?))
1159 }
1160
1161 pub fn allows(&self, name: &str) -> bool {
1193 match self {
1194 Self::PassAll => true,
1195 Self::AllowList(patterns) => patterns.iter().any(|p| p.matches(name)),
1196 Self::DenyList(patterns) => !patterns.iter().any(|p| p.matches(name)),
1197 }
1198 }
1199}
1200
1201impl BackendConfig {
1202 pub fn build_filter(&self, separator: &str) -> Result<Option<BackendFilter>> {
1209 if self.canary_of.is_some() || self.failover_for.is_some() {
1212 return Ok(Some(BackendFilter {
1213 namespace: format!("{}{}", self.name, separator),
1214 tool_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1215 resource_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1216 prompt_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1217 hide_destructive: false,
1218 read_only_only: false,
1219 }));
1220 }
1221
1222 let tool_filter = if !self.expose_tools.is_empty() {
1223 NameFilter::allow_list(self.expose_tools.iter().cloned())?
1224 } else if !self.hide_tools.is_empty() {
1225 NameFilter::deny_list(self.hide_tools.iter().cloned())?
1226 } else {
1227 NameFilter::PassAll
1228 };
1229
1230 let resource_filter = if !self.expose_resources.is_empty() {
1231 NameFilter::allow_list(self.expose_resources.iter().cloned())?
1232 } else if !self.hide_resources.is_empty() {
1233 NameFilter::deny_list(self.hide_resources.iter().cloned())?
1234 } else {
1235 NameFilter::PassAll
1236 };
1237
1238 let prompt_filter = if !self.expose_prompts.is_empty() {
1239 NameFilter::allow_list(self.expose_prompts.iter().cloned())?
1240 } else if !self.hide_prompts.is_empty() {
1241 NameFilter::deny_list(self.hide_prompts.iter().cloned())?
1242 } else {
1243 NameFilter::PassAll
1244 };
1245
1246 if matches!(tool_filter, NameFilter::PassAll)
1248 && matches!(resource_filter, NameFilter::PassAll)
1249 && matches!(prompt_filter, NameFilter::PassAll)
1250 && !self.hide_destructive
1251 && !self.read_only_only
1252 {
1253 return Ok(None);
1254 }
1255
1256 Ok(Some(BackendFilter {
1257 namespace: format!("{}{}", self.name, separator),
1258 tool_filter,
1259 resource_filter,
1260 prompt_filter,
1261 hide_destructive: self.hide_destructive,
1262 read_only_only: self.read_only_only,
1263 }))
1264 }
1265}
1266
1267impl ProxyConfig {
1268 pub fn load(path: &Path) -> Result<Self> {
1273 let content =
1274 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1275
1276 let mut config: Self = match path.extension().and_then(|e| e.to_str()) {
1277 #[cfg(feature = "yaml")]
1278 Some("yaml" | "yml") => yaml_serde::from_str(&content)
1279 .with_context(|| format!("parsing YAML {}", path.display()))?,
1280 #[cfg(not(feature = "yaml"))]
1281 Some("yaml" | "yml") => {
1282 anyhow::bail!(
1283 "YAML config requires the 'yaml' feature. Rebuild with: cargo install mcp-proxy --features yaml"
1284 );
1285 }
1286 _ => toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?,
1287 };
1288
1289 if let Some(ref mcp_json_path) = config.proxy.import_backends {
1291 let mcp_path = if std::path::Path::new(mcp_json_path).is_relative() {
1292 path.parent().unwrap_or(Path::new(".")).join(mcp_json_path)
1294 } else {
1295 std::path::PathBuf::from(mcp_json_path)
1296 };
1297
1298 let mcp_json = crate::mcp_json::McpJsonConfig::load(&mcp_path)
1299 .with_context(|| format!("importing backends from {}", mcp_path.display()))?;
1300
1301 let existing_names: HashSet<String> =
1302 config.backends.iter().map(|b| b.name.clone()).collect();
1303
1304 for backend in mcp_json.into_backends()? {
1305 if !existing_names.contains(&backend.name) {
1306 config.backends.push(backend);
1307 }
1308 }
1309 }
1310
1311 config.source_path = Some(path.to_path_buf());
1312 config.validate()?;
1313 Ok(config)
1314 }
1315
1316 pub fn from_mcp_json(path: &Path) -> Result<Self> {
1333 let mcp_json = crate::mcp_json::McpJsonConfig::load(path)?;
1334 let backends = mcp_json.into_backends()?;
1335
1336 let name = path
1338 .parent()
1339 .and_then(|p| p.file_name())
1340 .or_else(|| path.file_stem())
1341 .map(|s| s.to_string_lossy().into_owned())
1342 .unwrap_or_else(|| "mcp-proxy".to_string());
1343
1344 let config = Self {
1345 proxy: ProxySettings {
1346 name,
1347 version: default_version(),
1348 separator: default_separator(),
1349 listen: ListenConfig {
1350 host: default_host(),
1351 port: default_port(),
1352 },
1353 instructions: None,
1354 shutdown_timeout_seconds: default_shutdown_timeout(),
1355 hot_reload: false,
1356 import_backends: None,
1357 rate_limit: None,
1358 tool_discovery: false,
1359 tool_exposure: ToolExposure::default(),
1360 },
1361 backends,
1362 auth: None,
1363 performance: PerformanceConfig::default(),
1364 security: SecurityConfig::default(),
1365 cache: CacheBackendConfig::default(),
1366 observability: ObservabilityConfig::default(),
1367 composite_tools: Vec::new(),
1368 source_path: Some(path.to_path_buf()),
1369 };
1370
1371 config.validate()?;
1372 Ok(config)
1373 }
1374
1375 pub fn parse(toml: &str) -> Result<Self> {
1397 let config: Self = toml::from_str(toml).context("parsing config")?;
1398 config.validate()?;
1399 Ok(config)
1400 }
1401
1402 #[cfg(feature = "yaml")]
1424 pub fn parse_yaml(yaml: &str) -> Result<Self> {
1425 let config: Self = yaml_serde::from_str(yaml).context("parsing YAML config")?;
1426 config.validate()?;
1427 Ok(config)
1428 }
1429
1430 fn validate(&self) -> Result<()> {
1431 if self.backends.is_empty() {
1432 anyhow::bail!("at least one backend is required");
1433 }
1434
1435 match self.cache.backend.as_str() {
1437 "memory" => {}
1438 "redis" => {
1439 if self.cache.url.is_none() {
1440 anyhow::bail!(
1441 "cache.url is required when cache.backend = \"{}\"",
1442 self.cache.backend
1443 );
1444 }
1445 #[cfg(not(feature = "redis-cache"))]
1446 anyhow::bail!(
1447 "cache.backend = \"redis\" requires the 'redis-cache' feature. \
1448 Rebuild with: cargo install mcp-proxy --features redis-cache"
1449 );
1450 }
1451 "sqlite" => {
1452 if self.cache.url.is_none() {
1453 anyhow::bail!(
1454 "cache.url is required when cache.backend = \"{}\"",
1455 self.cache.backend
1456 );
1457 }
1458 #[cfg(not(feature = "sqlite-cache"))]
1459 anyhow::bail!(
1460 "cache.backend = \"sqlite\" requires the 'sqlite-cache' feature. \
1461 Rebuild with: cargo install mcp-proxy --features sqlite-cache"
1462 );
1463 }
1464 other => {
1465 anyhow::bail!(
1466 "unknown cache backend \"{}\", expected \"memory\", \"redis\", or \"sqlite\"",
1467 other
1468 );
1469 }
1470 }
1471
1472 if let Some(rl) = &self.proxy.rate_limit {
1474 if rl.requests == 0 {
1475 anyhow::bail!("proxy.rate_limit.requests must be > 0");
1476 }
1477 if rl.period_seconds == 0 {
1478 anyhow::bail!("proxy.rate_limit.period_seconds must be > 0");
1479 }
1480 }
1481
1482 if let Some(AuthConfig::Bearer {
1484 tokens,
1485 scoped_tokens,
1486 }) = &self.auth
1487 {
1488 if tokens.is_empty() && scoped_tokens.is_empty() {
1489 anyhow::bail!(
1490 "bearer auth requires at least one token in 'tokens' or 'scoped_tokens'"
1491 );
1492 }
1493 let mut seen_tokens = HashSet::new();
1495 for t in tokens {
1496 if !seen_tokens.insert(t.as_str()) {
1497 anyhow::bail!("duplicate bearer token in 'tokens'");
1498 }
1499 }
1500 for st in scoped_tokens {
1501 if !seen_tokens.insert(st.token.as_str()) {
1502 anyhow::bail!(
1503 "duplicate bearer token (appears in both 'tokens' and 'scoped_tokens' or duplicated within 'scoped_tokens')"
1504 );
1505 }
1506 if !st.allow_tools.is_empty() && !st.deny_tools.is_empty() {
1507 anyhow::bail!(
1508 "scoped_tokens: cannot specify both allow_tools and deny_tools for the same token"
1509 );
1510 }
1511 }
1512 }
1513
1514 if let Some(AuthConfig::OAuth {
1516 token_validation,
1517 client_id,
1518 client_secret,
1519 ..
1520 }) = &self.auth
1521 && matches!(
1522 token_validation,
1523 TokenValidationStrategy::Introspection | TokenValidationStrategy::Both
1524 )
1525 && (client_id.is_none() || client_secret.is_none())
1526 {
1527 anyhow::bail!("OAuth introspection requires both 'client_id' and 'client_secret'");
1528 }
1529
1530 if matches!(
1536 &self.auth,
1537 Some(AuthConfig::Jwt { .. }) | Some(AuthConfig::OAuth { .. })
1538 ) && self.security.admin_token.is_none()
1539 {
1540 anyhow::bail!(
1541 "security.admin_token is required when auth.type is 'jwt' or 'oauth': \
1542 the admin API has no token fallback for these auth types and would be \
1543 left unauthenticated. Set security.admin_token (supports ${{ENV_VAR}})."
1544 );
1545 }
1546
1547 let mut seen_names = HashSet::new();
1549 for backend in &self.backends {
1550 if !seen_names.insert(&backend.name) {
1551 anyhow::bail!("duplicate backend name '{}'", backend.name);
1552 }
1553 }
1554
1555 for backend in &self.backends {
1556 if !backend.headers.is_empty() && matches!(backend.transport, TransportType::Stdio) {
1557 anyhow::bail!(
1558 "backend '{}': headers require HTTP or WebSocket transport",
1559 backend.name
1560 );
1561 }
1562 let mut header_names = HashSet::new();
1563 for (name, value) in &backend.headers {
1564 axum::http::HeaderName::try_from(name.as_str())
1565 .with_context(|| format!("backend '{}': invalid header name", backend.name))?;
1566 if !header_names.insert(name.to_ascii_lowercase()) {
1567 anyhow::bail!(
1568 "backend '{}': duplicate header name '{}'",
1569 backend.name,
1570 name
1571 );
1572 }
1573 axum::http::HeaderValue::try_from(value.as_str()).map_err(|_| {
1574 anyhow::anyhow!(
1575 "backend '{}': invalid value for header '{}'",
1576 backend.name,
1577 name
1578 )
1579 })?;
1580 }
1581 match backend.transport {
1582 TransportType::Stdio => {
1583 if backend.command.is_none() {
1584 anyhow::bail!(
1585 "backend '{}': stdio transport requires 'command'",
1586 backend.name
1587 );
1588 }
1589 }
1590 TransportType::Http => {
1591 if backend.url.is_none() {
1592 anyhow::bail!("backend '{}': http transport requires 'url'", backend.name);
1593 }
1594 }
1595 TransportType::Websocket => {
1596 if backend.url.is_none() {
1597 anyhow::bail!(
1598 "backend '{}': websocket transport requires 'url'",
1599 backend.name
1600 );
1601 }
1602 }
1603 }
1604
1605 if let Some(cb) = &backend.circuit_breaker
1606 && (!cb.failure_rate_threshold.is_finite()
1607 || cb.failure_rate_threshold <= 0.0
1608 || cb.failure_rate_threshold > 1.0)
1609 {
1610 anyhow::bail!(
1611 "backend '{}': circuit_breaker.failure_rate_threshold must be in (0.0, 1.0]",
1612 backend.name
1613 );
1614 }
1615
1616 if let Some(cb) = &backend.circuit_breaker
1617 && cb.minimum_calls == 0
1618 {
1619 anyhow::bail!(
1620 "backend '{}': circuit_breaker.minimum_calls must be > 0",
1621 backend.name
1622 );
1623 }
1624
1625 if let Some(rl) = &backend.rate_limit
1626 && rl.period_seconds == 0
1627 {
1628 anyhow::bail!(
1629 "backend '{}': rate_limit.period_seconds must be > 0",
1630 backend.name
1631 );
1632 }
1633
1634 if let Some(rl) = &backend.rate_limit
1635 && rl.requests == 0
1636 {
1637 anyhow::bail!(
1638 "backend '{}': rate_limit.requests must be > 0",
1639 backend.name
1640 );
1641 }
1642
1643 if let Some(cc) = &backend.concurrency
1644 && cc.max_concurrent == 0
1645 {
1646 anyhow::bail!(
1647 "backend '{}': concurrency.max_concurrent must be > 0",
1648 backend.name
1649 );
1650 }
1651
1652 if !backend.expose_tools.is_empty() && !backend.hide_tools.is_empty() {
1653 anyhow::bail!(
1654 "backend '{}': cannot specify both expose_tools and hide_tools",
1655 backend.name
1656 );
1657 }
1658 if !backend.expose_resources.is_empty() && !backend.hide_resources.is_empty() {
1659 anyhow::bail!(
1660 "backend '{}': cannot specify both expose_resources and hide_resources",
1661 backend.name
1662 );
1663 }
1664 if !backend.expose_prompts.is_empty() && !backend.hide_prompts.is_empty() {
1665 anyhow::bail!(
1666 "backend '{}': cannot specify both expose_prompts and hide_prompts",
1667 backend.name
1668 );
1669 }
1670 }
1671
1672 let backend_names: HashSet<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
1674 for backend in &self.backends {
1675 if let Some(ref source) = backend.mirror_of {
1676 if !backend_names.contains(source.as_str()) {
1677 anyhow::bail!(
1678 "backend '{}': mirror_of references unknown backend '{}'",
1679 backend.name,
1680 source
1681 );
1682 }
1683 if source == &backend.name {
1684 anyhow::bail!(
1685 "backend '{}': mirror_of cannot reference itself",
1686 backend.name
1687 );
1688 }
1689 if backend.mirror_percent > 100 {
1690 anyhow::bail!(
1691 "backend '{}': mirror_percent must be 0-100, got {}",
1692 backend.name,
1693 backend.mirror_percent
1694 );
1695 }
1696 }
1697 }
1698
1699 for backend in &self.backends {
1701 if let Some(ref primary) = backend.failover_for {
1702 if !backend_names.contains(primary.as_str()) {
1703 anyhow::bail!(
1704 "backend '{}': failover_for references unknown backend '{}'",
1705 backend.name,
1706 primary
1707 );
1708 }
1709 if primary == &backend.name {
1710 anyhow::bail!(
1711 "backend '{}': failover_for cannot reference itself",
1712 backend.name
1713 );
1714 }
1715 }
1716 }
1717
1718 {
1720 let mut composite_names = HashSet::new();
1721 for ct in &self.composite_tools {
1722 if ct.name.is_empty() {
1723 anyhow::bail!("composite_tools: name must not be empty");
1724 }
1725 if ct.tools.is_empty() {
1726 anyhow::bail!(
1727 "composite_tools '{}': must reference at least one tool",
1728 ct.name
1729 );
1730 }
1731 if !composite_names.insert(&ct.name) {
1732 anyhow::bail!("duplicate composite_tools name '{}'", ct.name);
1733 }
1734 }
1735 }
1736
1737 for backend in &self.backends {
1739 if let Some(ref primary) = backend.canary_of {
1740 if !backend_names.contains(primary.as_str()) {
1741 anyhow::bail!(
1742 "backend '{}': canary_of references unknown backend '{}'",
1743 backend.name,
1744 primary
1745 );
1746 }
1747 if primary == &backend.name {
1748 anyhow::bail!(
1749 "backend '{}': canary_of cannot reference itself",
1750 backend.name
1751 );
1752 }
1753 if backend.weight == 0 || backend.weight > 100 {
1754 anyhow::bail!(
1755 "backend '{}': weight must be 1-100, got {}",
1756 backend.name,
1757 backend.weight
1758 );
1759 }
1760 }
1761 }
1762
1763 #[cfg(not(feature = "websocket"))]
1768 for backend in &self.backends {
1769 if matches!(backend.transport, TransportType::Websocket) {
1770 anyhow::bail!(
1771 "backend '{}': transport = \"websocket\" requires the 'websocket' feature. \
1772 Rebuild with: cargo install mcp-proxy --features websocket",
1773 backend.name
1774 );
1775 }
1776 }
1777
1778 #[cfg(not(feature = "discovery"))]
1780 if self.proxy.tool_exposure == ToolExposure::Search {
1781 anyhow::bail!(
1782 "tool_exposure = \"search\" requires the 'discovery' feature. \
1783 Rebuild with: cargo install mcp-proxy --features discovery"
1784 );
1785 }
1786
1787 for backend in &self.backends {
1789 let mut seen_tools = HashSet::new();
1790 for po in &backend.param_overrides {
1791 if po.tool.is_empty() {
1792 anyhow::bail!(
1793 "backend '{}': param_overrides.tool must not be empty",
1794 backend.name
1795 );
1796 }
1797 if !seen_tools.insert(&po.tool) {
1798 anyhow::bail!(
1799 "backend '{}': duplicate param_overrides for tool '{}'",
1800 backend.name,
1801 po.tool
1802 );
1803 }
1804 for hidden in &po.hide {
1807 if po.rename.contains_key(hidden) {
1808 anyhow::bail!(
1809 "backend '{}': param_overrides for tool '{}': \
1810 parameter '{}' cannot be both hidden and renamed",
1811 backend.name,
1812 po.tool,
1813 hidden
1814 );
1815 }
1816 }
1817 let mut rename_targets = HashSet::new();
1819 for target in po.rename.values() {
1820 if !rename_targets.insert(target) {
1821 anyhow::bail!(
1822 "backend '{}': param_overrides for tool '{}': \
1823 duplicate rename target '{}'",
1824 backend.name,
1825 po.tool,
1826 target
1827 );
1828 }
1829 }
1830 }
1831 }
1832
1833 Ok(())
1834 }
1835
1836 pub fn resolve_env_vars(&mut self) {
1839 for backend in &mut self.backends {
1840 for value in backend.env.values_mut() {
1841 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1842 && let Ok(env_val) = std::env::var(var_name)
1843 {
1844 *value = env_val;
1845 }
1846 }
1847 if let Some(ref mut token) = backend.bearer_token
1848 && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1849 && let Ok(env_val) = std::env::var(var_name)
1850 {
1851 *token = env_val;
1852 }
1853 for value in backend.headers.values_mut() {
1854 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1855 && let Ok(env_val) = std::env::var(var_name)
1856 {
1857 *value = env_val;
1858 }
1859 }
1860 }
1861
1862 if let Some(AuthConfig::Bearer {
1864 tokens,
1865 scoped_tokens,
1866 }) = &mut self.auth
1867 {
1868 for token in tokens.iter_mut() {
1869 if let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1870 && let Ok(env_val) = std::env::var(var_name)
1871 {
1872 *token = env_val;
1873 }
1874 }
1875 for st in scoped_tokens.iter_mut() {
1876 if let Some(var_name) = st
1877 .token
1878 .strip_prefix("${")
1879 .and_then(|s| s.strip_suffix('}'))
1880 && let Ok(env_val) = std::env::var(var_name)
1881 {
1882 st.token = env_val;
1883 }
1884 }
1885 }
1886
1887 if let Some(ref mut token) = self.security.admin_token
1889 && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1890 && let Ok(env_val) = std::env::var(var_name)
1891 {
1892 *token = env_val;
1893 }
1894
1895 if let Some(AuthConfig::OAuth { client_secret, .. }) = &mut self.auth
1897 && let Some(secret) = client_secret
1898 && let Some(var_name) = secret.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1899 && let Ok(env_val) = std::env::var(var_name)
1900 {
1901 *secret = env_val;
1902 }
1903 }
1904
1905 pub fn check_env_vars(&self) -> Vec<String> {
1932 fn is_unset_env_ref(value: &str) -> Option<&str> {
1933 let var_name = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))?;
1934 if std::env::var(var_name).is_err() {
1935 Some(var_name)
1936 } else {
1937 None
1938 }
1939 }
1940
1941 let mut warnings = Vec::new();
1942
1943 for backend in &self.backends {
1944 if let Some(ref token) = backend.bearer_token
1946 && let Some(var) = is_unset_env_ref(token)
1947 {
1948 warnings.push(format!(
1949 "backend '{}': bearer_token references unset env var '{}'",
1950 backend.name, var
1951 ));
1952 }
1953 for (key, value) in &backend.headers {
1955 if let Some(var) = is_unset_env_ref(value) {
1956 warnings.push(format!(
1957 "backend '{}': headers.{} references unset env var '{}'",
1958 backend.name, key, var
1959 ));
1960 }
1961 }
1962 for (key, value) in &backend.env {
1964 if let Some(var) = is_unset_env_ref(value) {
1965 warnings.push(format!(
1966 "backend '{}': env.{} references unset env var '{}'",
1967 backend.name, key, var
1968 ));
1969 }
1970 }
1971 }
1972
1973 match &self.auth {
1974 Some(AuthConfig::Bearer {
1975 tokens,
1976 scoped_tokens,
1977 }) => {
1978 for (i, token) in tokens.iter().enumerate() {
1979 if let Some(var) = is_unset_env_ref(token) {
1980 warnings.push(format!(
1981 "auth.bearer: tokens[{}] references unset env var '{}'",
1982 i, var
1983 ));
1984 }
1985 }
1986 for (i, st) in scoped_tokens.iter().enumerate() {
1987 if let Some(var) = is_unset_env_ref(&st.token) {
1988 warnings.push(format!(
1989 "auth.bearer: scoped_tokens[{}] references unset env var '{}'",
1990 i, var
1991 ));
1992 }
1993 }
1994 }
1995 Some(AuthConfig::OAuth {
1996 client_secret: Some(secret),
1997 ..
1998 }) => {
1999 if let Some(var) = is_unset_env_ref(secret) {
2000 warnings.push(format!(
2001 "auth.oauth: client_secret references unset env var '{}'",
2002 var
2003 ));
2004 }
2005 }
2006 _ => {}
2007 }
2008
2009 warnings
2010 }
2011}
2012
2013#[cfg(test)]
2014mod tests {
2015 use super::*;
2016
2017 fn minimal_config() -> &'static str {
2018 r#"
2019 [proxy]
2020 name = "test"
2021 [proxy.listen]
2022
2023 [[backends]]
2024 name = "echo"
2025 transport = "stdio"
2026 command = "echo"
2027 "#
2028 }
2029
2030 #[test]
2031 fn reject_invalid_resilience_builder_config() {
2032 let base = "[proxy]\nname = 'test'\n[proxy.listen]\n[[backends]]\nname = 'api'\ntransport = 'http'\nurl = 'http://localhost:1'\n";
2033 for extra in [
2034 "[backends.circuit_breaker]\nminimum_calls = 0",
2035 "[backends.circuit_breaker]\nfailure_rate_threshold = nan",
2036 "[backends.rate_limit]\nrequests = 1\nperiod_seconds = 0",
2037 ] {
2038 assert!(ProxyConfig::parse(&format!("{base}{extra}")).is_err());
2039 }
2040 }
2041
2042 #[test]
2043 fn test_parse_minimal_config() {
2044 let config = ProxyConfig::parse(minimal_config()).unwrap();
2045 assert_eq!(config.proxy.name, "test");
2046 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);
2053 assert_eq!(config.backends[0].name, "echo");
2054 assert!(config.auth.is_none());
2055 assert!(!config.observability.audit);
2056 assert!(!config.observability.metrics.enabled);
2057 }
2058
2059 #[test]
2060 fn test_parse_full_config() {
2061 let toml = r#"
2062 [proxy]
2063 name = "full-gw"
2064 version = "2.0.0"
2065 separator = "."
2066 shutdown_timeout_seconds = 60
2067 hot_reload = true
2068 instructions = "A test proxy"
2069 [proxy.listen]
2070 host = "0.0.0.0"
2071 port = 9090
2072
2073 [[backends]]
2074 name = "files"
2075 transport = "stdio"
2076 command = "file-server"
2077 args = ["--root", "/tmp"]
2078 expose_tools = ["read_file"]
2079
2080 [backends.env]
2081 LOG_LEVEL = "debug"
2082
2083 [backends.timeout]
2084 seconds = 30
2085
2086 [backends.concurrency]
2087 max_concurrent = 5
2088
2089 [backends.rate_limit]
2090 requests = 100
2091 period_seconds = 10
2092
2093 [backends.circuit_breaker]
2094 failure_rate_threshold = 0.5
2095 minimum_calls = 10
2096 wait_duration_seconds = 60
2097 permitted_calls_in_half_open = 2
2098
2099 [backends.cache]
2100 resource_ttl_seconds = 300
2101 tool_ttl_seconds = 60
2102 max_entries = 500
2103
2104 [[backends.aliases]]
2105 from = "read_file"
2106 to = "read"
2107
2108 [[backends]]
2109 name = "remote"
2110 transport = "http"
2111 url = "http://localhost:3000"
2112
2113 [observability]
2114 audit = true
2115 log_level = "debug"
2116 json_logs = true
2117
2118 [observability.metrics]
2119 enabled = true
2120
2121 [observability.tracing]
2122 enabled = true
2123 endpoint = "http://jaeger:4317"
2124 service_name = "test-gw"
2125
2126 [performance]
2127 coalesce_requests = true
2128
2129 [security]
2130 max_argument_size = 1048576
2131 "#;
2132
2133 let config = ProxyConfig::parse(toml).unwrap();
2134 assert_eq!(config.proxy.name, "full-gw");
2135 assert_eq!(config.proxy.version, "2.0.0");
2136 assert_eq!(config.proxy.separator, ".");
2137 assert_eq!(config.proxy.shutdown_timeout_seconds, 60);
2138 assert!(config.proxy.hot_reload);
2139 assert_eq!(config.proxy.instructions.as_deref(), Some("A test proxy"));
2140 assert_eq!(config.proxy.listen.host, "0.0.0.0");
2141 assert_eq!(config.proxy.listen.port, 9090);
2142
2143 assert_eq!(config.backends.len(), 2);
2144
2145 let files = &config.backends[0];
2146 assert_eq!(files.command.as_deref(), Some("file-server"));
2147 assert_eq!(files.args, vec!["--root", "/tmp"]);
2148 assert_eq!(files.expose_tools, vec!["read_file"]);
2149 assert_eq!(files.env.get("LOG_LEVEL").unwrap(), "debug");
2150 assert_eq!(files.timeout.as_ref().unwrap().seconds, 30);
2151 assert_eq!(files.concurrency.as_ref().unwrap().max_concurrent, 5);
2152 assert_eq!(files.rate_limit.as_ref().unwrap().requests, 100);
2153 assert_eq!(files.cache.as_ref().unwrap().resource_ttl_seconds, 300);
2154 assert_eq!(files.cache.as_ref().unwrap().tool_ttl_seconds, 60);
2155 assert_eq!(files.cache.as_ref().unwrap().max_entries, 500);
2156 assert_eq!(files.aliases.len(), 1);
2157 assert_eq!(files.aliases[0].from, "read_file");
2158 assert_eq!(files.aliases[0].to, "read");
2159
2160 let cb = files.circuit_breaker.as_ref().unwrap();
2161 assert_eq!(cb.failure_rate_threshold, 0.5);
2162 assert_eq!(cb.minimum_calls, 10);
2163 assert_eq!(cb.wait_duration_seconds, 60);
2164 assert_eq!(cb.permitted_calls_in_half_open, 2);
2165
2166 let remote = &config.backends[1];
2167 assert_eq!(remote.url.as_deref(), Some("http://localhost:3000"));
2168
2169 assert!(config.observability.audit);
2170 assert_eq!(config.observability.log_level, "debug");
2171 assert!(config.observability.json_logs);
2172 assert!(config.observability.metrics.enabled);
2173 assert!(config.observability.tracing.enabled);
2174 assert_eq!(config.observability.tracing.endpoint, "http://jaeger:4317");
2175
2176 assert!(config.performance.coalesce_requests);
2177 assert_eq!(config.security.max_argument_size, Some(1048576));
2178 }
2179
2180 #[test]
2181 fn test_parse_bearer_auth() {
2182 let toml = r#"
2183 [proxy]
2184 name = "auth-gw"
2185 [proxy.listen]
2186
2187 [[backends]]
2188 name = "echo"
2189 transport = "stdio"
2190 command = "echo"
2191
2192 [auth]
2193 type = "bearer"
2194 tokens = ["token-1", "token-2"]
2195 "#;
2196
2197 let config = ProxyConfig::parse(toml).unwrap();
2198 match &config.auth {
2199 Some(AuthConfig::Bearer { tokens, .. }) => {
2200 assert_eq!(tokens, &["token-1", "token-2"]);
2201 }
2202 other => panic!("expected Bearer auth, got: {:?}", other),
2203 }
2204 }
2205
2206 #[test]
2207 fn test_parse_jwt_auth_with_rbac() {
2208 let toml = r#"
2209 [proxy]
2210 name = "jwt-gw"
2211 [proxy.listen]
2212
2213 [[backends]]
2214 name = "echo"
2215 transport = "stdio"
2216 command = "echo"
2217
2218 [auth]
2219 type = "jwt"
2220 issuer = "https://auth.example.com"
2221 audience = "mcp-proxy"
2222 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2223
2224 [[auth.roles]]
2225 name = "reader"
2226 allow_tools = ["echo/read"]
2227
2228 [[auth.roles]]
2229 name = "admin"
2230
2231 [auth.role_mapping]
2232 claim = "scope"
2233 mapping = { "mcp:read" = "reader", "mcp:admin" = "admin" }
2234
2235 [security]
2236 admin_token = "admin-secret"
2237 "#;
2238
2239 let config = ProxyConfig::parse(toml).unwrap();
2240 match &config.auth {
2241 Some(AuthConfig::Jwt {
2242 issuer,
2243 audience,
2244 jwks_uri,
2245 roles,
2246 role_mapping,
2247 }) => {
2248 assert_eq!(issuer, "https://auth.example.com");
2249 assert_eq!(audience, "mcp-proxy");
2250 assert_eq!(jwks_uri, "https://auth.example.com/.well-known/jwks.json");
2251 assert_eq!(roles.len(), 2);
2252 assert_eq!(roles[0].name, "reader");
2253 assert_eq!(roles[0].allow_tools, vec!["echo/read"]);
2254 let mapping = role_mapping.as_ref().unwrap();
2255 assert_eq!(mapping.claim, "scope");
2256 assert_eq!(mapping.mapping.get("mcp:read").unwrap(), "reader");
2257 }
2258 other => panic!("expected Jwt auth, got: {:?}", other),
2259 }
2260 }
2261
2262 #[test]
2267 fn test_reject_no_backends() {
2268 let toml = r#"
2269 [proxy]
2270 name = "empty"
2271 [proxy.listen]
2272 "#;
2273
2274 let err = ProxyConfig::parse(toml).unwrap_err();
2275 assert!(
2276 format!("{err}").contains("at least one backend"),
2277 "unexpected error: {err}"
2278 );
2279 }
2280
2281 #[test]
2282 fn test_reject_stdio_without_command() {
2283 let toml = r#"
2284 [proxy]
2285 name = "bad"
2286 [proxy.listen]
2287
2288 [[backends]]
2289 name = "broken"
2290 transport = "stdio"
2291 "#;
2292
2293 let err = ProxyConfig::parse(toml).unwrap_err();
2294 assert!(
2295 format!("{err}").contains("stdio transport requires 'command'"),
2296 "unexpected error: {err}"
2297 );
2298 }
2299
2300 #[test]
2301 fn test_reject_http_without_url() {
2302 let toml = r#"
2303 [proxy]
2304 name = "bad"
2305 [proxy.listen]
2306
2307 [[backends]]
2308 name = "broken"
2309 transport = "http"
2310 "#;
2311
2312 let err = ProxyConfig::parse(toml).unwrap_err();
2313 assert!(
2314 format!("{err}").contains("http transport requires 'url'"),
2315 "unexpected error: {err}"
2316 );
2317 }
2318
2319 #[test]
2320 fn test_reject_invalid_circuit_breaker_threshold() {
2321 let toml = r#"
2322 [proxy]
2323 name = "bad"
2324 [proxy.listen]
2325
2326 [[backends]]
2327 name = "svc"
2328 transport = "stdio"
2329 command = "echo"
2330
2331 [backends.circuit_breaker]
2332 failure_rate_threshold = 1.5
2333 "#;
2334
2335 let err = ProxyConfig::parse(toml).unwrap_err();
2336 assert!(
2337 format!("{err}").contains("failure_rate_threshold must be in (0.0, 1.0]"),
2338 "unexpected error: {err}"
2339 );
2340 }
2341
2342 #[test]
2343 fn test_reject_zero_rate_limit() {
2344 let toml = r#"
2345 [proxy]
2346 name = "bad"
2347 [proxy.listen]
2348
2349 [[backends]]
2350 name = "svc"
2351 transport = "stdio"
2352 command = "echo"
2353
2354 [backends.rate_limit]
2355 requests = 0
2356 "#;
2357
2358 let err = ProxyConfig::parse(toml).unwrap_err();
2359 assert!(
2360 format!("{err}").contains("rate_limit.requests must be > 0"),
2361 "unexpected error: {err}"
2362 );
2363 }
2364
2365 #[test]
2366 fn test_reject_zero_concurrency() {
2367 let toml = r#"
2368 [proxy]
2369 name = "bad"
2370 [proxy.listen]
2371
2372 [[backends]]
2373 name = "svc"
2374 transport = "stdio"
2375 command = "echo"
2376
2377 [backends.concurrency]
2378 max_concurrent = 0
2379 "#;
2380
2381 let err = ProxyConfig::parse(toml).unwrap_err();
2382 assert!(
2383 format!("{err}").contains("concurrency.max_concurrent must be > 0"),
2384 "unexpected error: {err}"
2385 );
2386 }
2387
2388 #[test]
2389 fn test_reject_expose_and_hide_tools() {
2390 let toml = r#"
2391 [proxy]
2392 name = "bad"
2393 [proxy.listen]
2394
2395 [[backends]]
2396 name = "svc"
2397 transport = "stdio"
2398 command = "echo"
2399 expose_tools = ["read"]
2400 hide_tools = ["write"]
2401 "#;
2402
2403 let err = ProxyConfig::parse(toml).unwrap_err();
2404 assert!(
2405 format!("{err}").contains("cannot specify both expose_tools and hide_tools"),
2406 "unexpected error: {err}"
2407 );
2408 }
2409
2410 #[test]
2411 fn test_reject_expose_and_hide_resources() {
2412 let toml = r#"
2413 [proxy]
2414 name = "bad"
2415 [proxy.listen]
2416
2417 [[backends]]
2418 name = "svc"
2419 transport = "stdio"
2420 command = "echo"
2421 expose_resources = ["file:///a"]
2422 hide_resources = ["file:///b"]
2423 "#;
2424
2425 let err = ProxyConfig::parse(toml).unwrap_err();
2426 assert!(
2427 format!("{err}").contains("cannot specify both expose_resources and hide_resources"),
2428 "unexpected error: {err}"
2429 );
2430 }
2431
2432 #[test]
2433 fn test_reject_expose_and_hide_prompts() {
2434 let toml = r#"
2435 [proxy]
2436 name = "bad"
2437 [proxy.listen]
2438
2439 [[backends]]
2440 name = "svc"
2441 transport = "stdio"
2442 command = "echo"
2443 expose_prompts = ["help"]
2444 hide_prompts = ["admin"]
2445 "#;
2446
2447 let err = ProxyConfig::parse(toml).unwrap_err();
2448 assert!(
2449 format!("{err}").contains("cannot specify both expose_prompts and hide_prompts"),
2450 "unexpected error: {err}"
2451 );
2452 }
2453
2454 #[test]
2459 fn test_resolve_env_vars() {
2460 unsafe { std::env::set_var("MCP_GW_TEST_TOKEN", "secret-123") };
2462
2463 let toml = r#"
2464 [proxy]
2465 name = "env-test"
2466 [proxy.listen]
2467
2468 [[backends]]
2469 name = "svc"
2470 transport = "stdio"
2471 command = "echo"
2472
2473 [backends.env]
2474 API_TOKEN = "${MCP_GW_TEST_TOKEN}"
2475 STATIC_VAL = "unchanged"
2476 "#;
2477
2478 let mut config = ProxyConfig::parse(toml).unwrap();
2479 config.resolve_env_vars();
2480
2481 assert_eq!(
2482 config.backends[0].env.get("API_TOKEN").unwrap(),
2483 "secret-123"
2484 );
2485 assert_eq!(
2486 config.backends[0].env.get("STATIC_VAL").unwrap(),
2487 "unchanged"
2488 );
2489
2490 unsafe { std::env::remove_var("MCP_GW_TEST_TOKEN") };
2492 }
2493
2494 #[test]
2495 fn reject_invalid_or_unsupported_headers() {
2496 let base = "[proxy]\nname = 'test'\n[proxy.listen]\n[[backends]]\nname = 'api'\ntransport = 'http'\nurl = 'http://localhost:1'\n";
2497 for extra in [
2498 "headres = {}",
2499 "[backends.headers]\n'bad name' = 'value'",
2500 "[backends.headers]\nX = 'value'\nx = 'other'",
2501 "[backends.headers]\nX = \"line\\nvalue\"",
2502 ] {
2503 assert!(
2504 ProxyConfig::parse(&format!("{base}{extra}")).is_err(),
2505 "accepted: {extra}"
2506 );
2507 }
2508 let stdio = "[proxy]\nname = 'test'\n[proxy.listen]\n[[backends]]\nname = 'local'\ntransport = 'stdio'\ncommand = 'echo'\nheaders = { X = 'value' }";
2509 assert!(ProxyConfig::parse(stdio).is_err());
2510 }
2511
2512 #[test]
2513 fn test_parse_backend_headers() {
2514 let toml = r#"
2515 [proxy]
2516 name = "hdr-gw"
2517 [proxy.listen]
2518
2519 [[backends]]
2520 name = "signoz"
2521 transport = "http"
2522 url = "http://localhost:3000"
2523 bearer_token = "tok"
2524
2525 [backends.headers]
2526 "X-API-Key" = "key123"
2527 "X-Tenant" = "acme"
2528 "#;
2529
2530 let config = ProxyConfig::parse(toml).unwrap();
2531 let headers = &config.backends[0].headers;
2532 assert_eq!(headers.get("X-API-Key").map(String::as_str), Some("key123"));
2533 assert_eq!(headers.get("X-Tenant").map(String::as_str), Some("acme"));
2534 assert!(config.backends[0].bearer_token.is_some());
2535 let empty = ProxyConfig::parse(
2537 r#"
2538 [proxy]
2539 name = "g"
2540 [proxy.listen]
2541
2542 [[backends]]
2543 name = "db"
2544 transport = "http"
2545 url = "http://localhost:1"
2546 "#,
2547 )
2548 .unwrap();
2549 assert!(empty.backends[0].headers.is_empty());
2550 }
2551
2552 #[test]
2553 fn test_resolve_headers_env_var() {
2554 unsafe { std::env::set_var("MCP_GW_TEST_HEADER", "resolved-value") };
2555 let toml = r#"
2556 [proxy]
2557 name = "hdr-env-gw"
2558 [proxy.listen]
2559
2560 [[backends]]
2561 name = "signoz"
2562 transport = "http"
2563 url = "http://localhost:3000"
2564
2565 [backends.headers]
2566 "X-API-Key" = "${MCP_GW_TEST_HEADER}"
2567 "X-Literal" = "plain"
2568 "#;
2569 let mut config = ProxyConfig::parse(toml).unwrap();
2570 config.resolve_env_vars();
2571 unsafe { std::env::remove_var("MCP_GW_TEST_HEADER") };
2572 let headers = &config.backends[0].headers;
2573 assert_eq!(
2574 headers.get("X-API-Key").map(String::as_str),
2575 Some("resolved-value")
2576 );
2577 assert_eq!(headers.get("X-Literal").map(String::as_str), Some("plain"));
2578 }
2579
2580 #[test]
2581 fn test_parse_bearer_token_and_forward_auth() {
2582 let toml = r#"
2583 [proxy]
2584 name = "token-gw"
2585 [proxy.listen]
2586
2587 [[backends]]
2588 name = "github"
2589 transport = "http"
2590 url = "http://localhost:3000"
2591 bearer_token = "ghp_abc123"
2592 forward_auth = true
2593
2594 [[backends]]
2595 name = "db"
2596 transport = "http"
2597 url = "http://localhost:5432"
2598 "#;
2599
2600 let config = ProxyConfig::parse(toml).unwrap();
2601 assert_eq!(
2602 config.backends[0].bearer_token.as_deref(),
2603 Some("ghp_abc123")
2604 );
2605 assert!(config.backends[0].forward_auth);
2606 assert!(config.backends[1].bearer_token.is_none());
2607 assert!(!config.backends[1].forward_auth);
2608 }
2609
2610 #[test]
2611 fn test_resolve_bearer_token_env_var() {
2612 unsafe { std::env::set_var("MCP_GW_TEST_BEARER", "resolved-token") };
2613
2614 let toml = r#"
2615 [proxy]
2616 name = "env-token"
2617 [proxy.listen]
2618
2619 [[backends]]
2620 name = "api"
2621 transport = "http"
2622 url = "http://localhost:3000"
2623 bearer_token = "${MCP_GW_TEST_BEARER}"
2624 "#;
2625
2626 let mut config = ProxyConfig::parse(toml).unwrap();
2627 config.resolve_env_vars();
2628
2629 assert_eq!(
2630 config.backends[0].bearer_token.as_deref(),
2631 Some("resolved-token")
2632 );
2633
2634 unsafe { std::env::remove_var("MCP_GW_TEST_BEARER") };
2635 }
2636
2637 #[test]
2638 fn test_parse_outlier_detection() {
2639 let toml = r#"
2640 [proxy]
2641 name = "od-gw"
2642 [proxy.listen]
2643
2644 [[backends]]
2645 name = "flaky"
2646 transport = "http"
2647 url = "http://localhost:8080"
2648
2649 [backends.outlier_detection]
2650 consecutive_errors = 3
2651 interval_seconds = 5
2652 base_ejection_seconds = 60
2653 max_ejection_percent = 25
2654 "#;
2655
2656 let config = ProxyConfig::parse(toml).unwrap();
2657 let od = config.backends[0]
2658 .outlier_detection
2659 .as_ref()
2660 .expect("should have outlier_detection");
2661 assert_eq!(od.consecutive_errors, 3);
2662 assert_eq!(od.interval_seconds, 5);
2663 assert_eq!(od.base_ejection_seconds, 60);
2664 assert_eq!(od.max_ejection_percent, 25);
2665 }
2666
2667 #[test]
2668 fn test_parse_outlier_detection_defaults() {
2669 let toml = r#"
2670 [proxy]
2671 name = "od-gw"
2672 [proxy.listen]
2673
2674 [[backends]]
2675 name = "flaky"
2676 transport = "http"
2677 url = "http://localhost:8080"
2678
2679 [backends.outlier_detection]
2680 "#;
2681
2682 let config = ProxyConfig::parse(toml).unwrap();
2683 let od = config.backends[0]
2684 .outlier_detection
2685 .as_ref()
2686 .expect("should have outlier_detection");
2687 assert_eq!(od.consecutive_errors, 5);
2688 assert_eq!(od.interval_seconds, 10);
2689 assert_eq!(od.base_ejection_seconds, 30);
2690 assert_eq!(od.max_ejection_percent, 50);
2691 }
2692
2693 #[test]
2694 fn test_parse_mirror_config() {
2695 let toml = r#"
2696 [proxy]
2697 name = "mirror-gw"
2698 [proxy.listen]
2699
2700 [[backends]]
2701 name = "api"
2702 transport = "http"
2703 url = "http://localhost:8080"
2704
2705 [[backends]]
2706 name = "api-v2"
2707 transport = "http"
2708 url = "http://localhost:8081"
2709 mirror_of = "api"
2710 mirror_percent = 10
2711 "#;
2712
2713 let config = ProxyConfig::parse(toml).unwrap();
2714 assert!(config.backends[0].mirror_of.is_none());
2715 assert_eq!(config.backends[1].mirror_of.as_deref(), Some("api"));
2716 assert_eq!(config.backends[1].mirror_percent, 10);
2717 }
2718
2719 #[test]
2720 fn test_mirror_percent_defaults_to_100() {
2721 let toml = r#"
2722 [proxy]
2723 name = "mirror-gw"
2724 [proxy.listen]
2725
2726 [[backends]]
2727 name = "api"
2728 transport = "http"
2729 url = "http://localhost:8080"
2730
2731 [[backends]]
2732 name = "api-v2"
2733 transport = "http"
2734 url = "http://localhost:8081"
2735 mirror_of = "api"
2736 "#;
2737
2738 let config = ProxyConfig::parse(toml).unwrap();
2739 assert_eq!(config.backends[1].mirror_percent, 100);
2740 }
2741
2742 #[test]
2743 fn test_reject_mirror_unknown_backend() {
2744 let toml = r#"
2745 [proxy]
2746 name = "bad"
2747 [proxy.listen]
2748
2749 [[backends]]
2750 name = "api-v2"
2751 transport = "http"
2752 url = "http://localhost:8081"
2753 mirror_of = "nonexistent"
2754 "#;
2755
2756 let err = ProxyConfig::parse(toml).unwrap_err();
2757 assert!(
2758 format!("{err}").contains("mirror_of references unknown backend"),
2759 "unexpected error: {err}"
2760 );
2761 }
2762
2763 #[test]
2764 fn test_reject_mirror_percent_over_100() {
2765 let toml = r#"
2766 [proxy]
2767 name = "bad"
2768 [proxy.listen]
2769
2770 [[backends]]
2771 name = "primary"
2772 transport = "stdio"
2773 command = "echo"
2774
2775 [[backends]]
2776 name = "mirror"
2777 transport = "stdio"
2778 command = "echo"
2779 mirror_of = "primary"
2780 mirror_percent = 101
2781 "#;
2782 let err = ProxyConfig::parse(toml).unwrap_err();
2783 assert!(
2784 format!("{err}").contains("mirror_percent must be 0-100"),
2785 "unexpected error: {err}"
2786 );
2787 }
2788
2789 #[test]
2790 fn test_reject_canary_weight_over_100() {
2791 let toml = r#"
2792 [proxy]
2793 name = "bad"
2794 [proxy.listen]
2795
2796 [[backends]]
2797 name = "primary"
2798 transport = "stdio"
2799 command = "echo"
2800
2801 [[backends]]
2802 name = "canary"
2803 transport = "stdio"
2804 command = "echo"
2805 canary_of = "primary"
2806 weight = 101
2807 "#;
2808 let err = ProxyConfig::parse(toml).unwrap_err();
2809 assert!(
2810 format!("{err}").contains("weight must be 1-100"),
2811 "unexpected error: {err}"
2812 );
2813 }
2814
2815 #[test]
2816 fn test_reject_mirror_self() {
2817 let toml = r#"
2818 [proxy]
2819 name = "bad"
2820 [proxy.listen]
2821
2822 [[backends]]
2823 name = "api"
2824 transport = "http"
2825 url = "http://localhost:8080"
2826 mirror_of = "api"
2827 "#;
2828
2829 let err = ProxyConfig::parse(toml).unwrap_err();
2830 assert!(
2831 format!("{err}").contains("mirror_of cannot reference itself"),
2832 "unexpected error: {err}"
2833 );
2834 }
2835
2836 #[test]
2837 fn test_parse_hedging_config() {
2838 let toml = r#"
2839 [proxy]
2840 name = "hedge-gw"
2841 [proxy.listen]
2842
2843 [[backends]]
2844 name = "api"
2845 transport = "http"
2846 url = "http://localhost:8080"
2847
2848 [backends.hedging]
2849 delay_ms = 150
2850 max_hedges = 2
2851 "#;
2852
2853 let config = ProxyConfig::parse(toml).unwrap();
2854 let hedge = config.backends[0]
2855 .hedging
2856 .as_ref()
2857 .expect("should have hedging");
2858 assert_eq!(hedge.delay_ms, 150);
2859 assert_eq!(hedge.max_hedges, 2);
2860 }
2861
2862 #[test]
2863 fn test_parse_hedging_defaults() {
2864 let toml = r#"
2865 [proxy]
2866 name = "hedge-gw"
2867 [proxy.listen]
2868
2869 [[backends]]
2870 name = "api"
2871 transport = "http"
2872 url = "http://localhost:8080"
2873
2874 [backends.hedging]
2875 "#;
2876
2877 let config = ProxyConfig::parse(toml).unwrap();
2878 let hedge = config.backends[0]
2879 .hedging
2880 .as_ref()
2881 .expect("should have hedging");
2882 assert_eq!(hedge.delay_ms, 200);
2883 assert_eq!(hedge.max_hedges, 1);
2884 }
2885
2886 #[test]
2891 fn test_build_filter_allowlist() {
2892 let toml = r#"
2893 [proxy]
2894 name = "filter"
2895 [proxy.listen]
2896
2897 [[backends]]
2898 name = "svc"
2899 transport = "stdio"
2900 command = "echo"
2901 expose_tools = ["read", "list"]
2902 "#;
2903
2904 let config = ProxyConfig::parse(toml).unwrap();
2905 let filter = config.backends[0]
2906 .build_filter(&config.proxy.separator)
2907 .unwrap()
2908 .expect("should have filter");
2909 assert_eq!(filter.namespace, "svc/");
2910 assert!(filter.tool_filter.allows("read"));
2911 assert!(filter.tool_filter.allows("list"));
2912 assert!(!filter.tool_filter.allows("delete"));
2913 }
2914
2915 #[test]
2916 fn test_build_filter_denylist() {
2917 let toml = r#"
2918 [proxy]
2919 name = "filter"
2920 [proxy.listen]
2921
2922 [[backends]]
2923 name = "svc"
2924 transport = "stdio"
2925 command = "echo"
2926 hide_tools = ["delete", "write"]
2927 "#;
2928
2929 let config = ProxyConfig::parse(toml).unwrap();
2930 let filter = config.backends[0]
2931 .build_filter(&config.proxy.separator)
2932 .unwrap()
2933 .expect("should have filter");
2934 assert!(filter.tool_filter.allows("read"));
2935 assert!(!filter.tool_filter.allows("delete"));
2936 assert!(!filter.tool_filter.allows("write"));
2937 }
2938
2939 #[test]
2940 fn test_parse_inject_args() {
2941 let toml = r#"
2942 [proxy]
2943 name = "inject-gw"
2944 [proxy.listen]
2945
2946 [[backends]]
2947 name = "db"
2948 transport = "http"
2949 url = "http://localhost:8080"
2950
2951 [backends.default_args]
2952 timeout = 30
2953
2954 [[backends.inject_args]]
2955 tool = "query"
2956 args = { read_only = true, max_rows = 1000 }
2957
2958 [[backends.inject_args]]
2959 tool = "dangerous_op"
2960 args = { dry_run = true }
2961 overwrite = true
2962 "#;
2963
2964 let config = ProxyConfig::parse(toml).unwrap();
2965 let backend = &config.backends[0];
2966
2967 assert_eq!(backend.default_args.len(), 1);
2968 assert_eq!(backend.default_args["timeout"], 30);
2969
2970 assert_eq!(backend.inject_args.len(), 2);
2971 assert_eq!(backend.inject_args[0].tool, "query");
2972 assert_eq!(backend.inject_args[0].args["read_only"], true);
2973 assert_eq!(backend.inject_args[0].args["max_rows"], 1000);
2974 assert!(!backend.inject_args[0].overwrite);
2975
2976 assert_eq!(backend.inject_args[1].tool, "dangerous_op");
2977 assert_eq!(backend.inject_args[1].args["dry_run"], true);
2978 assert!(backend.inject_args[1].overwrite);
2979 }
2980
2981 #[test]
2982 fn test_parse_inject_args_defaults_to_empty() {
2983 let config = ProxyConfig::parse(minimal_config()).unwrap();
2984 assert!(config.backends[0].default_args.is_empty());
2985 assert!(config.backends[0].inject_args.is_empty());
2986 }
2987
2988 #[test]
2989 fn test_build_filter_none_when_no_filtering() {
2990 let config = ProxyConfig::parse(minimal_config()).unwrap();
2991 assert!(
2992 config.backends[0]
2993 .build_filter(&config.proxy.separator)
2994 .unwrap()
2995 .is_none()
2996 );
2997 }
2998
2999 #[test]
3000 fn test_validate_rejects_duplicate_backend_names() {
3001 let toml = r#"
3002 [proxy]
3003 name = "test"
3004 [proxy.listen]
3005
3006 [[backends]]
3007 name = "echo"
3008 transport = "stdio"
3009 command = "echo"
3010
3011 [[backends]]
3012 name = "echo"
3013 transport = "stdio"
3014 command = "cat"
3015 "#;
3016 let err = ProxyConfig::parse(toml).unwrap_err();
3017 assert!(
3018 err.to_string().contains("duplicate backend name"),
3019 "expected duplicate error, got: {}",
3020 err
3021 );
3022 }
3023
3024 #[test]
3025 fn test_validate_global_rate_limit_zero_requests() {
3026 let toml = r#"
3027 [proxy]
3028 name = "test"
3029 [proxy.listen]
3030 [proxy.rate_limit]
3031 requests = 0
3032
3033 [[backends]]
3034 name = "echo"
3035 transport = "stdio"
3036 command = "echo"
3037 "#;
3038 let err = ProxyConfig::parse(toml).unwrap_err();
3039 assert!(err.to_string().contains("requests must be > 0"));
3040 }
3041
3042 #[test]
3043 fn test_validate_jwt_requires_admin_token() {
3044 let toml = r#"
3047 [proxy]
3048 name = "jwt-gw"
3049 [proxy.listen]
3050
3051 [[backends]]
3052 name = "echo"
3053 transport = "stdio"
3054 command = "echo"
3055
3056 [auth]
3057 type = "jwt"
3058 issuer = "https://auth.example.com"
3059 audience = "mcp-proxy"
3060 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
3061 "#;
3062 let err = ProxyConfig::parse(toml).unwrap_err();
3063 assert!(
3064 err.to_string().contains("admin_token"),
3065 "expected admin_token error, got: {err}"
3066 );
3067 }
3068
3069 #[test]
3070 fn test_validate_jwt_with_admin_token_ok() {
3071 let toml = r#"
3073 [proxy]
3074 name = "jwt-gw"
3075 [proxy.listen]
3076
3077 [[backends]]
3078 name = "echo"
3079 transport = "stdio"
3080 command = "echo"
3081
3082 [auth]
3083 type = "jwt"
3084 issuer = "https://auth.example.com"
3085 audience = "mcp-proxy"
3086 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
3087
3088 [security]
3089 admin_token = "admin-secret"
3090 "#;
3091 assert!(ProxyConfig::parse(toml).is_ok());
3092 }
3093
3094 #[test]
3095 fn test_validate_oauth_requires_admin_token() {
3096 let toml = r#"
3099 [proxy]
3100 name = "oauth-gw"
3101 [proxy.listen]
3102
3103 [[backends]]
3104 name = "echo"
3105 transport = "stdio"
3106 command = "echo"
3107
3108 [auth]
3109 type = "oauth"
3110 issuer = "https://auth.example.com"
3111 audience = "mcp-proxy"
3112 "#;
3113 let err = ProxyConfig::parse(toml).unwrap_err();
3114 assert!(
3115 err.to_string().contains("admin_token"),
3116 "expected admin_token error, got: {err}"
3117 );
3118 }
3119
3120 #[test]
3121 fn test_parse_global_rate_limit() {
3122 let toml = r#"
3123 [proxy]
3124 name = "test"
3125 [proxy.listen]
3126 [proxy.rate_limit]
3127 requests = 500
3128 period_seconds = 1
3129
3130 [[backends]]
3131 name = "echo"
3132 transport = "stdio"
3133 command = "echo"
3134 "#;
3135 let config = ProxyConfig::parse(toml).unwrap();
3136 let rl = config.proxy.rate_limit.unwrap();
3137 assert_eq!(rl.requests, 500);
3138 assert_eq!(rl.period_seconds, 1);
3139 }
3140
3141 #[test]
3142 fn test_name_filter_glob_wildcard() {
3143 let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
3144 assert!(filter.allows("read_file"));
3145 assert!(filter.allows("write_file"));
3146 assert!(!filter.allows("query"));
3147 assert!(!filter.allows("file_read"));
3148 }
3149
3150 #[test]
3151 fn test_name_filter_glob_prefix() {
3152 let filter = NameFilter::allow_list(["list_*".to_string()]).unwrap();
3153 assert!(filter.allows("list_files"));
3154 assert!(filter.allows("list_users"));
3155 assert!(!filter.allows("get_files"));
3156 }
3157
3158 #[test]
3159 fn test_name_filter_glob_question_mark() {
3160 let filter = NameFilter::allow_list(["get_?".to_string()]).unwrap();
3161 assert!(filter.allows("get_a"));
3162 assert!(filter.allows("get_1"));
3163 assert!(!filter.allows("get_ab"));
3164 assert!(!filter.allows("get_"));
3165 }
3166
3167 #[test]
3168 fn test_name_filter_glob_deny_list() {
3169 let filter = NameFilter::deny_list(["*_delete*".to_string()]).unwrap();
3170 assert!(filter.allows("read_file"));
3171 assert!(filter.allows("create_issue"));
3172 assert!(!filter.allows("force_delete_all"));
3173 assert!(!filter.allows("soft_delete"));
3174 }
3175
3176 #[test]
3177 fn test_name_filter_glob_exact_match_still_works() {
3178 let filter = NameFilter::allow_list(["read_file".to_string()]).unwrap();
3179 assert!(filter.allows("read_file"));
3180 assert!(!filter.allows("write_file"));
3181 }
3182
3183 #[test]
3184 fn test_name_filter_glob_multiple_patterns() {
3185 let filter = NameFilter::allow_list(["read_*".to_string(), "list_*".to_string()]).unwrap();
3186 assert!(filter.allows("read_file"));
3187 assert!(filter.allows("list_users"));
3188 assert!(!filter.allows("delete_file"));
3189 }
3190
3191 #[test]
3192 fn test_name_filter_regex_allow_list() {
3193 let filter =
3194 NameFilter::allow_list(["re:^list_.*$".to_string(), "re:^get_\\w+$".to_string()])
3195 .unwrap();
3196 assert!(filter.allows("list_files"));
3197 assert!(filter.allows("list_users"));
3198 assert!(filter.allows("get_item"));
3199 assert!(!filter.allows("delete_file"));
3200 assert!(!filter.allows("create_issue"));
3201 }
3202
3203 #[test]
3204 fn test_name_filter_regex_deny_list() {
3205 let filter = NameFilter::deny_list(["re:^delete_".to_string()]).unwrap();
3206 assert!(filter.allows("read_file"));
3207 assert!(filter.allows("list_users"));
3208 assert!(!filter.allows("delete_file"));
3209 assert!(!filter.allows("delete_all"));
3210 }
3211
3212 #[test]
3213 fn test_name_filter_mixed_glob_and_regex() {
3214 let filter =
3215 NameFilter::allow_list(["read_*".to_string(), "re:^list_\\w+$".to_string()]).unwrap();
3216 assert!(filter.allows("read_file"));
3217 assert!(filter.allows("read_dir"));
3218 assert!(filter.allows("list_users"));
3219 assert!(!filter.allows("delete_file"));
3220 }
3221
3222 #[test]
3223 fn test_name_filter_regex_invalid_pattern() {
3224 let result = NameFilter::allow_list(["re:[invalid".to_string()]);
3225 assert!(result.is_err(), "invalid regex should produce an error");
3226 }
3227
3228 #[test]
3229 fn test_name_filter_regex_partial_match() {
3230 let filter = NameFilter::allow_list(["re:list".to_string()]).unwrap();
3232 assert!(filter.allows("list_files"));
3233 assert!(filter.allows("my_list_tool"));
3234 assert!(!filter.allows("read_file"));
3235 }
3236
3237 #[test]
3238 fn test_config_parse_regex_filter() {
3239 let toml = r#"
3240 [proxy]
3241 name = "regex-gw"
3242 [proxy.listen]
3243
3244 [[backends]]
3245 name = "svc"
3246 transport = "stdio"
3247 command = "echo"
3248 expose_tools = ["*_issue", "re:^list_.*$"]
3249 "#;
3250
3251 let config = ProxyConfig::parse(toml).unwrap();
3252 let filter = config.backends[0]
3253 .build_filter(&config.proxy.separator)
3254 .unwrap()
3255 .expect("should have filter");
3256 assert!(filter.tool_filter.allows("create_issue"));
3257 assert!(filter.tool_filter.allows("list_files"));
3258 assert!(filter.tool_filter.allows("list_users"));
3259 assert!(!filter.tool_filter.allows("delete_file"));
3260 }
3261
3262 #[test]
3263 fn test_parse_param_overrides() {
3264 let toml = r#"
3265 [proxy]
3266 name = "override-gw"
3267 [proxy.listen]
3268
3269 [[backends]]
3270 name = "fs"
3271 transport = "http"
3272 url = "http://localhost:8080"
3273
3274 [[backends.param_overrides]]
3275 tool = "list_directory"
3276 hide = ["path"]
3277 rename = { recursive = "deep_search" }
3278
3279 [backends.param_overrides.defaults]
3280 path = "/home/docs"
3281 "#;
3282
3283 let config = ProxyConfig::parse(toml).unwrap();
3284 assert_eq!(config.backends[0].param_overrides.len(), 1);
3285 let po = &config.backends[0].param_overrides[0];
3286 assert_eq!(po.tool, "list_directory");
3287 assert_eq!(po.hide, vec!["path"]);
3288 assert_eq!(po.defaults.get("path").unwrap(), "/home/docs");
3289 assert_eq!(po.rename.get("recursive").unwrap(), "deep_search");
3290 }
3291
3292 #[test]
3293 fn test_reject_param_override_empty_tool() {
3294 let toml = r#"
3295 [proxy]
3296 name = "bad"
3297 [proxy.listen]
3298
3299 [[backends]]
3300 name = "fs"
3301 transport = "http"
3302 url = "http://localhost:8080"
3303
3304 [[backends.param_overrides]]
3305 tool = ""
3306 hide = ["path"]
3307 "#;
3308
3309 let err = ProxyConfig::parse(toml).unwrap_err();
3310 assert!(
3311 format!("{err}").contains("tool must not be empty"),
3312 "unexpected error: {err}"
3313 );
3314 }
3315
3316 #[test]
3317 fn test_reject_param_override_duplicate_tool() {
3318 let toml = r#"
3319 [proxy]
3320 name = "bad"
3321 [proxy.listen]
3322
3323 [[backends]]
3324 name = "fs"
3325 transport = "http"
3326 url = "http://localhost:8080"
3327
3328 [[backends.param_overrides]]
3329 tool = "list_directory"
3330 hide = ["path"]
3331
3332 [[backends.param_overrides]]
3333 tool = "list_directory"
3334 hide = ["pattern"]
3335 "#;
3336
3337 let err = ProxyConfig::parse(toml).unwrap_err();
3338 assert!(
3339 format!("{err}").contains("duplicate param_overrides"),
3340 "unexpected error: {err}"
3341 );
3342 }
3343
3344 #[test]
3345 fn test_reject_param_override_hide_and_rename_same_param() {
3346 let toml = r#"
3347 [proxy]
3348 name = "bad"
3349 [proxy.listen]
3350
3351 [[backends]]
3352 name = "fs"
3353 transport = "http"
3354 url = "http://localhost:8080"
3355
3356 [[backends.param_overrides]]
3357 tool = "list_directory"
3358 hide = ["path"]
3359 rename = { path = "dir" }
3360 "#;
3361
3362 let err = ProxyConfig::parse(toml).unwrap_err();
3363 assert!(
3364 format!("{err}").contains("cannot be both hidden and renamed"),
3365 "unexpected error: {err}"
3366 );
3367 }
3368
3369 #[test]
3370 fn test_reject_param_override_duplicate_rename_target() {
3371 let toml = r#"
3372 [proxy]
3373 name = "bad"
3374 [proxy.listen]
3375
3376 [[backends]]
3377 name = "fs"
3378 transport = "http"
3379 url = "http://localhost:8080"
3380
3381 [[backends.param_overrides]]
3382 tool = "list_directory"
3383 rename = { path = "location", dir = "location" }
3384 "#;
3385
3386 let err = ProxyConfig::parse(toml).unwrap_err();
3387 assert!(
3388 format!("{err}").contains("duplicate rename target"),
3389 "unexpected error: {err}"
3390 );
3391 }
3392
3393 #[test]
3394 fn test_cache_backend_defaults_to_memory() {
3395 let config = ProxyConfig::parse(minimal_config()).unwrap();
3396 assert_eq!(config.cache.backend, "memory");
3397 assert!(config.cache.url.is_none());
3398 }
3399
3400 #[test]
3401 fn test_cache_backend_redis_requires_url() {
3402 let toml = r#"
3403 [proxy]
3404 name = "test"
3405 [proxy.listen]
3406 [cache]
3407 backend = "redis"
3408
3409 [[backends]]
3410 name = "echo"
3411 transport = "stdio"
3412 command = "echo"
3413 "#;
3414 let err = ProxyConfig::parse(toml).unwrap_err();
3415 assert!(err.to_string().contains("cache.url is required"));
3416 }
3417
3418 #[test]
3419 fn test_cache_backend_unknown_rejected() {
3420 let toml = r#"
3421 [proxy]
3422 name = "test"
3423 [proxy.listen]
3424 [cache]
3425 backend = "memcached"
3426
3427 [[backends]]
3428 name = "echo"
3429 transport = "stdio"
3430 command = "echo"
3431 "#;
3432 let err = ProxyConfig::parse(toml).unwrap_err();
3433 assert!(err.to_string().contains("unknown cache backend"));
3434 }
3435
3436 const REDIS_CACHE_CONFIG: &str = r#"
3437 [proxy]
3438 name = "test"
3439 [proxy.listen]
3440 [cache]
3441 backend = "redis"
3442 url = "redis://localhost:6379"
3443 prefix = "myapp:"
3444
3445 [[backends]]
3446 name = "echo"
3447 transport = "stdio"
3448 command = "echo"
3449 "#;
3450
3451 #[cfg(feature = "redis-cache")]
3452 #[test]
3453 fn test_cache_backend_redis_with_url() {
3454 let config = ProxyConfig::parse(REDIS_CACHE_CONFIG).unwrap();
3455 assert_eq!(config.cache.backend, "redis");
3456 assert_eq!(config.cache.url.as_deref(), Some("redis://localhost:6379"));
3457 assert_eq!(config.cache.prefix, "myapp:");
3458 }
3459
3460 #[cfg(not(feature = "redis-cache"))]
3461 #[test]
3462 fn test_cache_backend_redis_rejected_without_feature() {
3463 let err = ProxyConfig::parse(REDIS_CACHE_CONFIG).unwrap_err();
3464 assert!(
3465 err.to_string()
3466 .contains("requires the 'redis-cache' feature")
3467 );
3468 }
3469
3470 const SQLITE_CACHE_CONFIG: &str = r#"
3471 [proxy]
3472 name = "test"
3473 [proxy.listen]
3474 [cache]
3475 backend = "sqlite"
3476 url = "cache.db"
3477
3478 [[backends]]
3479 name = "echo"
3480 transport = "stdio"
3481 command = "echo"
3482 "#;
3483
3484 #[cfg(feature = "sqlite-cache")]
3485 #[test]
3486 fn test_cache_backend_sqlite_with_url() {
3487 let config = ProxyConfig::parse(SQLITE_CACHE_CONFIG).unwrap();
3488 assert_eq!(config.cache.backend, "sqlite");
3489 assert_eq!(config.cache.url.as_deref(), Some("cache.db"));
3490 }
3491
3492 #[cfg(not(feature = "sqlite-cache"))]
3493 #[test]
3494 fn test_cache_backend_sqlite_rejected_without_feature() {
3495 let err = ProxyConfig::parse(SQLITE_CACHE_CONFIG).unwrap_err();
3496 assert!(
3497 err.to_string()
3498 .contains("requires the 'sqlite-cache' feature")
3499 );
3500 }
3501
3502 #[cfg(not(feature = "websocket"))]
3503 const WEBSOCKET_BACKEND_CONFIG: &str = r#"
3504 [proxy]
3505 name = "test"
3506 [proxy.listen]
3507
3508 [[backends]]
3509 name = "ws"
3510 transport = "websocket"
3511 url = "ws://localhost:9000"
3512 "#;
3513
3514 #[cfg(not(feature = "websocket"))]
3515 #[test]
3516 fn test_websocket_transport_rejected_without_feature() {
3517 let err = ProxyConfig::parse(WEBSOCKET_BACKEND_CONFIG).unwrap_err();
3518 assert!(err.to_string().contains("requires the 'websocket' feature"));
3519 }
3520
3521 #[test]
3522 fn test_parse_bearer_scoped_tokens() {
3523 let toml = r#"
3524 [proxy]
3525 name = "scoped"
3526 [proxy.listen]
3527
3528 [[backends]]
3529 name = "echo"
3530 transport = "stdio"
3531 command = "echo"
3532
3533 [auth]
3534 type = "bearer"
3535
3536 [[auth.scoped_tokens]]
3537 token = "frontend-token"
3538 allow_tools = ["echo/read_file"]
3539
3540 [[auth.scoped_tokens]]
3541 token = "admin-token"
3542 "#;
3543
3544 let config = ProxyConfig::parse(toml).unwrap();
3545 match &config.auth {
3546 Some(AuthConfig::Bearer {
3547 tokens,
3548 scoped_tokens,
3549 }) => {
3550 assert!(tokens.is_empty());
3551 assert_eq!(scoped_tokens.len(), 2);
3552 assert_eq!(scoped_tokens[0].token, "frontend-token");
3553 assert_eq!(scoped_tokens[0].allow_tools, vec!["echo/read_file"]);
3554 assert!(scoped_tokens[1].allow_tools.is_empty());
3555 }
3556 other => panic!("expected Bearer auth, got: {other:?}"),
3557 }
3558 }
3559
3560 #[test]
3561 fn test_parse_bearer_mixed_tokens() {
3562 let toml = r#"
3563 [proxy]
3564 name = "mixed"
3565 [proxy.listen]
3566
3567 [[backends]]
3568 name = "echo"
3569 transport = "stdio"
3570 command = "echo"
3571
3572 [auth]
3573 type = "bearer"
3574 tokens = ["simple-token"]
3575
3576 [[auth.scoped_tokens]]
3577 token = "scoped-token"
3578 deny_tools = ["echo/delete"]
3579 "#;
3580
3581 let config = ProxyConfig::parse(toml).unwrap();
3582 match &config.auth {
3583 Some(AuthConfig::Bearer {
3584 tokens,
3585 scoped_tokens,
3586 }) => {
3587 assert_eq!(tokens, &["simple-token"]);
3588 assert_eq!(scoped_tokens.len(), 1);
3589 assert_eq!(scoped_tokens[0].deny_tools, vec!["echo/delete"]);
3590 }
3591 other => panic!("expected Bearer auth, got: {other:?}"),
3592 }
3593 }
3594
3595 #[test]
3596 fn test_bearer_empty_tokens_rejected() {
3597 let toml = r#"
3598 [proxy]
3599 name = "empty"
3600 [proxy.listen]
3601
3602 [[backends]]
3603 name = "echo"
3604 transport = "stdio"
3605 command = "echo"
3606
3607 [auth]
3608 type = "bearer"
3609 "#;
3610
3611 let err = ProxyConfig::parse(toml).unwrap_err();
3612 assert!(
3613 err.to_string().contains("at least one token"),
3614 "unexpected error: {err}"
3615 );
3616 }
3617
3618 #[test]
3619 fn test_bearer_duplicate_across_lists_rejected() {
3620 let toml = r#"
3621 [proxy]
3622 name = "dup"
3623 [proxy.listen]
3624
3625 [[backends]]
3626 name = "echo"
3627 transport = "stdio"
3628 command = "echo"
3629
3630 [auth]
3631 type = "bearer"
3632 tokens = ["shared-token"]
3633
3634 [[auth.scoped_tokens]]
3635 token = "shared-token"
3636 allow_tools = ["echo/read"]
3637 "#;
3638
3639 let err = ProxyConfig::parse(toml).unwrap_err();
3640 assert!(
3641 err.to_string().contains("duplicate bearer token"),
3642 "unexpected error: {err}"
3643 );
3644 }
3645
3646 #[test]
3647 fn test_bearer_allow_and_deny_rejected() {
3648 let toml = r#"
3649 [proxy]
3650 name = "both"
3651 [proxy.listen]
3652
3653 [[backends]]
3654 name = "echo"
3655 transport = "stdio"
3656 command = "echo"
3657
3658 [auth]
3659 type = "bearer"
3660
3661 [[auth.scoped_tokens]]
3662 token = "conflict"
3663 allow_tools = ["echo/read"]
3664 deny_tools = ["echo/write"]
3665 "#;
3666
3667 let err = ProxyConfig::parse(toml).unwrap_err();
3668 assert!(
3669 err.to_string().contains("cannot specify both"),
3670 "unexpected error: {err}"
3671 );
3672 }
3673
3674 #[cfg(feature = "websocket")]
3675 #[test]
3676 fn test_parse_websocket_transport() {
3677 let toml = r#"
3678 [proxy]
3679 name = "ws-proxy"
3680 [proxy.listen]
3681
3682 [[backends]]
3683 name = "ws-backend"
3684 transport = "websocket"
3685 url = "ws://localhost:9090/ws"
3686 "#;
3687
3688 let config = ProxyConfig::parse(toml).unwrap();
3689 assert!(matches!(
3690 config.backends[0].transport,
3691 TransportType::Websocket
3692 ));
3693 assert_eq!(
3694 config.backends[0].url.as_deref(),
3695 Some("ws://localhost:9090/ws")
3696 );
3697 }
3698
3699 #[test]
3700 fn test_websocket_transport_requires_url() {
3701 let toml = r#"
3702 [proxy]
3703 name = "ws-proxy"
3704 [proxy.listen]
3705
3706 [[backends]]
3707 name = "ws-backend"
3708 transport = "websocket"
3709 "#;
3710
3711 let err = ProxyConfig::parse(toml).unwrap_err();
3712 assert!(
3713 err.to_string()
3714 .contains("websocket transport requires 'url'"),
3715 "unexpected error: {err}"
3716 );
3717 }
3718
3719 #[cfg(feature = "websocket")]
3720 #[test]
3721 fn test_websocket_with_bearer_token() {
3722 let toml = r#"
3723 [proxy]
3724 name = "ws-proxy"
3725 [proxy.listen]
3726
3727 [[backends]]
3728 name = "ws-backend"
3729 transport = "websocket"
3730 url = "wss://secure.example.com/mcp"
3731 bearer_token = "my-secret"
3732 "#;
3733
3734 let config = ProxyConfig::parse(toml).unwrap();
3735 assert_eq!(
3736 config.backends[0].bearer_token.as_deref(),
3737 Some("my-secret")
3738 );
3739 }
3740
3741 #[test]
3742 fn test_tool_discovery_defaults_false() {
3743 let config = ProxyConfig::parse(minimal_config()).unwrap();
3744 assert!(!config.proxy.tool_discovery);
3745 }
3746
3747 #[test]
3748 fn test_tool_discovery_enabled() {
3749 let toml = r#"
3750 [proxy]
3751 name = "discovery"
3752 tool_discovery = true
3753 [proxy.listen]
3754
3755 [[backends]]
3756 name = "echo"
3757 transport = "stdio"
3758 command = "echo"
3759 "#;
3760
3761 let config = ProxyConfig::parse(toml).unwrap();
3762 assert!(config.proxy.tool_discovery);
3763 }
3764
3765 #[test]
3766 fn test_parse_oauth_config() {
3767 let toml = r#"
3768 [proxy]
3769 name = "oauth-proxy"
3770 [proxy.listen]
3771
3772 [[backends]]
3773 name = "echo"
3774 transport = "stdio"
3775 command = "echo"
3776
3777 [auth]
3778 type = "oauth"
3779 issuer = "https://accounts.google.com"
3780 audience = "mcp-proxy"
3781
3782 [security]
3783 admin_token = "admin-secret"
3784 "#;
3785
3786 let config = ProxyConfig::parse(toml).unwrap();
3787 match &config.auth {
3788 Some(AuthConfig::OAuth {
3789 issuer,
3790 audience,
3791 token_validation,
3792 ..
3793 }) => {
3794 assert_eq!(issuer, "https://accounts.google.com");
3795 assert_eq!(audience, "mcp-proxy");
3796 assert_eq!(token_validation, &TokenValidationStrategy::Jwt);
3797 }
3798 other => panic!("expected OAuth auth, got: {other:?}"),
3799 }
3800 }
3801
3802 #[test]
3803 fn test_parse_oauth_with_introspection() {
3804 let toml = r#"
3805 [proxy]
3806 name = "oauth-proxy"
3807 [proxy.listen]
3808
3809 [[backends]]
3810 name = "echo"
3811 transport = "stdio"
3812 command = "echo"
3813
3814 [auth]
3815 type = "oauth"
3816 issuer = "https://auth.example.com"
3817 audience = "mcp-proxy"
3818 client_id = "my-client"
3819 client_secret = "my-secret"
3820 token_validation = "introspection"
3821
3822 [security]
3823 admin_token = "admin-secret"
3824 "#;
3825
3826 let config = ProxyConfig::parse(toml).unwrap();
3827 match &config.auth {
3828 Some(AuthConfig::OAuth {
3829 token_validation,
3830 client_id,
3831 client_secret,
3832 ..
3833 }) => {
3834 assert_eq!(token_validation, &TokenValidationStrategy::Introspection);
3835 assert_eq!(client_id.as_deref(), Some("my-client"));
3836 assert_eq!(client_secret.as_deref(), Some("my-secret"));
3837 }
3838 other => panic!("expected OAuth auth, got: {other:?}"),
3839 }
3840 }
3841
3842 #[test]
3843 fn test_oauth_introspection_requires_credentials() {
3844 let toml = r#"
3845 [proxy]
3846 name = "oauth-proxy"
3847 [proxy.listen]
3848
3849 [[backends]]
3850 name = "echo"
3851 transport = "stdio"
3852 command = "echo"
3853
3854 [auth]
3855 type = "oauth"
3856 issuer = "https://auth.example.com"
3857 audience = "mcp-proxy"
3858 token_validation = "introspection"
3859 "#;
3860
3861 let err = ProxyConfig::parse(toml).unwrap_err();
3862 assert!(
3863 err.to_string().contains("client_id"),
3864 "unexpected error: {err}"
3865 );
3866 }
3867
3868 #[test]
3869 fn test_parse_oauth_with_overrides() {
3870 let toml = r#"
3871 [proxy]
3872 name = "oauth-proxy"
3873 [proxy.listen]
3874
3875 [[backends]]
3876 name = "echo"
3877 transport = "stdio"
3878 command = "echo"
3879
3880 [auth]
3881 type = "oauth"
3882 issuer = "https://auth.example.com"
3883 audience = "mcp-proxy"
3884 jwks_uri = "https://auth.example.com/custom/jwks"
3885 introspection_endpoint = "https://auth.example.com/custom/introspect"
3886 client_id = "my-client"
3887 client_secret = "my-secret"
3888 token_validation = "both"
3889 required_scopes = ["read", "write"]
3890
3891 [security]
3892 admin_token = "admin-secret"
3893 "#;
3894
3895 let config = ProxyConfig::parse(toml).unwrap();
3896 match &config.auth {
3897 Some(AuthConfig::OAuth {
3898 jwks_uri,
3899 introspection_endpoint,
3900 token_validation,
3901 required_scopes,
3902 ..
3903 }) => {
3904 assert_eq!(
3905 jwks_uri.as_deref(),
3906 Some("https://auth.example.com/custom/jwks")
3907 );
3908 assert_eq!(
3909 introspection_endpoint.as_deref(),
3910 Some("https://auth.example.com/custom/introspect")
3911 );
3912 assert_eq!(token_validation, &TokenValidationStrategy::Both);
3913 assert_eq!(required_scopes, &["read", "write"]);
3914 }
3915 other => panic!("expected OAuth auth, got: {other:?}"),
3916 }
3917 }
3918
3919 #[test]
3920 fn test_check_env_vars_warns_on_unset() {
3921 let toml = r#"
3922 [proxy]
3923 name = "env-check"
3924 [proxy.listen]
3925
3926 [[backends]]
3927 name = "svc"
3928 transport = "stdio"
3929 command = "echo"
3930 bearer_token = "${TOTALLY_UNSET_VAR_1}"
3931
3932 [backends.env]
3933 API_KEY = "${TOTALLY_UNSET_VAR_2}"
3934 STATIC = "plain-value"
3935
3936 [auth]
3937 type = "bearer"
3938 tokens = ["${TOTALLY_UNSET_VAR_3}", "literal-token"]
3939
3940 [[auth.scoped_tokens]]
3941 token = "${TOTALLY_UNSET_VAR_4}"
3942 allow_tools = ["svc/echo"]
3943 "#;
3944
3945 let config = ProxyConfig::parse(toml).unwrap();
3946 let warnings = config.check_env_vars();
3947
3948 assert_eq!(warnings.len(), 4, "warnings: {warnings:?}");
3949 assert!(warnings[0].contains("TOTALLY_UNSET_VAR_1"));
3950 assert!(warnings[0].contains("bearer_token"));
3951 assert!(warnings[1].contains("TOTALLY_UNSET_VAR_2"));
3952 assert!(warnings[1].contains("env.API_KEY"));
3953 assert!(warnings[2].contains("TOTALLY_UNSET_VAR_3"));
3954 assert!(warnings[2].contains("tokens[0]"));
3955 assert!(warnings[3].contains("TOTALLY_UNSET_VAR_4"));
3956 assert!(warnings[3].contains("scoped_tokens[0]"));
3957 }
3958
3959 #[test]
3960 fn test_check_env_vars_no_warnings_when_set() {
3961 unsafe { std::env::set_var("MCP_CHECK_TEST_VAR", "value") };
3963
3964 let toml = r#"
3965 [proxy]
3966 name = "env-check"
3967 [proxy.listen]
3968
3969 [[backends]]
3970 name = "svc"
3971 transport = "stdio"
3972 command = "echo"
3973 bearer_token = "${MCP_CHECK_TEST_VAR}"
3974 "#;
3975
3976 let config = ProxyConfig::parse(toml).unwrap();
3977 let warnings = config.check_env_vars();
3978 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3979
3980 unsafe { std::env::remove_var("MCP_CHECK_TEST_VAR") };
3982 }
3983
3984 #[test]
3985 fn test_check_env_vars_no_warnings_for_literals() {
3986 let toml = r#"
3987 [proxy]
3988 name = "env-check"
3989 [proxy.listen]
3990
3991 [[backends]]
3992 name = "svc"
3993 transport = "stdio"
3994 command = "echo"
3995 bearer_token = "literal-token"
3996 "#;
3997
3998 let config = ProxyConfig::parse(toml).unwrap();
3999 let warnings = config.check_env_vars();
4000 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
4001 }
4002
4003 #[test]
4004 fn test_check_env_vars_oauth_client_secret() {
4005 let toml = r#"
4006 [proxy]
4007 name = "oauth-check"
4008 [proxy.listen]
4009
4010 [[backends]]
4011 name = "svc"
4012 transport = "http"
4013 url = "http://localhost:3000"
4014
4015 [auth]
4016 type = "oauth"
4017 issuer = "https://auth.example.com"
4018 audience = "mcp-proxy"
4019 client_id = "my-client"
4020 client_secret = "${TOTALLY_UNSET_OAUTH_SECRET}"
4021 token_validation = "introspection"
4022
4023 [security]
4024 admin_token = "admin-secret"
4025 "#;
4026
4027 let config = ProxyConfig::parse(toml).unwrap();
4028 let warnings = config.check_env_vars();
4029 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
4030 assert!(warnings[0].contains("TOTALLY_UNSET_OAUTH_SECRET"));
4031 assert!(warnings[0].contains("client_secret"));
4032 }
4033
4034 #[cfg(feature = "yaml")]
4035 #[test]
4036 fn test_parse_yaml_config() {
4037 let yaml = r#"
4038proxy:
4039 name: yaml-proxy
4040 listen:
4041 host: "127.0.0.1"
4042 port: 8080
4043backends:
4044 - name: echo
4045 transport: stdio
4046 command: echo
4047"#;
4048 let config = ProxyConfig::parse_yaml(yaml).unwrap();
4049 assert_eq!(config.proxy.name, "yaml-proxy");
4050 assert_eq!(config.backends.len(), 1);
4051 assert_eq!(config.backends[0].name, "echo");
4052 }
4053
4054 #[cfg(feature = "yaml")]
4055 #[test]
4056 fn test_parse_yaml_with_auth() {
4057 let yaml = r#"
4058proxy:
4059 name: auth-proxy
4060 listen:
4061 host: "127.0.0.1"
4062 port: 9090
4063backends:
4064 - name: api
4065 transport: stdio
4066 command: echo
4067auth:
4068 type: bearer
4069 tokens:
4070 - token-1
4071 - token-2
4072"#;
4073 let config = ProxyConfig::parse_yaml(yaml).unwrap();
4074 match &config.auth {
4075 Some(AuthConfig::Bearer { tokens, .. }) => {
4076 assert_eq!(tokens, &["token-1", "token-2"]);
4077 }
4078 other => panic!("expected Bearer auth, got: {other:?}"),
4079 }
4080 }
4081
4082 #[cfg(feature = "yaml")]
4083 #[test]
4084 fn test_parse_yaml_with_middleware() {
4085 let yaml = r#"
4086proxy:
4087 name: mw-proxy
4088 listen:
4089 host: "127.0.0.1"
4090 port: 8080
4091backends:
4092 - name: api
4093 transport: stdio
4094 command: echo
4095 timeout:
4096 seconds: 30
4097 rate_limit:
4098 requests: 100
4099 period_seconds: 1
4100 expose_tools:
4101 - read_file
4102 - list_directory
4103"#;
4104 let config = ProxyConfig::parse_yaml(yaml).unwrap();
4105 assert_eq!(config.backends[0].timeout.as_ref().unwrap().seconds, 30);
4106 assert_eq!(
4107 config.backends[0].rate_limit.as_ref().unwrap().requests,
4108 100
4109 );
4110 assert_eq!(
4111 config.backends[0].expose_tools,
4112 vec!["read_file", "list_directory"]
4113 );
4114 }
4115
4116 #[cfg(feature = "yaml")]
4117 #[test]
4118 fn yaml_round_trip_preserves_auth_headers_and_middleware() {
4119 let original = ProxyConfig::parse_yaml(
4120 r#"
4121proxy:
4122 name: yaml-round-trip
4123 listen: {}
4124auth:
4125 type: bearer
4126 tokens: [test-token]
4127backends:
4128 - name: api
4129 transport: http
4130 url: https://example.com/mcp
4131 headers:
4132 X-API-Key: "${API_KEY}"
4133 Authorization: "ApiKey literal"
4134 timeout:
4135 seconds: 90
4136 rate_limit:
4137 requests: 5
4138 period_seconds: 2
4139"#,
4140 )
4141 .unwrap();
4142 let serialized = yaml_serde::to_string(&original).unwrap();
4143 let restored = ProxyConfig::parse_yaml(&serialized).unwrap();
4144 assert_eq!(
4145 serde_json::to_value(&original).unwrap(),
4146 serde_json::to_value(&restored).unwrap()
4147 );
4148 assert_eq!(restored.backends[0].headers["X-API-Key"], "${API_KEY}");
4149 }
4150
4151 #[cfg(feature = "yaml")]
4152 #[test]
4153 fn yaml_file_extensions_load_through_the_config_loader() {
4154 let dir = tempfile::tempdir().unwrap();
4155 let content = "proxy: {name: yaml-file, listen: {}}\nbackends:\n - {name: api, transport: http, url: 'http://localhost:8080'}\n";
4156 for extension in ["yaml", "yml"] {
4157 let path = dir.path().join(format!("proxy.{extension}"));
4158 std::fs::write(&path, content).unwrap();
4159 let config = ProxyConfig::load(&path).unwrap();
4160 assert_eq!(config.proxy.name, "yaml-file");
4161 assert_eq!(config.source_path.as_ref(), Some(&path));
4162 assert_eq!(
4163 config.backends[0].url.as_deref(),
4164 Some("http://localhost:8080")
4165 );
4166 }
4167 }
4168
4169 #[cfg(feature = "yaml")]
4170 #[test]
4171 fn yaml_rejects_malformed_duplicate_and_unknown_backend_fields() {
4172 let malformed = ProxyConfig::parse_yaml("proxy: [unterminated").unwrap_err();
4173 assert!(format!("{malformed:#}").contains("parsing YAML config"));
4174 let prefix = "proxy: {name: invalid-yaml, listen: {}}\nbackends:\n - name: api\n transport: http\n url: 'http://localhost:8080'\n";
4175 for (suffix, expected) in [
4176 (" name: duplicate\n", "duplicate field"),
4177 (" typo: value\n", "unknown field"),
4178 ] {
4179 let error = ProxyConfig::parse_yaml(&format!("{prefix}{suffix}")).unwrap_err();
4180 assert!(format!("{error:#}").contains(expected), "{error:#}");
4181 }
4182 }
4183
4184 #[test]
4185 fn test_from_mcp_json() {
4186 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json");
4187 let project_dir = dir.join("my-project");
4188 std::fs::create_dir_all(&project_dir).unwrap();
4189
4190 let mcp_json_path = project_dir.join(".mcp.json");
4191 std::fs::write(
4192 &mcp_json_path,
4193 r#"{
4194 "mcpServers": {
4195 "github": {
4196 "command": "npx",
4197 "args": ["-y", "@modelcontextprotocol/server-github"]
4198 },
4199 "api": {
4200 "url": "http://localhost:9000"
4201 }
4202 }
4203 }"#,
4204 )
4205 .unwrap();
4206
4207 let config = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap();
4208
4209 assert_eq!(config.proxy.name, "my-project");
4211 assert_eq!(config.proxy.listen.host, "127.0.0.1");
4213 assert_eq!(config.proxy.listen.port, 8080);
4214 assert_eq!(config.proxy.version, "0.1.0");
4215 assert_eq!(config.proxy.separator, "/");
4216 assert!(config.auth.is_none());
4218 assert!(config.composite_tools.is_empty());
4219 assert_eq!(config.backends.len(), 2);
4221 assert_eq!(config.backends[0].name, "api");
4222 assert_eq!(config.backends[1].name, "github");
4223
4224 std::fs::remove_dir_all(&dir).unwrap();
4225 }
4226
4227 #[test]
4228 fn test_from_mcp_json_empty_rejects() {
4229 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json_empty");
4230 std::fs::create_dir_all(&dir).unwrap();
4231
4232 let mcp_json_path = dir.join(".mcp.json");
4233 std::fs::write(&mcp_json_path, r#"{ "mcpServers": {} }"#).unwrap();
4234
4235 let err = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap_err();
4236 assert!(
4237 err.to_string().contains("at least one backend"),
4238 "unexpected error: {err}"
4239 );
4240
4241 std::fs::remove_dir_all(&dir).unwrap();
4242 }
4243
4244 #[test]
4245 fn test_priority_defaults_to_zero() {
4246 let toml = r#"
4247 [proxy]
4248 name = "test"
4249 [proxy.listen]
4250
4251 [[backends]]
4252 name = "api"
4253 transport = "stdio"
4254 command = "echo"
4255 "#;
4256
4257 let config = ProxyConfig::parse(toml).unwrap();
4258 assert_eq!(config.backends[0].priority, 0);
4259 }
4260
4261 #[test]
4262 fn test_priority_parsed_from_config() {
4263 let toml = r#"
4264 [proxy]
4265 name = "test"
4266 [proxy.listen]
4267
4268 [[backends]]
4269 name = "api"
4270 transport = "stdio"
4271 command = "echo"
4272
4273 [[backends]]
4274 name = "api-backup-1"
4275 transport = "stdio"
4276 command = "echo"
4277 failover_for = "api"
4278 priority = 10
4279
4280 [[backends]]
4281 name = "api-backup-2"
4282 transport = "stdio"
4283 command = "echo"
4284 failover_for = "api"
4285 priority = 5
4286 "#;
4287
4288 let config = ProxyConfig::parse(toml).unwrap();
4289 assert_eq!(config.backends[0].priority, 0);
4290 assert_eq!(config.backends[1].priority, 10);
4291 assert_eq!(config.backends[2].priority, 5);
4292 }
4293}