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 }
1638 }
1639
1640 for backend in &self.backends {
1642 if let Some(ref primary) = backend.failover_for {
1643 if !backend_names.contains(primary.as_str()) {
1644 anyhow::bail!(
1645 "backend '{}': failover_for references unknown backend '{}'",
1646 backend.name,
1647 primary
1648 );
1649 }
1650 if primary == &backend.name {
1651 anyhow::bail!(
1652 "backend '{}': failover_for cannot reference itself",
1653 backend.name
1654 );
1655 }
1656 }
1657 }
1658
1659 {
1661 let mut composite_names = HashSet::new();
1662 for ct in &self.composite_tools {
1663 if ct.name.is_empty() {
1664 anyhow::bail!("composite_tools: name must not be empty");
1665 }
1666 if ct.tools.is_empty() {
1667 anyhow::bail!(
1668 "composite_tools '{}': must reference at least one tool",
1669 ct.name
1670 );
1671 }
1672 if !composite_names.insert(&ct.name) {
1673 anyhow::bail!("duplicate composite_tools name '{}'", ct.name);
1674 }
1675 }
1676 }
1677
1678 for backend in &self.backends {
1680 if let Some(ref primary) = backend.canary_of {
1681 if !backend_names.contains(primary.as_str()) {
1682 anyhow::bail!(
1683 "backend '{}': canary_of references unknown backend '{}'",
1684 backend.name,
1685 primary
1686 );
1687 }
1688 if primary == &backend.name {
1689 anyhow::bail!(
1690 "backend '{}': canary_of cannot reference itself",
1691 backend.name
1692 );
1693 }
1694 if backend.weight == 0 {
1695 anyhow::bail!("backend '{}': weight must be > 0", backend.name);
1696 }
1697 }
1698 }
1699
1700 #[cfg(not(feature = "discovery"))]
1702 if self.proxy.tool_exposure == ToolExposure::Search {
1703 anyhow::bail!(
1704 "tool_exposure = \"search\" requires the 'discovery' feature. \
1705 Rebuild with: cargo install mcp-proxy --features discovery"
1706 );
1707 }
1708
1709 for backend in &self.backends {
1711 let mut seen_tools = HashSet::new();
1712 for po in &backend.param_overrides {
1713 if po.tool.is_empty() {
1714 anyhow::bail!(
1715 "backend '{}': param_overrides.tool must not be empty",
1716 backend.name
1717 );
1718 }
1719 if !seen_tools.insert(&po.tool) {
1720 anyhow::bail!(
1721 "backend '{}': duplicate param_overrides for tool '{}'",
1722 backend.name,
1723 po.tool
1724 );
1725 }
1726 for hidden in &po.hide {
1729 if po.rename.contains_key(hidden) {
1730 anyhow::bail!(
1731 "backend '{}': param_overrides for tool '{}': \
1732 parameter '{}' cannot be both hidden and renamed",
1733 backend.name,
1734 po.tool,
1735 hidden
1736 );
1737 }
1738 }
1739 let mut rename_targets = HashSet::new();
1741 for target in po.rename.values() {
1742 if !rename_targets.insert(target) {
1743 anyhow::bail!(
1744 "backend '{}': param_overrides for tool '{}': \
1745 duplicate rename target '{}'",
1746 backend.name,
1747 po.tool,
1748 target
1749 );
1750 }
1751 }
1752 }
1753 }
1754
1755 Ok(())
1756 }
1757
1758 pub fn resolve_env_vars(&mut self) {
1761 for backend in &mut self.backends {
1762 for value in backend.env.values_mut() {
1763 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1764 && let Ok(env_val) = std::env::var(var_name)
1765 {
1766 *value = env_val;
1767 }
1768 }
1769 if let Some(ref mut token) = backend.bearer_token
1770 && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1771 && let Ok(env_val) = std::env::var(var_name)
1772 {
1773 *token = env_val;
1774 }
1775 }
1776
1777 if let Some(AuthConfig::Bearer {
1779 tokens,
1780 scoped_tokens,
1781 }) = &mut self.auth
1782 {
1783 for token in tokens.iter_mut() {
1784 if let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1785 && let Ok(env_val) = std::env::var(var_name)
1786 {
1787 *token = env_val;
1788 }
1789 }
1790 for st in scoped_tokens.iter_mut() {
1791 if let Some(var_name) = st
1792 .token
1793 .strip_prefix("${")
1794 .and_then(|s| s.strip_suffix('}'))
1795 && let Ok(env_val) = std::env::var(var_name)
1796 {
1797 st.token = env_val;
1798 }
1799 }
1800 }
1801
1802 if let Some(ref mut token) = self.security.admin_token
1804 && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1805 && let Ok(env_val) = std::env::var(var_name)
1806 {
1807 *token = env_val;
1808 }
1809
1810 if let Some(AuthConfig::OAuth { client_secret, .. }) = &mut self.auth
1812 && let Some(secret) = client_secret
1813 && let Some(var_name) = secret.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1814 && let Ok(env_val) = std::env::var(var_name)
1815 {
1816 *secret = env_val;
1817 }
1818 }
1819
1820 pub fn check_env_vars(&self) -> Vec<String> {
1847 fn is_unset_env_ref(value: &str) -> Option<&str> {
1848 let var_name = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))?;
1849 if std::env::var(var_name).is_err() {
1850 Some(var_name)
1851 } else {
1852 None
1853 }
1854 }
1855
1856 let mut warnings = Vec::new();
1857
1858 for backend in &self.backends {
1859 if let Some(ref token) = backend.bearer_token
1861 && let Some(var) = is_unset_env_ref(token)
1862 {
1863 warnings.push(format!(
1864 "backend '{}': bearer_token references unset env var '{}'",
1865 backend.name, var
1866 ));
1867 }
1868 for (key, value) in &backend.env {
1870 if let Some(var) = is_unset_env_ref(value) {
1871 warnings.push(format!(
1872 "backend '{}': env.{} references unset env var '{}'",
1873 backend.name, key, var
1874 ));
1875 }
1876 }
1877 }
1878
1879 match &self.auth {
1880 Some(AuthConfig::Bearer {
1881 tokens,
1882 scoped_tokens,
1883 }) => {
1884 for (i, token) in tokens.iter().enumerate() {
1885 if let Some(var) = is_unset_env_ref(token) {
1886 warnings.push(format!(
1887 "auth.bearer: tokens[{}] references unset env var '{}'",
1888 i, var
1889 ));
1890 }
1891 }
1892 for (i, st) in scoped_tokens.iter().enumerate() {
1893 if let Some(var) = is_unset_env_ref(&st.token) {
1894 warnings.push(format!(
1895 "auth.bearer: scoped_tokens[{}] references unset env var '{}'",
1896 i, var
1897 ));
1898 }
1899 }
1900 }
1901 Some(AuthConfig::OAuth {
1902 client_secret: Some(secret),
1903 ..
1904 }) => {
1905 if let Some(var) = is_unset_env_ref(secret) {
1906 warnings.push(format!(
1907 "auth.oauth: client_secret references unset env var '{}'",
1908 var
1909 ));
1910 }
1911 }
1912 _ => {}
1913 }
1914
1915 warnings
1916 }
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921 use super::*;
1922
1923 fn minimal_config() -> &'static str {
1924 r#"
1925 [proxy]
1926 name = "test"
1927 [proxy.listen]
1928
1929 [[backends]]
1930 name = "echo"
1931 transport = "stdio"
1932 command = "echo"
1933 "#
1934 }
1935
1936 #[test]
1937 fn test_parse_minimal_config() {
1938 let config = ProxyConfig::parse(minimal_config()).unwrap();
1939 assert_eq!(config.proxy.name, "test");
1940 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);
1947 assert_eq!(config.backends[0].name, "echo");
1948 assert!(config.auth.is_none());
1949 assert!(!config.observability.audit);
1950 assert!(!config.observability.metrics.enabled);
1951 }
1952
1953 #[test]
1954 fn test_parse_full_config() {
1955 let toml = r#"
1956 [proxy]
1957 name = "full-gw"
1958 version = "2.0.0"
1959 separator = "."
1960 shutdown_timeout_seconds = 60
1961 hot_reload = true
1962 instructions = "A test proxy"
1963 [proxy.listen]
1964 host = "0.0.0.0"
1965 port = 9090
1966
1967 [[backends]]
1968 name = "files"
1969 transport = "stdio"
1970 command = "file-server"
1971 args = ["--root", "/tmp"]
1972 expose_tools = ["read_file"]
1973
1974 [backends.env]
1975 LOG_LEVEL = "debug"
1976
1977 [backends.timeout]
1978 seconds = 30
1979
1980 [backends.concurrency]
1981 max_concurrent = 5
1982
1983 [backends.rate_limit]
1984 requests = 100
1985 period_seconds = 10
1986
1987 [backends.circuit_breaker]
1988 failure_rate_threshold = 0.5
1989 minimum_calls = 10
1990 wait_duration_seconds = 60
1991 permitted_calls_in_half_open = 2
1992
1993 [backends.cache]
1994 resource_ttl_seconds = 300
1995 tool_ttl_seconds = 60
1996 max_entries = 500
1997
1998 [[backends.aliases]]
1999 from = "read_file"
2000 to = "read"
2001
2002 [[backends]]
2003 name = "remote"
2004 transport = "http"
2005 url = "http://localhost:3000"
2006
2007 [observability]
2008 audit = true
2009 log_level = "debug"
2010 json_logs = true
2011
2012 [observability.metrics]
2013 enabled = true
2014
2015 [observability.tracing]
2016 enabled = true
2017 endpoint = "http://jaeger:4317"
2018 service_name = "test-gw"
2019
2020 [performance]
2021 coalesce_requests = true
2022
2023 [security]
2024 max_argument_size = 1048576
2025 "#;
2026
2027 let config = ProxyConfig::parse(toml).unwrap();
2028 assert_eq!(config.proxy.name, "full-gw");
2029 assert_eq!(config.proxy.version, "2.0.0");
2030 assert_eq!(config.proxy.separator, ".");
2031 assert_eq!(config.proxy.shutdown_timeout_seconds, 60);
2032 assert!(config.proxy.hot_reload);
2033 assert_eq!(config.proxy.instructions.as_deref(), Some("A test proxy"));
2034 assert_eq!(config.proxy.listen.host, "0.0.0.0");
2035 assert_eq!(config.proxy.listen.port, 9090);
2036
2037 assert_eq!(config.backends.len(), 2);
2038
2039 let files = &config.backends[0];
2040 assert_eq!(files.command.as_deref(), Some("file-server"));
2041 assert_eq!(files.args, vec!["--root", "/tmp"]);
2042 assert_eq!(files.expose_tools, vec!["read_file"]);
2043 assert_eq!(files.env.get("LOG_LEVEL").unwrap(), "debug");
2044 assert_eq!(files.timeout.as_ref().unwrap().seconds, 30);
2045 assert_eq!(files.concurrency.as_ref().unwrap().max_concurrent, 5);
2046 assert_eq!(files.rate_limit.as_ref().unwrap().requests, 100);
2047 assert_eq!(files.cache.as_ref().unwrap().resource_ttl_seconds, 300);
2048 assert_eq!(files.cache.as_ref().unwrap().tool_ttl_seconds, 60);
2049 assert_eq!(files.cache.as_ref().unwrap().max_entries, 500);
2050 assert_eq!(files.aliases.len(), 1);
2051 assert_eq!(files.aliases[0].from, "read_file");
2052 assert_eq!(files.aliases[0].to, "read");
2053
2054 let cb = files.circuit_breaker.as_ref().unwrap();
2055 assert_eq!(cb.failure_rate_threshold, 0.5);
2056 assert_eq!(cb.minimum_calls, 10);
2057 assert_eq!(cb.wait_duration_seconds, 60);
2058 assert_eq!(cb.permitted_calls_in_half_open, 2);
2059
2060 let remote = &config.backends[1];
2061 assert_eq!(remote.url.as_deref(), Some("http://localhost:3000"));
2062
2063 assert!(config.observability.audit);
2064 assert_eq!(config.observability.log_level, "debug");
2065 assert!(config.observability.json_logs);
2066 assert!(config.observability.metrics.enabled);
2067 assert!(config.observability.tracing.enabled);
2068 assert_eq!(config.observability.tracing.endpoint, "http://jaeger:4317");
2069
2070 assert!(config.performance.coalesce_requests);
2071 assert_eq!(config.security.max_argument_size, Some(1048576));
2072 }
2073
2074 #[test]
2075 fn test_parse_bearer_auth() {
2076 let toml = r#"
2077 [proxy]
2078 name = "auth-gw"
2079 [proxy.listen]
2080
2081 [[backends]]
2082 name = "echo"
2083 transport = "stdio"
2084 command = "echo"
2085
2086 [auth]
2087 type = "bearer"
2088 tokens = ["token-1", "token-2"]
2089 "#;
2090
2091 let config = ProxyConfig::parse(toml).unwrap();
2092 match &config.auth {
2093 Some(AuthConfig::Bearer { tokens, .. }) => {
2094 assert_eq!(tokens, &["token-1", "token-2"]);
2095 }
2096 other => panic!("expected Bearer auth, got: {:?}", other),
2097 }
2098 }
2099
2100 #[test]
2101 fn test_parse_jwt_auth_with_rbac() {
2102 let toml = r#"
2103 [proxy]
2104 name = "jwt-gw"
2105 [proxy.listen]
2106
2107 [[backends]]
2108 name = "echo"
2109 transport = "stdio"
2110 command = "echo"
2111
2112 [auth]
2113 type = "jwt"
2114 issuer = "https://auth.example.com"
2115 audience = "mcp-proxy"
2116 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2117
2118 [[auth.roles]]
2119 name = "reader"
2120 allow_tools = ["echo/read"]
2121
2122 [[auth.roles]]
2123 name = "admin"
2124
2125 [auth.role_mapping]
2126 claim = "scope"
2127 mapping = { "mcp:read" = "reader", "mcp:admin" = "admin" }
2128
2129 [security]
2130 admin_token = "admin-secret"
2131 "#;
2132
2133 let config = ProxyConfig::parse(toml).unwrap();
2134 match &config.auth {
2135 Some(AuthConfig::Jwt {
2136 issuer,
2137 audience,
2138 jwks_uri,
2139 roles,
2140 role_mapping,
2141 }) => {
2142 assert_eq!(issuer, "https://auth.example.com");
2143 assert_eq!(audience, "mcp-proxy");
2144 assert_eq!(jwks_uri, "https://auth.example.com/.well-known/jwks.json");
2145 assert_eq!(roles.len(), 2);
2146 assert_eq!(roles[0].name, "reader");
2147 assert_eq!(roles[0].allow_tools, vec!["echo/read"]);
2148 let mapping = role_mapping.as_ref().unwrap();
2149 assert_eq!(mapping.claim, "scope");
2150 assert_eq!(mapping.mapping.get("mcp:read").unwrap(), "reader");
2151 }
2152 other => panic!("expected Jwt auth, got: {:?}", other),
2153 }
2154 }
2155
2156 #[test]
2161 fn test_reject_no_backends() {
2162 let toml = r#"
2163 [proxy]
2164 name = "empty"
2165 [proxy.listen]
2166 "#;
2167
2168 let err = ProxyConfig::parse(toml).unwrap_err();
2169 assert!(
2170 format!("{err}").contains("at least one backend"),
2171 "unexpected error: {err}"
2172 );
2173 }
2174
2175 #[test]
2176 fn test_reject_stdio_without_command() {
2177 let toml = r#"
2178 [proxy]
2179 name = "bad"
2180 [proxy.listen]
2181
2182 [[backends]]
2183 name = "broken"
2184 transport = "stdio"
2185 "#;
2186
2187 let err = ProxyConfig::parse(toml).unwrap_err();
2188 assert!(
2189 format!("{err}").contains("stdio transport requires 'command'"),
2190 "unexpected error: {err}"
2191 );
2192 }
2193
2194 #[test]
2195 fn test_reject_http_without_url() {
2196 let toml = r#"
2197 [proxy]
2198 name = "bad"
2199 [proxy.listen]
2200
2201 [[backends]]
2202 name = "broken"
2203 transport = "http"
2204 "#;
2205
2206 let err = ProxyConfig::parse(toml).unwrap_err();
2207 assert!(
2208 format!("{err}").contains("http transport requires 'url'"),
2209 "unexpected error: {err}"
2210 );
2211 }
2212
2213 #[test]
2214 fn test_reject_invalid_circuit_breaker_threshold() {
2215 let toml = r#"
2216 [proxy]
2217 name = "bad"
2218 [proxy.listen]
2219
2220 [[backends]]
2221 name = "svc"
2222 transport = "stdio"
2223 command = "echo"
2224
2225 [backends.circuit_breaker]
2226 failure_rate_threshold = 1.5
2227 "#;
2228
2229 let err = ProxyConfig::parse(toml).unwrap_err();
2230 assert!(
2231 format!("{err}").contains("failure_rate_threshold must be in (0.0, 1.0]"),
2232 "unexpected error: {err}"
2233 );
2234 }
2235
2236 #[test]
2237 fn test_reject_zero_rate_limit() {
2238 let toml = r#"
2239 [proxy]
2240 name = "bad"
2241 [proxy.listen]
2242
2243 [[backends]]
2244 name = "svc"
2245 transport = "stdio"
2246 command = "echo"
2247
2248 [backends.rate_limit]
2249 requests = 0
2250 "#;
2251
2252 let err = ProxyConfig::parse(toml).unwrap_err();
2253 assert!(
2254 format!("{err}").contains("rate_limit.requests must be > 0"),
2255 "unexpected error: {err}"
2256 );
2257 }
2258
2259 #[test]
2260 fn test_reject_zero_concurrency() {
2261 let toml = r#"
2262 [proxy]
2263 name = "bad"
2264 [proxy.listen]
2265
2266 [[backends]]
2267 name = "svc"
2268 transport = "stdio"
2269 command = "echo"
2270
2271 [backends.concurrency]
2272 max_concurrent = 0
2273 "#;
2274
2275 let err = ProxyConfig::parse(toml).unwrap_err();
2276 assert!(
2277 format!("{err}").contains("concurrency.max_concurrent must be > 0"),
2278 "unexpected error: {err}"
2279 );
2280 }
2281
2282 #[test]
2283 fn test_reject_expose_and_hide_tools() {
2284 let toml = r#"
2285 [proxy]
2286 name = "bad"
2287 [proxy.listen]
2288
2289 [[backends]]
2290 name = "svc"
2291 transport = "stdio"
2292 command = "echo"
2293 expose_tools = ["read"]
2294 hide_tools = ["write"]
2295 "#;
2296
2297 let err = ProxyConfig::parse(toml).unwrap_err();
2298 assert!(
2299 format!("{err}").contains("cannot specify both expose_tools and hide_tools"),
2300 "unexpected error: {err}"
2301 );
2302 }
2303
2304 #[test]
2305 fn test_reject_expose_and_hide_resources() {
2306 let toml = r#"
2307 [proxy]
2308 name = "bad"
2309 [proxy.listen]
2310
2311 [[backends]]
2312 name = "svc"
2313 transport = "stdio"
2314 command = "echo"
2315 expose_resources = ["file:///a"]
2316 hide_resources = ["file:///b"]
2317 "#;
2318
2319 let err = ProxyConfig::parse(toml).unwrap_err();
2320 assert!(
2321 format!("{err}").contains("cannot specify both expose_resources and hide_resources"),
2322 "unexpected error: {err}"
2323 );
2324 }
2325
2326 #[test]
2327 fn test_reject_expose_and_hide_prompts() {
2328 let toml = r#"
2329 [proxy]
2330 name = "bad"
2331 [proxy.listen]
2332
2333 [[backends]]
2334 name = "svc"
2335 transport = "stdio"
2336 command = "echo"
2337 expose_prompts = ["help"]
2338 hide_prompts = ["admin"]
2339 "#;
2340
2341 let err = ProxyConfig::parse(toml).unwrap_err();
2342 assert!(
2343 format!("{err}").contains("cannot specify both expose_prompts and hide_prompts"),
2344 "unexpected error: {err}"
2345 );
2346 }
2347
2348 #[test]
2353 fn test_resolve_env_vars() {
2354 unsafe { std::env::set_var("MCP_GW_TEST_TOKEN", "secret-123") };
2356
2357 let toml = r#"
2358 [proxy]
2359 name = "env-test"
2360 [proxy.listen]
2361
2362 [[backends]]
2363 name = "svc"
2364 transport = "stdio"
2365 command = "echo"
2366
2367 [backends.env]
2368 API_TOKEN = "${MCP_GW_TEST_TOKEN}"
2369 STATIC_VAL = "unchanged"
2370 "#;
2371
2372 let mut config = ProxyConfig::parse(toml).unwrap();
2373 config.resolve_env_vars();
2374
2375 assert_eq!(
2376 config.backends[0].env.get("API_TOKEN").unwrap(),
2377 "secret-123"
2378 );
2379 assert_eq!(
2380 config.backends[0].env.get("STATIC_VAL").unwrap(),
2381 "unchanged"
2382 );
2383
2384 unsafe { std::env::remove_var("MCP_GW_TEST_TOKEN") };
2386 }
2387
2388 #[test]
2389 fn test_parse_bearer_token_and_forward_auth() {
2390 let toml = r#"
2391 [proxy]
2392 name = "token-gw"
2393 [proxy.listen]
2394
2395 [[backends]]
2396 name = "github"
2397 transport = "http"
2398 url = "http://localhost:3000"
2399 bearer_token = "ghp_abc123"
2400 forward_auth = true
2401
2402 [[backends]]
2403 name = "db"
2404 transport = "http"
2405 url = "http://localhost:5432"
2406 "#;
2407
2408 let config = ProxyConfig::parse(toml).unwrap();
2409 assert_eq!(
2410 config.backends[0].bearer_token.as_deref(),
2411 Some("ghp_abc123")
2412 );
2413 assert!(config.backends[0].forward_auth);
2414 assert!(config.backends[1].bearer_token.is_none());
2415 assert!(!config.backends[1].forward_auth);
2416 }
2417
2418 #[test]
2419 fn test_resolve_bearer_token_env_var() {
2420 unsafe { std::env::set_var("MCP_GW_TEST_BEARER", "resolved-token") };
2421
2422 let toml = r#"
2423 [proxy]
2424 name = "env-token"
2425 [proxy.listen]
2426
2427 [[backends]]
2428 name = "api"
2429 transport = "http"
2430 url = "http://localhost:3000"
2431 bearer_token = "${MCP_GW_TEST_BEARER}"
2432 "#;
2433
2434 let mut config = ProxyConfig::parse(toml).unwrap();
2435 config.resolve_env_vars();
2436
2437 assert_eq!(
2438 config.backends[0].bearer_token.as_deref(),
2439 Some("resolved-token")
2440 );
2441
2442 unsafe { std::env::remove_var("MCP_GW_TEST_BEARER") };
2443 }
2444
2445 #[test]
2446 fn test_parse_outlier_detection() {
2447 let toml = r#"
2448 [proxy]
2449 name = "od-gw"
2450 [proxy.listen]
2451
2452 [[backends]]
2453 name = "flaky"
2454 transport = "http"
2455 url = "http://localhost:8080"
2456
2457 [backends.outlier_detection]
2458 consecutive_errors = 3
2459 interval_seconds = 5
2460 base_ejection_seconds = 60
2461 max_ejection_percent = 25
2462 "#;
2463
2464 let config = ProxyConfig::parse(toml).unwrap();
2465 let od = config.backends[0]
2466 .outlier_detection
2467 .as_ref()
2468 .expect("should have outlier_detection");
2469 assert_eq!(od.consecutive_errors, 3);
2470 assert_eq!(od.interval_seconds, 5);
2471 assert_eq!(od.base_ejection_seconds, 60);
2472 assert_eq!(od.max_ejection_percent, 25);
2473 }
2474
2475 #[test]
2476 fn test_parse_outlier_detection_defaults() {
2477 let toml = r#"
2478 [proxy]
2479 name = "od-gw"
2480 [proxy.listen]
2481
2482 [[backends]]
2483 name = "flaky"
2484 transport = "http"
2485 url = "http://localhost:8080"
2486
2487 [backends.outlier_detection]
2488 "#;
2489
2490 let config = ProxyConfig::parse(toml).unwrap();
2491 let od = config.backends[0]
2492 .outlier_detection
2493 .as_ref()
2494 .expect("should have outlier_detection");
2495 assert_eq!(od.consecutive_errors, 5);
2496 assert_eq!(od.interval_seconds, 10);
2497 assert_eq!(od.base_ejection_seconds, 30);
2498 assert_eq!(od.max_ejection_percent, 50);
2499 }
2500
2501 #[test]
2502 fn test_parse_mirror_config() {
2503 let toml = r#"
2504 [proxy]
2505 name = "mirror-gw"
2506 [proxy.listen]
2507
2508 [[backends]]
2509 name = "api"
2510 transport = "http"
2511 url = "http://localhost:8080"
2512
2513 [[backends]]
2514 name = "api-v2"
2515 transport = "http"
2516 url = "http://localhost:8081"
2517 mirror_of = "api"
2518 mirror_percent = 10
2519 "#;
2520
2521 let config = ProxyConfig::parse(toml).unwrap();
2522 assert!(config.backends[0].mirror_of.is_none());
2523 assert_eq!(config.backends[1].mirror_of.as_deref(), Some("api"));
2524 assert_eq!(config.backends[1].mirror_percent, 10);
2525 }
2526
2527 #[test]
2528 fn test_mirror_percent_defaults_to_100() {
2529 let toml = r#"
2530 [proxy]
2531 name = "mirror-gw"
2532 [proxy.listen]
2533
2534 [[backends]]
2535 name = "api"
2536 transport = "http"
2537 url = "http://localhost:8080"
2538
2539 [[backends]]
2540 name = "api-v2"
2541 transport = "http"
2542 url = "http://localhost:8081"
2543 mirror_of = "api"
2544 "#;
2545
2546 let config = ProxyConfig::parse(toml).unwrap();
2547 assert_eq!(config.backends[1].mirror_percent, 100);
2548 }
2549
2550 #[test]
2551 fn test_reject_mirror_unknown_backend() {
2552 let toml = r#"
2553 [proxy]
2554 name = "bad"
2555 [proxy.listen]
2556
2557 [[backends]]
2558 name = "api-v2"
2559 transport = "http"
2560 url = "http://localhost:8081"
2561 mirror_of = "nonexistent"
2562 "#;
2563
2564 let err = ProxyConfig::parse(toml).unwrap_err();
2565 assert!(
2566 format!("{err}").contains("mirror_of references unknown backend"),
2567 "unexpected error: {err}"
2568 );
2569 }
2570
2571 #[test]
2572 fn test_reject_mirror_self() {
2573 let toml = r#"
2574 [proxy]
2575 name = "bad"
2576 [proxy.listen]
2577
2578 [[backends]]
2579 name = "api"
2580 transport = "http"
2581 url = "http://localhost:8080"
2582 mirror_of = "api"
2583 "#;
2584
2585 let err = ProxyConfig::parse(toml).unwrap_err();
2586 assert!(
2587 format!("{err}").contains("mirror_of cannot reference itself"),
2588 "unexpected error: {err}"
2589 );
2590 }
2591
2592 #[test]
2593 fn test_parse_hedging_config() {
2594 let toml = r#"
2595 [proxy]
2596 name = "hedge-gw"
2597 [proxy.listen]
2598
2599 [[backends]]
2600 name = "api"
2601 transport = "http"
2602 url = "http://localhost:8080"
2603
2604 [backends.hedging]
2605 delay_ms = 150
2606 max_hedges = 2
2607 "#;
2608
2609 let config = ProxyConfig::parse(toml).unwrap();
2610 let hedge = config.backends[0]
2611 .hedging
2612 .as_ref()
2613 .expect("should have hedging");
2614 assert_eq!(hedge.delay_ms, 150);
2615 assert_eq!(hedge.max_hedges, 2);
2616 }
2617
2618 #[test]
2619 fn test_parse_hedging_defaults() {
2620 let toml = r#"
2621 [proxy]
2622 name = "hedge-gw"
2623 [proxy.listen]
2624
2625 [[backends]]
2626 name = "api"
2627 transport = "http"
2628 url = "http://localhost:8080"
2629
2630 [backends.hedging]
2631 "#;
2632
2633 let config = ProxyConfig::parse(toml).unwrap();
2634 let hedge = config.backends[0]
2635 .hedging
2636 .as_ref()
2637 .expect("should have hedging");
2638 assert_eq!(hedge.delay_ms, 200);
2639 assert_eq!(hedge.max_hedges, 1);
2640 }
2641
2642 #[test]
2647 fn test_build_filter_allowlist() {
2648 let toml = r#"
2649 [proxy]
2650 name = "filter"
2651 [proxy.listen]
2652
2653 [[backends]]
2654 name = "svc"
2655 transport = "stdio"
2656 command = "echo"
2657 expose_tools = ["read", "list"]
2658 "#;
2659
2660 let config = ProxyConfig::parse(toml).unwrap();
2661 let filter = config.backends[0]
2662 .build_filter(&config.proxy.separator)
2663 .unwrap()
2664 .expect("should have filter");
2665 assert_eq!(filter.namespace, "svc/");
2666 assert!(filter.tool_filter.allows("read"));
2667 assert!(filter.tool_filter.allows("list"));
2668 assert!(!filter.tool_filter.allows("delete"));
2669 }
2670
2671 #[test]
2672 fn test_build_filter_denylist() {
2673 let toml = r#"
2674 [proxy]
2675 name = "filter"
2676 [proxy.listen]
2677
2678 [[backends]]
2679 name = "svc"
2680 transport = "stdio"
2681 command = "echo"
2682 hide_tools = ["delete", "write"]
2683 "#;
2684
2685 let config = ProxyConfig::parse(toml).unwrap();
2686 let filter = config.backends[0]
2687 .build_filter(&config.proxy.separator)
2688 .unwrap()
2689 .expect("should have filter");
2690 assert!(filter.tool_filter.allows("read"));
2691 assert!(!filter.tool_filter.allows("delete"));
2692 assert!(!filter.tool_filter.allows("write"));
2693 }
2694
2695 #[test]
2696 fn test_parse_inject_args() {
2697 let toml = r#"
2698 [proxy]
2699 name = "inject-gw"
2700 [proxy.listen]
2701
2702 [[backends]]
2703 name = "db"
2704 transport = "http"
2705 url = "http://localhost:8080"
2706
2707 [backends.default_args]
2708 timeout = 30
2709
2710 [[backends.inject_args]]
2711 tool = "query"
2712 args = { read_only = true, max_rows = 1000 }
2713
2714 [[backends.inject_args]]
2715 tool = "dangerous_op"
2716 args = { dry_run = true }
2717 overwrite = true
2718 "#;
2719
2720 let config = ProxyConfig::parse(toml).unwrap();
2721 let backend = &config.backends[0];
2722
2723 assert_eq!(backend.default_args.len(), 1);
2724 assert_eq!(backend.default_args["timeout"], 30);
2725
2726 assert_eq!(backend.inject_args.len(), 2);
2727 assert_eq!(backend.inject_args[0].tool, "query");
2728 assert_eq!(backend.inject_args[0].args["read_only"], true);
2729 assert_eq!(backend.inject_args[0].args["max_rows"], 1000);
2730 assert!(!backend.inject_args[0].overwrite);
2731
2732 assert_eq!(backend.inject_args[1].tool, "dangerous_op");
2733 assert_eq!(backend.inject_args[1].args["dry_run"], true);
2734 assert!(backend.inject_args[1].overwrite);
2735 }
2736
2737 #[test]
2738 fn test_parse_inject_args_defaults_to_empty() {
2739 let config = ProxyConfig::parse(minimal_config()).unwrap();
2740 assert!(config.backends[0].default_args.is_empty());
2741 assert!(config.backends[0].inject_args.is_empty());
2742 }
2743
2744 #[test]
2745 fn test_build_filter_none_when_no_filtering() {
2746 let config = ProxyConfig::parse(minimal_config()).unwrap();
2747 assert!(
2748 config.backends[0]
2749 .build_filter(&config.proxy.separator)
2750 .unwrap()
2751 .is_none()
2752 );
2753 }
2754
2755 #[test]
2756 fn test_validate_rejects_duplicate_backend_names() {
2757 let toml = r#"
2758 [proxy]
2759 name = "test"
2760 [proxy.listen]
2761
2762 [[backends]]
2763 name = "echo"
2764 transport = "stdio"
2765 command = "echo"
2766
2767 [[backends]]
2768 name = "echo"
2769 transport = "stdio"
2770 command = "cat"
2771 "#;
2772 let err = ProxyConfig::parse(toml).unwrap_err();
2773 assert!(
2774 err.to_string().contains("duplicate backend name"),
2775 "expected duplicate error, got: {}",
2776 err
2777 );
2778 }
2779
2780 #[test]
2781 fn test_validate_global_rate_limit_zero_requests() {
2782 let toml = r#"
2783 [proxy]
2784 name = "test"
2785 [proxy.listen]
2786 [proxy.rate_limit]
2787 requests = 0
2788
2789 [[backends]]
2790 name = "echo"
2791 transport = "stdio"
2792 command = "echo"
2793 "#;
2794 let err = ProxyConfig::parse(toml).unwrap_err();
2795 assert!(err.to_string().contains("requests must be > 0"));
2796 }
2797
2798 #[test]
2799 fn test_validate_jwt_requires_admin_token() {
2800 let toml = r#"
2803 [proxy]
2804 name = "jwt-gw"
2805 [proxy.listen]
2806
2807 [[backends]]
2808 name = "echo"
2809 transport = "stdio"
2810 command = "echo"
2811
2812 [auth]
2813 type = "jwt"
2814 issuer = "https://auth.example.com"
2815 audience = "mcp-proxy"
2816 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2817 "#;
2818 let err = ProxyConfig::parse(toml).unwrap_err();
2819 assert!(
2820 err.to_string().contains("admin_token"),
2821 "expected admin_token error, got: {err}"
2822 );
2823 }
2824
2825 #[test]
2826 fn test_validate_jwt_with_admin_token_ok() {
2827 let toml = r#"
2829 [proxy]
2830 name = "jwt-gw"
2831 [proxy.listen]
2832
2833 [[backends]]
2834 name = "echo"
2835 transport = "stdio"
2836 command = "echo"
2837
2838 [auth]
2839 type = "jwt"
2840 issuer = "https://auth.example.com"
2841 audience = "mcp-proxy"
2842 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2843
2844 [security]
2845 admin_token = "admin-secret"
2846 "#;
2847 assert!(ProxyConfig::parse(toml).is_ok());
2848 }
2849
2850 #[test]
2851 fn test_validate_oauth_requires_admin_token() {
2852 let toml = r#"
2855 [proxy]
2856 name = "oauth-gw"
2857 [proxy.listen]
2858
2859 [[backends]]
2860 name = "echo"
2861 transport = "stdio"
2862 command = "echo"
2863
2864 [auth]
2865 type = "oauth"
2866 issuer = "https://auth.example.com"
2867 audience = "mcp-proxy"
2868 "#;
2869 let err = ProxyConfig::parse(toml).unwrap_err();
2870 assert!(
2871 err.to_string().contains("admin_token"),
2872 "expected admin_token error, got: {err}"
2873 );
2874 }
2875
2876 #[test]
2877 fn test_parse_global_rate_limit() {
2878 let toml = r#"
2879 [proxy]
2880 name = "test"
2881 [proxy.listen]
2882 [proxy.rate_limit]
2883 requests = 500
2884 period_seconds = 1
2885
2886 [[backends]]
2887 name = "echo"
2888 transport = "stdio"
2889 command = "echo"
2890 "#;
2891 let config = ProxyConfig::parse(toml).unwrap();
2892 let rl = config.proxy.rate_limit.unwrap();
2893 assert_eq!(rl.requests, 500);
2894 assert_eq!(rl.period_seconds, 1);
2895 }
2896
2897 #[test]
2898 fn test_name_filter_glob_wildcard() {
2899 let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
2900 assert!(filter.allows("read_file"));
2901 assert!(filter.allows("write_file"));
2902 assert!(!filter.allows("query"));
2903 assert!(!filter.allows("file_read"));
2904 }
2905
2906 #[test]
2907 fn test_name_filter_glob_prefix() {
2908 let filter = NameFilter::allow_list(["list_*".to_string()]).unwrap();
2909 assert!(filter.allows("list_files"));
2910 assert!(filter.allows("list_users"));
2911 assert!(!filter.allows("get_files"));
2912 }
2913
2914 #[test]
2915 fn test_name_filter_glob_question_mark() {
2916 let filter = NameFilter::allow_list(["get_?".to_string()]).unwrap();
2917 assert!(filter.allows("get_a"));
2918 assert!(filter.allows("get_1"));
2919 assert!(!filter.allows("get_ab"));
2920 assert!(!filter.allows("get_"));
2921 }
2922
2923 #[test]
2924 fn test_name_filter_glob_deny_list() {
2925 let filter = NameFilter::deny_list(["*_delete*".to_string()]).unwrap();
2926 assert!(filter.allows("read_file"));
2927 assert!(filter.allows("create_issue"));
2928 assert!(!filter.allows("force_delete_all"));
2929 assert!(!filter.allows("soft_delete"));
2930 }
2931
2932 #[test]
2933 fn test_name_filter_glob_exact_match_still_works() {
2934 let filter = NameFilter::allow_list(["read_file".to_string()]).unwrap();
2935 assert!(filter.allows("read_file"));
2936 assert!(!filter.allows("write_file"));
2937 }
2938
2939 #[test]
2940 fn test_name_filter_glob_multiple_patterns() {
2941 let filter = NameFilter::allow_list(["read_*".to_string(), "list_*".to_string()]).unwrap();
2942 assert!(filter.allows("read_file"));
2943 assert!(filter.allows("list_users"));
2944 assert!(!filter.allows("delete_file"));
2945 }
2946
2947 #[test]
2948 fn test_name_filter_regex_allow_list() {
2949 let filter =
2950 NameFilter::allow_list(["re:^list_.*$".to_string(), "re:^get_\\w+$".to_string()])
2951 .unwrap();
2952 assert!(filter.allows("list_files"));
2953 assert!(filter.allows("list_users"));
2954 assert!(filter.allows("get_item"));
2955 assert!(!filter.allows("delete_file"));
2956 assert!(!filter.allows("create_issue"));
2957 }
2958
2959 #[test]
2960 fn test_name_filter_regex_deny_list() {
2961 let filter = NameFilter::deny_list(["re:^delete_".to_string()]).unwrap();
2962 assert!(filter.allows("read_file"));
2963 assert!(filter.allows("list_users"));
2964 assert!(!filter.allows("delete_file"));
2965 assert!(!filter.allows("delete_all"));
2966 }
2967
2968 #[test]
2969 fn test_name_filter_mixed_glob_and_regex() {
2970 let filter =
2971 NameFilter::allow_list(["read_*".to_string(), "re:^list_\\w+$".to_string()]).unwrap();
2972 assert!(filter.allows("read_file"));
2973 assert!(filter.allows("read_dir"));
2974 assert!(filter.allows("list_users"));
2975 assert!(!filter.allows("delete_file"));
2976 }
2977
2978 #[test]
2979 fn test_name_filter_regex_invalid_pattern() {
2980 let result = NameFilter::allow_list(["re:[invalid".to_string()]);
2981 assert!(result.is_err(), "invalid regex should produce an error");
2982 }
2983
2984 #[test]
2985 fn test_name_filter_regex_partial_match() {
2986 let filter = NameFilter::allow_list(["re:list".to_string()]).unwrap();
2988 assert!(filter.allows("list_files"));
2989 assert!(filter.allows("my_list_tool"));
2990 assert!(!filter.allows("read_file"));
2991 }
2992
2993 #[test]
2994 fn test_config_parse_regex_filter() {
2995 let toml = r#"
2996 [proxy]
2997 name = "regex-gw"
2998 [proxy.listen]
2999
3000 [[backends]]
3001 name = "svc"
3002 transport = "stdio"
3003 command = "echo"
3004 expose_tools = ["*_issue", "re:^list_.*$"]
3005 "#;
3006
3007 let config = ProxyConfig::parse(toml).unwrap();
3008 let filter = config.backends[0]
3009 .build_filter(&config.proxy.separator)
3010 .unwrap()
3011 .expect("should have filter");
3012 assert!(filter.tool_filter.allows("create_issue"));
3013 assert!(filter.tool_filter.allows("list_files"));
3014 assert!(filter.tool_filter.allows("list_users"));
3015 assert!(!filter.tool_filter.allows("delete_file"));
3016 }
3017
3018 #[test]
3019 fn test_parse_param_overrides() {
3020 let toml = r#"
3021 [proxy]
3022 name = "override-gw"
3023 [proxy.listen]
3024
3025 [[backends]]
3026 name = "fs"
3027 transport = "http"
3028 url = "http://localhost:8080"
3029
3030 [[backends.param_overrides]]
3031 tool = "list_directory"
3032 hide = ["path"]
3033 rename = { recursive = "deep_search" }
3034
3035 [backends.param_overrides.defaults]
3036 path = "/home/docs"
3037 "#;
3038
3039 let config = ProxyConfig::parse(toml).unwrap();
3040 assert_eq!(config.backends[0].param_overrides.len(), 1);
3041 let po = &config.backends[0].param_overrides[0];
3042 assert_eq!(po.tool, "list_directory");
3043 assert_eq!(po.hide, vec!["path"]);
3044 assert_eq!(po.defaults.get("path").unwrap(), "/home/docs");
3045 assert_eq!(po.rename.get("recursive").unwrap(), "deep_search");
3046 }
3047
3048 #[test]
3049 fn test_reject_param_override_empty_tool() {
3050 let toml = r#"
3051 [proxy]
3052 name = "bad"
3053 [proxy.listen]
3054
3055 [[backends]]
3056 name = "fs"
3057 transport = "http"
3058 url = "http://localhost:8080"
3059
3060 [[backends.param_overrides]]
3061 tool = ""
3062 hide = ["path"]
3063 "#;
3064
3065 let err = ProxyConfig::parse(toml).unwrap_err();
3066 assert!(
3067 format!("{err}").contains("tool must not be empty"),
3068 "unexpected error: {err}"
3069 );
3070 }
3071
3072 #[test]
3073 fn test_reject_param_override_duplicate_tool() {
3074 let toml = r#"
3075 [proxy]
3076 name = "bad"
3077 [proxy.listen]
3078
3079 [[backends]]
3080 name = "fs"
3081 transport = "http"
3082 url = "http://localhost:8080"
3083
3084 [[backends.param_overrides]]
3085 tool = "list_directory"
3086 hide = ["path"]
3087
3088 [[backends.param_overrides]]
3089 tool = "list_directory"
3090 hide = ["pattern"]
3091 "#;
3092
3093 let err = ProxyConfig::parse(toml).unwrap_err();
3094 assert!(
3095 format!("{err}").contains("duplicate param_overrides"),
3096 "unexpected error: {err}"
3097 );
3098 }
3099
3100 #[test]
3101 fn test_reject_param_override_hide_and_rename_same_param() {
3102 let toml = r#"
3103 [proxy]
3104 name = "bad"
3105 [proxy.listen]
3106
3107 [[backends]]
3108 name = "fs"
3109 transport = "http"
3110 url = "http://localhost:8080"
3111
3112 [[backends.param_overrides]]
3113 tool = "list_directory"
3114 hide = ["path"]
3115 rename = { path = "dir" }
3116 "#;
3117
3118 let err = ProxyConfig::parse(toml).unwrap_err();
3119 assert!(
3120 format!("{err}").contains("cannot be both hidden and renamed"),
3121 "unexpected error: {err}"
3122 );
3123 }
3124
3125 #[test]
3126 fn test_reject_param_override_duplicate_rename_target() {
3127 let toml = r#"
3128 [proxy]
3129 name = "bad"
3130 [proxy.listen]
3131
3132 [[backends]]
3133 name = "fs"
3134 transport = "http"
3135 url = "http://localhost:8080"
3136
3137 [[backends.param_overrides]]
3138 tool = "list_directory"
3139 rename = { path = "location", dir = "location" }
3140 "#;
3141
3142 let err = ProxyConfig::parse(toml).unwrap_err();
3143 assert!(
3144 format!("{err}").contains("duplicate rename target"),
3145 "unexpected error: {err}"
3146 );
3147 }
3148
3149 #[test]
3150 fn test_cache_backend_defaults_to_memory() {
3151 let config = ProxyConfig::parse(minimal_config()).unwrap();
3152 assert_eq!(config.cache.backend, "memory");
3153 assert!(config.cache.url.is_none());
3154 }
3155
3156 #[test]
3157 fn test_cache_backend_redis_requires_url() {
3158 let toml = r#"
3159 [proxy]
3160 name = "test"
3161 [proxy.listen]
3162 [cache]
3163 backend = "redis"
3164
3165 [[backends]]
3166 name = "echo"
3167 transport = "stdio"
3168 command = "echo"
3169 "#;
3170 let err = ProxyConfig::parse(toml).unwrap_err();
3171 assert!(err.to_string().contains("cache.url is required"));
3172 }
3173
3174 #[test]
3175 fn test_cache_backend_unknown_rejected() {
3176 let toml = r#"
3177 [proxy]
3178 name = "test"
3179 [proxy.listen]
3180 [cache]
3181 backend = "memcached"
3182
3183 [[backends]]
3184 name = "echo"
3185 transport = "stdio"
3186 command = "echo"
3187 "#;
3188 let err = ProxyConfig::parse(toml).unwrap_err();
3189 assert!(err.to_string().contains("unknown cache backend"));
3190 }
3191
3192 #[test]
3193 fn test_cache_backend_redis_with_url() {
3194 let toml = r#"
3195 [proxy]
3196 name = "test"
3197 [proxy.listen]
3198 [cache]
3199 backend = "redis"
3200 url = "redis://localhost:6379"
3201 prefix = "myapp:"
3202
3203 [[backends]]
3204 name = "echo"
3205 transport = "stdio"
3206 command = "echo"
3207 "#;
3208 let config = ProxyConfig::parse(toml).unwrap();
3209 assert_eq!(config.cache.backend, "redis");
3210 assert_eq!(config.cache.url.as_deref(), Some("redis://localhost:6379"));
3211 assert_eq!(config.cache.prefix, "myapp:");
3212 }
3213
3214 #[test]
3215 fn test_parse_bearer_scoped_tokens() {
3216 let toml = r#"
3217 [proxy]
3218 name = "scoped"
3219 [proxy.listen]
3220
3221 [[backends]]
3222 name = "echo"
3223 transport = "stdio"
3224 command = "echo"
3225
3226 [auth]
3227 type = "bearer"
3228
3229 [[auth.scoped_tokens]]
3230 token = "frontend-token"
3231 allow_tools = ["echo/read_file"]
3232
3233 [[auth.scoped_tokens]]
3234 token = "admin-token"
3235 "#;
3236
3237 let config = ProxyConfig::parse(toml).unwrap();
3238 match &config.auth {
3239 Some(AuthConfig::Bearer {
3240 tokens,
3241 scoped_tokens,
3242 }) => {
3243 assert!(tokens.is_empty());
3244 assert_eq!(scoped_tokens.len(), 2);
3245 assert_eq!(scoped_tokens[0].token, "frontend-token");
3246 assert_eq!(scoped_tokens[0].allow_tools, vec!["echo/read_file"]);
3247 assert!(scoped_tokens[1].allow_tools.is_empty());
3248 }
3249 other => panic!("expected Bearer auth, got: {other:?}"),
3250 }
3251 }
3252
3253 #[test]
3254 fn test_parse_bearer_mixed_tokens() {
3255 let toml = r#"
3256 [proxy]
3257 name = "mixed"
3258 [proxy.listen]
3259
3260 [[backends]]
3261 name = "echo"
3262 transport = "stdio"
3263 command = "echo"
3264
3265 [auth]
3266 type = "bearer"
3267 tokens = ["simple-token"]
3268
3269 [[auth.scoped_tokens]]
3270 token = "scoped-token"
3271 deny_tools = ["echo/delete"]
3272 "#;
3273
3274 let config = ProxyConfig::parse(toml).unwrap();
3275 match &config.auth {
3276 Some(AuthConfig::Bearer {
3277 tokens,
3278 scoped_tokens,
3279 }) => {
3280 assert_eq!(tokens, &["simple-token"]);
3281 assert_eq!(scoped_tokens.len(), 1);
3282 assert_eq!(scoped_tokens[0].deny_tools, vec!["echo/delete"]);
3283 }
3284 other => panic!("expected Bearer auth, got: {other:?}"),
3285 }
3286 }
3287
3288 #[test]
3289 fn test_bearer_empty_tokens_rejected() {
3290 let toml = r#"
3291 [proxy]
3292 name = "empty"
3293 [proxy.listen]
3294
3295 [[backends]]
3296 name = "echo"
3297 transport = "stdio"
3298 command = "echo"
3299
3300 [auth]
3301 type = "bearer"
3302 "#;
3303
3304 let err = ProxyConfig::parse(toml).unwrap_err();
3305 assert!(
3306 err.to_string().contains("at least one token"),
3307 "unexpected error: {err}"
3308 );
3309 }
3310
3311 #[test]
3312 fn test_bearer_duplicate_across_lists_rejected() {
3313 let toml = r#"
3314 [proxy]
3315 name = "dup"
3316 [proxy.listen]
3317
3318 [[backends]]
3319 name = "echo"
3320 transport = "stdio"
3321 command = "echo"
3322
3323 [auth]
3324 type = "bearer"
3325 tokens = ["shared-token"]
3326
3327 [[auth.scoped_tokens]]
3328 token = "shared-token"
3329 allow_tools = ["echo/read"]
3330 "#;
3331
3332 let err = ProxyConfig::parse(toml).unwrap_err();
3333 assert!(
3334 err.to_string().contains("duplicate bearer token"),
3335 "unexpected error: {err}"
3336 );
3337 }
3338
3339 #[test]
3340 fn test_bearer_allow_and_deny_rejected() {
3341 let toml = r#"
3342 [proxy]
3343 name = "both"
3344 [proxy.listen]
3345
3346 [[backends]]
3347 name = "echo"
3348 transport = "stdio"
3349 command = "echo"
3350
3351 [auth]
3352 type = "bearer"
3353
3354 [[auth.scoped_tokens]]
3355 token = "conflict"
3356 allow_tools = ["echo/read"]
3357 deny_tools = ["echo/write"]
3358 "#;
3359
3360 let err = ProxyConfig::parse(toml).unwrap_err();
3361 assert!(
3362 err.to_string().contains("cannot specify both"),
3363 "unexpected error: {err}"
3364 );
3365 }
3366
3367 #[test]
3368 fn test_parse_websocket_transport() {
3369 let toml = r#"
3370 [proxy]
3371 name = "ws-proxy"
3372 [proxy.listen]
3373
3374 [[backends]]
3375 name = "ws-backend"
3376 transport = "websocket"
3377 url = "ws://localhost:9090/ws"
3378 "#;
3379
3380 let config = ProxyConfig::parse(toml).unwrap();
3381 assert!(matches!(
3382 config.backends[0].transport,
3383 TransportType::Websocket
3384 ));
3385 assert_eq!(
3386 config.backends[0].url.as_deref(),
3387 Some("ws://localhost:9090/ws")
3388 );
3389 }
3390
3391 #[test]
3392 fn test_websocket_transport_requires_url() {
3393 let toml = r#"
3394 [proxy]
3395 name = "ws-proxy"
3396 [proxy.listen]
3397
3398 [[backends]]
3399 name = "ws-backend"
3400 transport = "websocket"
3401 "#;
3402
3403 let err = ProxyConfig::parse(toml).unwrap_err();
3404 assert!(
3405 err.to_string()
3406 .contains("websocket transport requires 'url'"),
3407 "unexpected error: {err}"
3408 );
3409 }
3410
3411 #[test]
3412 fn test_websocket_with_bearer_token() {
3413 let toml = r#"
3414 [proxy]
3415 name = "ws-proxy"
3416 [proxy.listen]
3417
3418 [[backends]]
3419 name = "ws-backend"
3420 transport = "websocket"
3421 url = "wss://secure.example.com/mcp"
3422 bearer_token = "my-secret"
3423 "#;
3424
3425 let config = ProxyConfig::parse(toml).unwrap();
3426 assert_eq!(
3427 config.backends[0].bearer_token.as_deref(),
3428 Some("my-secret")
3429 );
3430 }
3431
3432 #[test]
3433 fn test_tool_discovery_defaults_false() {
3434 let config = ProxyConfig::parse(minimal_config()).unwrap();
3435 assert!(!config.proxy.tool_discovery);
3436 }
3437
3438 #[test]
3439 fn test_tool_discovery_enabled() {
3440 let toml = r#"
3441 [proxy]
3442 name = "discovery"
3443 tool_discovery = true
3444 [proxy.listen]
3445
3446 [[backends]]
3447 name = "echo"
3448 transport = "stdio"
3449 command = "echo"
3450 "#;
3451
3452 let config = ProxyConfig::parse(toml).unwrap();
3453 assert!(config.proxy.tool_discovery);
3454 }
3455
3456 #[test]
3457 fn test_parse_oauth_config() {
3458 let toml = r#"
3459 [proxy]
3460 name = "oauth-proxy"
3461 [proxy.listen]
3462
3463 [[backends]]
3464 name = "echo"
3465 transport = "stdio"
3466 command = "echo"
3467
3468 [auth]
3469 type = "oauth"
3470 issuer = "https://accounts.google.com"
3471 audience = "mcp-proxy"
3472
3473 [security]
3474 admin_token = "admin-secret"
3475 "#;
3476
3477 let config = ProxyConfig::parse(toml).unwrap();
3478 match &config.auth {
3479 Some(AuthConfig::OAuth {
3480 issuer,
3481 audience,
3482 token_validation,
3483 ..
3484 }) => {
3485 assert_eq!(issuer, "https://accounts.google.com");
3486 assert_eq!(audience, "mcp-proxy");
3487 assert_eq!(token_validation, &TokenValidationStrategy::Jwt);
3488 }
3489 other => panic!("expected OAuth auth, got: {other:?}"),
3490 }
3491 }
3492
3493 #[test]
3494 fn test_parse_oauth_with_introspection() {
3495 let toml = r#"
3496 [proxy]
3497 name = "oauth-proxy"
3498 [proxy.listen]
3499
3500 [[backends]]
3501 name = "echo"
3502 transport = "stdio"
3503 command = "echo"
3504
3505 [auth]
3506 type = "oauth"
3507 issuer = "https://auth.example.com"
3508 audience = "mcp-proxy"
3509 client_id = "my-client"
3510 client_secret = "my-secret"
3511 token_validation = "introspection"
3512
3513 [security]
3514 admin_token = "admin-secret"
3515 "#;
3516
3517 let config = ProxyConfig::parse(toml).unwrap();
3518 match &config.auth {
3519 Some(AuthConfig::OAuth {
3520 token_validation,
3521 client_id,
3522 client_secret,
3523 ..
3524 }) => {
3525 assert_eq!(token_validation, &TokenValidationStrategy::Introspection);
3526 assert_eq!(client_id.as_deref(), Some("my-client"));
3527 assert_eq!(client_secret.as_deref(), Some("my-secret"));
3528 }
3529 other => panic!("expected OAuth auth, got: {other:?}"),
3530 }
3531 }
3532
3533 #[test]
3534 fn test_oauth_introspection_requires_credentials() {
3535 let toml = r#"
3536 [proxy]
3537 name = "oauth-proxy"
3538 [proxy.listen]
3539
3540 [[backends]]
3541 name = "echo"
3542 transport = "stdio"
3543 command = "echo"
3544
3545 [auth]
3546 type = "oauth"
3547 issuer = "https://auth.example.com"
3548 audience = "mcp-proxy"
3549 token_validation = "introspection"
3550 "#;
3551
3552 let err = ProxyConfig::parse(toml).unwrap_err();
3553 assert!(
3554 err.to_string().contains("client_id"),
3555 "unexpected error: {err}"
3556 );
3557 }
3558
3559 #[test]
3560 fn test_parse_oauth_with_overrides() {
3561 let toml = r#"
3562 [proxy]
3563 name = "oauth-proxy"
3564 [proxy.listen]
3565
3566 [[backends]]
3567 name = "echo"
3568 transport = "stdio"
3569 command = "echo"
3570
3571 [auth]
3572 type = "oauth"
3573 issuer = "https://auth.example.com"
3574 audience = "mcp-proxy"
3575 jwks_uri = "https://auth.example.com/custom/jwks"
3576 introspection_endpoint = "https://auth.example.com/custom/introspect"
3577 client_id = "my-client"
3578 client_secret = "my-secret"
3579 token_validation = "both"
3580 required_scopes = ["read", "write"]
3581
3582 [security]
3583 admin_token = "admin-secret"
3584 "#;
3585
3586 let config = ProxyConfig::parse(toml).unwrap();
3587 match &config.auth {
3588 Some(AuthConfig::OAuth {
3589 jwks_uri,
3590 introspection_endpoint,
3591 token_validation,
3592 required_scopes,
3593 ..
3594 }) => {
3595 assert_eq!(
3596 jwks_uri.as_deref(),
3597 Some("https://auth.example.com/custom/jwks")
3598 );
3599 assert_eq!(
3600 introspection_endpoint.as_deref(),
3601 Some("https://auth.example.com/custom/introspect")
3602 );
3603 assert_eq!(token_validation, &TokenValidationStrategy::Both);
3604 assert_eq!(required_scopes, &["read", "write"]);
3605 }
3606 other => panic!("expected OAuth auth, got: {other:?}"),
3607 }
3608 }
3609
3610 #[test]
3611 fn test_check_env_vars_warns_on_unset() {
3612 let toml = r#"
3613 [proxy]
3614 name = "env-check"
3615 [proxy.listen]
3616
3617 [[backends]]
3618 name = "svc"
3619 transport = "stdio"
3620 command = "echo"
3621 bearer_token = "${TOTALLY_UNSET_VAR_1}"
3622
3623 [backends.env]
3624 API_KEY = "${TOTALLY_UNSET_VAR_2}"
3625 STATIC = "plain-value"
3626
3627 [auth]
3628 type = "bearer"
3629 tokens = ["${TOTALLY_UNSET_VAR_3}", "literal-token"]
3630
3631 [[auth.scoped_tokens]]
3632 token = "${TOTALLY_UNSET_VAR_4}"
3633 allow_tools = ["svc/echo"]
3634 "#;
3635
3636 let config = ProxyConfig::parse(toml).unwrap();
3637 let warnings = config.check_env_vars();
3638
3639 assert_eq!(warnings.len(), 4, "warnings: {warnings:?}");
3640 assert!(warnings[0].contains("TOTALLY_UNSET_VAR_1"));
3641 assert!(warnings[0].contains("bearer_token"));
3642 assert!(warnings[1].contains("TOTALLY_UNSET_VAR_2"));
3643 assert!(warnings[1].contains("env.API_KEY"));
3644 assert!(warnings[2].contains("TOTALLY_UNSET_VAR_3"));
3645 assert!(warnings[2].contains("tokens[0]"));
3646 assert!(warnings[3].contains("TOTALLY_UNSET_VAR_4"));
3647 assert!(warnings[3].contains("scoped_tokens[0]"));
3648 }
3649
3650 #[test]
3651 fn test_check_env_vars_no_warnings_when_set() {
3652 unsafe { std::env::set_var("MCP_CHECK_TEST_VAR", "value") };
3654
3655 let toml = r#"
3656 [proxy]
3657 name = "env-check"
3658 [proxy.listen]
3659
3660 [[backends]]
3661 name = "svc"
3662 transport = "stdio"
3663 command = "echo"
3664 bearer_token = "${MCP_CHECK_TEST_VAR}"
3665 "#;
3666
3667 let config = ProxyConfig::parse(toml).unwrap();
3668 let warnings = config.check_env_vars();
3669 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3670
3671 unsafe { std::env::remove_var("MCP_CHECK_TEST_VAR") };
3673 }
3674
3675 #[test]
3676 fn test_check_env_vars_no_warnings_for_literals() {
3677 let toml = r#"
3678 [proxy]
3679 name = "env-check"
3680 [proxy.listen]
3681
3682 [[backends]]
3683 name = "svc"
3684 transport = "stdio"
3685 command = "echo"
3686 bearer_token = "literal-token"
3687 "#;
3688
3689 let config = ProxyConfig::parse(toml).unwrap();
3690 let warnings = config.check_env_vars();
3691 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3692 }
3693
3694 #[test]
3695 fn test_check_env_vars_oauth_client_secret() {
3696 let toml = r#"
3697 [proxy]
3698 name = "oauth-check"
3699 [proxy.listen]
3700
3701 [[backends]]
3702 name = "svc"
3703 transport = "http"
3704 url = "http://localhost:3000"
3705
3706 [auth]
3707 type = "oauth"
3708 issuer = "https://auth.example.com"
3709 audience = "mcp-proxy"
3710 client_id = "my-client"
3711 client_secret = "${TOTALLY_UNSET_OAUTH_SECRET}"
3712 token_validation = "introspection"
3713
3714 [security]
3715 admin_token = "admin-secret"
3716 "#;
3717
3718 let config = ProxyConfig::parse(toml).unwrap();
3719 let warnings = config.check_env_vars();
3720 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
3721 assert!(warnings[0].contains("TOTALLY_UNSET_OAUTH_SECRET"));
3722 assert!(warnings[0].contains("client_secret"));
3723 }
3724
3725 #[cfg(feature = "yaml")]
3726 #[test]
3727 fn test_parse_yaml_config() {
3728 let yaml = r#"
3729proxy:
3730 name: yaml-proxy
3731 listen:
3732 host: "127.0.0.1"
3733 port: 8080
3734backends:
3735 - name: echo
3736 transport: stdio
3737 command: echo
3738"#;
3739 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3740 assert_eq!(config.proxy.name, "yaml-proxy");
3741 assert_eq!(config.backends.len(), 1);
3742 assert_eq!(config.backends[0].name, "echo");
3743 }
3744
3745 #[cfg(feature = "yaml")]
3746 #[test]
3747 fn test_parse_yaml_with_auth() {
3748 let yaml = r#"
3749proxy:
3750 name: auth-proxy
3751 listen:
3752 host: "127.0.0.1"
3753 port: 9090
3754backends:
3755 - name: api
3756 transport: stdio
3757 command: echo
3758auth:
3759 type: bearer
3760 tokens:
3761 - token-1
3762 - token-2
3763"#;
3764 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3765 match &config.auth {
3766 Some(AuthConfig::Bearer { tokens, .. }) => {
3767 assert_eq!(tokens, &["token-1", "token-2"]);
3768 }
3769 other => panic!("expected Bearer auth, got: {other:?}"),
3770 }
3771 }
3772
3773 #[cfg(feature = "yaml")]
3774 #[test]
3775 fn test_parse_yaml_with_middleware() {
3776 let yaml = r#"
3777proxy:
3778 name: mw-proxy
3779 listen:
3780 host: "127.0.0.1"
3781 port: 8080
3782backends:
3783 - name: api
3784 transport: stdio
3785 command: echo
3786 timeout:
3787 seconds: 30
3788 rate_limit:
3789 requests: 100
3790 period_seconds: 1
3791 expose_tools:
3792 - read_file
3793 - list_directory
3794"#;
3795 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3796 assert_eq!(config.backends[0].timeout.as_ref().unwrap().seconds, 30);
3797 assert_eq!(
3798 config.backends[0].rate_limit.as_ref().unwrap().requests,
3799 100
3800 );
3801 assert_eq!(
3802 config.backends[0].expose_tools,
3803 vec!["read_file", "list_directory"]
3804 );
3805 }
3806
3807 #[test]
3808 fn test_from_mcp_json() {
3809 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json");
3810 let project_dir = dir.join("my-project");
3811 std::fs::create_dir_all(&project_dir).unwrap();
3812
3813 let mcp_json_path = project_dir.join(".mcp.json");
3814 std::fs::write(
3815 &mcp_json_path,
3816 r#"{
3817 "mcpServers": {
3818 "github": {
3819 "command": "npx",
3820 "args": ["-y", "@modelcontextprotocol/server-github"]
3821 },
3822 "api": {
3823 "url": "http://localhost:9000"
3824 }
3825 }
3826 }"#,
3827 )
3828 .unwrap();
3829
3830 let config = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap();
3831
3832 assert_eq!(config.proxy.name, "my-project");
3834 assert_eq!(config.proxy.listen.host, "127.0.0.1");
3836 assert_eq!(config.proxy.listen.port, 8080);
3837 assert_eq!(config.proxy.version, "0.1.0");
3838 assert_eq!(config.proxy.separator, "/");
3839 assert!(config.auth.is_none());
3841 assert!(config.composite_tools.is_empty());
3842 assert_eq!(config.backends.len(), 2);
3844 assert_eq!(config.backends[0].name, "api");
3845 assert_eq!(config.backends[1].name, "github");
3846
3847 std::fs::remove_dir_all(&dir).unwrap();
3848 }
3849
3850 #[test]
3851 fn test_from_mcp_json_empty_rejects() {
3852 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json_empty");
3853 std::fs::create_dir_all(&dir).unwrap();
3854
3855 let mcp_json_path = dir.join(".mcp.json");
3856 std::fs::write(&mcp_json_path, r#"{ "mcpServers": {} }"#).unwrap();
3857
3858 let err = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap_err();
3859 assert!(
3860 err.to_string().contains("at least one backend"),
3861 "unexpected error: {err}"
3862 );
3863
3864 std::fs::remove_dir_all(&dir).unwrap();
3865 }
3866
3867 #[test]
3868 fn test_priority_defaults_to_zero() {
3869 let toml = r#"
3870 [proxy]
3871 name = "test"
3872 [proxy.listen]
3873
3874 [[backends]]
3875 name = "api"
3876 transport = "stdio"
3877 command = "echo"
3878 "#;
3879
3880 let config = ProxyConfig::parse(toml).unwrap();
3881 assert_eq!(config.backends[0].priority, 0);
3882 }
3883
3884 #[test]
3885 fn test_priority_parsed_from_config() {
3886 let toml = r#"
3887 [proxy]
3888 name = "test"
3889 [proxy.listen]
3890
3891 [[backends]]
3892 name = "api"
3893 transport = "stdio"
3894 command = "echo"
3895
3896 [[backends]]
3897 name = "api-backup-1"
3898 transport = "stdio"
3899 command = "echo"
3900 failover_for = "api"
3901 priority = 10
3902
3903 [[backends]]
3904 name = "api-backup-2"
3905 transport = "stdio"
3906 command = "echo"
3907 failover_for = "api"
3908 priority = 5
3909 "#;
3910
3911 let config = ProxyConfig::parse(toml).unwrap();
3912 assert_eq!(config.backends[0].priority, 0);
3913 assert_eq!(config.backends[1].priority, 10);
3914 assert_eq!(config.backends[2].priority, 5);
3915 }
3916}