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)]
706 required_scopes: Vec<String>,
707 #[serde(default)]
709 roles: Vec<RoleConfig>,
710 role_mapping: Option<RoleMappingConfig>,
712 },
713}
714
715#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
717#[serde(rename_all = "lowercase")]
718pub enum TokenValidationStrategy {
719 #[default]
721 Jwt,
722 Introspection,
725 Both,
728}
729
730#[derive(Debug, Clone, Deserialize, Serialize)]
753pub struct BearerTokenConfig {
754 pub token: String,
756 #[serde(default)]
759 pub allow_tools: Vec<String>,
760 #[serde(default)]
762 pub deny_tools: Vec<String>,
763}
764
765#[derive(Debug, Deserialize, Serialize)]
767pub struct RoleConfig {
768 pub name: String,
770 #[serde(default)]
772 pub allow_tools: Vec<String>,
773 #[serde(default)]
775 pub deny_tools: Vec<String>,
776}
777
778#[derive(Debug, Deserialize, Serialize)]
780pub struct RoleMappingConfig {
781 pub claim: String,
783 pub mapping: HashMap<String, String>,
785}
786
787#[derive(Debug, Deserialize, Serialize)]
789pub struct AliasConfig {
790 pub from: String,
792 pub to: String,
794}
795
796#[derive(Debug, Deserialize, Serialize)]
798pub struct BackendCacheConfig {
799 #[serde(default)]
801 pub resource_ttl_seconds: u64,
802 #[serde(default)]
804 pub tool_ttl_seconds: u64,
805 #[serde(default = "default_max_cache_entries")]
807 pub max_entries: u64,
808}
809
810#[derive(Debug, Deserialize, Serialize, Clone)]
824pub struct CacheBackendConfig {
825 #[serde(default = "default_cache_backend")]
827 pub backend: String,
828 pub url: Option<String>,
830 #[serde(default = "default_cache_prefix")]
832 pub prefix: String,
833}
834
835impl Default for CacheBackendConfig {
836 fn default() -> Self {
837 Self {
838 backend: default_cache_backend(),
839 url: None,
840 prefix: default_cache_prefix(),
841 }
842 }
843}
844
845fn default_cache_backend() -> String {
846 "memory".to_string()
847}
848
849fn default_cache_prefix() -> String {
850 "mcp-proxy:".to_string()
851}
852
853#[derive(Debug, Default, Deserialize, Serialize)]
855pub struct PerformanceConfig {
856 #[serde(default)]
858 pub coalesce_requests: bool,
859}
860
861#[derive(Debug, Default, Deserialize, Serialize)]
863pub struct SecurityConfig {
864 pub max_argument_size: Option<usize>,
866 pub admin_token: Option<String>,
871}
872
873#[derive(Debug, Default, Deserialize, Serialize)]
875pub struct ObservabilityConfig {
876 #[serde(default)]
878 pub audit: bool,
879 #[serde(default = "default_log_level")]
881 pub log_level: String,
882 #[serde(default)]
884 pub json_logs: bool,
885 #[serde(default)]
887 pub metrics: MetricsConfig,
888 #[serde(default)]
890 pub tracing: TracingConfig,
891 #[serde(default)]
893 pub access_log: AccessLogConfig,
894}
895
896#[derive(Debug, Default, Deserialize, Serialize)]
898pub struct AccessLogConfig {
899 #[serde(default)]
901 pub enabled: bool,
902}
903
904#[derive(Debug, Default, Deserialize, Serialize)]
906pub struct MetricsConfig {
907 #[serde(default)]
909 pub enabled: bool,
910}
911
912#[derive(Debug, Default, Deserialize, Serialize)]
914pub struct TracingConfig {
915 #[serde(default)]
917 pub enabled: bool,
918 #[serde(default = "default_otlp_endpoint")]
920 pub endpoint: String,
921 #[serde(default = "default_service_name")]
923 pub service_name: String,
924}
925
926fn default_version() -> String {
929 "0.1.0".to_string()
930}
931
932fn default_separator() -> String {
933 "/".to_string()
934}
935
936fn default_host() -> String {
937 "127.0.0.1".to_string()
938}
939
940fn default_port() -> u16 {
941 8080
942}
943
944fn default_log_level() -> String {
945 "info".to_string()
946}
947
948fn default_failure_rate() -> f64 {
949 0.5
950}
951
952fn default_min_calls() -> usize {
953 5
954}
955
956fn default_wait_duration() -> u64 {
957 30
958}
959
960fn default_half_open_calls() -> usize {
961 3
962}
963
964fn default_rate_period() -> u64 {
965 1
966}
967
968fn default_max_retries() -> u32 {
969 3
970}
971
972fn default_initial_backoff_ms() -> u64 {
973 100
974}
975
976fn default_max_backoff_ms() -> u64 {
977 5000
978}
979
980fn default_min_retries_per_sec() -> u32 {
981 10
982}
983
984fn default_consecutive_errors() -> u32 {
985 5
986}
987
988fn default_interval_seconds() -> u64 {
989 10
990}
991
992fn default_base_ejection_seconds() -> u64 {
993 30
994}
995
996fn default_max_ejection_percent() -> u32 {
997 50
998}
999
1000fn default_hedge_delay_ms() -> u64 {
1001 200
1002}
1003
1004fn default_max_hedges() -> usize {
1005 1
1006}
1007
1008fn default_mirror_percent() -> u32 {
1009 100
1010}
1011
1012fn default_weight() -> u32 {
1013 100
1014}
1015
1016fn default_max_cache_entries() -> u64 {
1017 1000
1018}
1019
1020fn default_shutdown_timeout() -> u64 {
1021 30
1022}
1023
1024fn default_otlp_endpoint() -> String {
1025 "http://localhost:4317".to_string()
1026}
1027
1028fn default_service_name() -> String {
1029 "mcp-proxy".to_string()
1030}
1031
1032#[derive(Debug, Clone)]
1034pub struct BackendFilter {
1035 pub namespace: String,
1037 pub tool_filter: NameFilter,
1039 pub resource_filter: NameFilter,
1041 pub prompt_filter: NameFilter,
1043 pub hide_destructive: bool,
1045 pub read_only_only: bool,
1047}
1048
1049#[derive(Debug, Clone)]
1054pub enum CompiledPattern {
1055 Glob(String),
1057 Regex(regex::Regex),
1059}
1060
1061impl CompiledPattern {
1062 fn compile(pattern: &str) -> Result<Self> {
1065 if let Some(re_pat) = pattern.strip_prefix("re:") {
1066 let re = regex::Regex::new(re_pat)
1067 .with_context(|| format!("invalid regex in filter pattern: {pattern}"))?;
1068 Ok(Self::Regex(re))
1069 } else {
1070 Ok(Self::Glob(pattern.to_string()))
1071 }
1072 }
1073
1074 fn matches(&self, name: &str) -> bool {
1076 match self {
1077 Self::Glob(pat) => glob_match::glob_match(pat, name),
1078 Self::Regex(re) => re.is_match(name),
1079 }
1080 }
1081}
1082
1083#[derive(Debug, Clone)]
1091pub enum NameFilter {
1092 PassAll,
1094 AllowList(Vec<CompiledPattern>),
1096 DenyList(Vec<CompiledPattern>),
1098}
1099
1100impl NameFilter {
1101 pub fn allow_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1110 let compiled: Result<Vec<_>> = patterns
1111 .into_iter()
1112 .map(|p| CompiledPattern::compile(&p))
1113 .collect();
1114 Ok(Self::AllowList(compiled?))
1115 }
1116
1117 pub fn deny_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1126 let compiled: Result<Vec<_>> = patterns
1127 .into_iter()
1128 .map(|p| CompiledPattern::compile(&p))
1129 .collect();
1130 Ok(Self::DenyList(compiled?))
1131 }
1132
1133 pub fn allows(&self, name: &str) -> bool {
1165 match self {
1166 Self::PassAll => true,
1167 Self::AllowList(patterns) => patterns.iter().any(|p| p.matches(name)),
1168 Self::DenyList(patterns) => !patterns.iter().any(|p| p.matches(name)),
1169 }
1170 }
1171}
1172
1173impl BackendConfig {
1174 pub fn build_filter(&self, separator: &str) -> Result<Option<BackendFilter>> {
1181 if self.canary_of.is_some() || self.failover_for.is_some() {
1184 return Ok(Some(BackendFilter {
1185 namespace: format!("{}{}", self.name, separator),
1186 tool_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1187 resource_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1188 prompt_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1189 hide_destructive: false,
1190 read_only_only: false,
1191 }));
1192 }
1193
1194 let tool_filter = if !self.expose_tools.is_empty() {
1195 NameFilter::allow_list(self.expose_tools.iter().cloned())?
1196 } else if !self.hide_tools.is_empty() {
1197 NameFilter::deny_list(self.hide_tools.iter().cloned())?
1198 } else {
1199 NameFilter::PassAll
1200 };
1201
1202 let resource_filter = if !self.expose_resources.is_empty() {
1203 NameFilter::allow_list(self.expose_resources.iter().cloned())?
1204 } else if !self.hide_resources.is_empty() {
1205 NameFilter::deny_list(self.hide_resources.iter().cloned())?
1206 } else {
1207 NameFilter::PassAll
1208 };
1209
1210 let prompt_filter = if !self.expose_prompts.is_empty() {
1211 NameFilter::allow_list(self.expose_prompts.iter().cloned())?
1212 } else if !self.hide_prompts.is_empty() {
1213 NameFilter::deny_list(self.hide_prompts.iter().cloned())?
1214 } else {
1215 NameFilter::PassAll
1216 };
1217
1218 if matches!(tool_filter, NameFilter::PassAll)
1220 && matches!(resource_filter, NameFilter::PassAll)
1221 && matches!(prompt_filter, NameFilter::PassAll)
1222 && !self.hide_destructive
1223 && !self.read_only_only
1224 {
1225 return Ok(None);
1226 }
1227
1228 Ok(Some(BackendFilter {
1229 namespace: format!("{}{}", self.name, separator),
1230 tool_filter,
1231 resource_filter,
1232 prompt_filter,
1233 hide_destructive: self.hide_destructive,
1234 read_only_only: self.read_only_only,
1235 }))
1236 }
1237}
1238
1239impl ProxyConfig {
1240 pub fn load(path: &Path) -> Result<Self> {
1245 let content =
1246 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1247
1248 let mut config: Self = match path.extension().and_then(|e| e.to_str()) {
1249 #[cfg(feature = "yaml")]
1250 Some("yaml" | "yml") => serde_yaml::from_str(&content)
1251 .with_context(|| format!("parsing YAML {}", path.display()))?,
1252 #[cfg(not(feature = "yaml"))]
1253 Some("yaml" | "yml") => {
1254 anyhow::bail!(
1255 "YAML config requires the 'yaml' feature. Rebuild with: cargo install mcp-proxy --features yaml"
1256 );
1257 }
1258 _ => toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?,
1259 };
1260
1261 if let Some(ref mcp_json_path) = config.proxy.import_backends {
1263 let mcp_path = if std::path::Path::new(mcp_json_path).is_relative() {
1264 path.parent().unwrap_or(Path::new(".")).join(mcp_json_path)
1266 } else {
1267 std::path::PathBuf::from(mcp_json_path)
1268 };
1269
1270 let mcp_json = crate::mcp_json::McpJsonConfig::load(&mcp_path)
1271 .with_context(|| format!("importing backends from {}", mcp_path.display()))?;
1272
1273 let existing_names: HashSet<String> =
1274 config.backends.iter().map(|b| b.name.clone()).collect();
1275
1276 for backend in mcp_json.into_backends()? {
1277 if !existing_names.contains(&backend.name) {
1278 config.backends.push(backend);
1279 }
1280 }
1281 }
1282
1283 config.source_path = Some(path.to_path_buf());
1284 config.validate()?;
1285 Ok(config)
1286 }
1287
1288 pub fn from_mcp_json(path: &Path) -> Result<Self> {
1305 let mcp_json = crate::mcp_json::McpJsonConfig::load(path)?;
1306 let backends = mcp_json.into_backends()?;
1307
1308 let name = path
1310 .parent()
1311 .and_then(|p| p.file_name())
1312 .or_else(|| path.file_stem())
1313 .map(|s| s.to_string_lossy().into_owned())
1314 .unwrap_or_else(|| "mcp-proxy".to_string());
1315
1316 let config = Self {
1317 proxy: ProxySettings {
1318 name,
1319 version: default_version(),
1320 separator: default_separator(),
1321 listen: ListenConfig {
1322 host: default_host(),
1323 port: default_port(),
1324 },
1325 instructions: None,
1326 shutdown_timeout_seconds: default_shutdown_timeout(),
1327 hot_reload: false,
1328 import_backends: None,
1329 rate_limit: None,
1330 tool_discovery: false,
1331 tool_exposure: ToolExposure::default(),
1332 },
1333 backends,
1334 auth: None,
1335 performance: PerformanceConfig::default(),
1336 security: SecurityConfig::default(),
1337 cache: CacheBackendConfig::default(),
1338 observability: ObservabilityConfig::default(),
1339 composite_tools: Vec::new(),
1340 source_path: Some(path.to_path_buf()),
1341 };
1342
1343 config.validate()?;
1344 Ok(config)
1345 }
1346
1347 pub fn parse(toml: &str) -> Result<Self> {
1369 let config: Self = toml::from_str(toml).context("parsing config")?;
1370 config.validate()?;
1371 Ok(config)
1372 }
1373
1374 #[cfg(feature = "yaml")]
1396 pub fn parse_yaml(yaml: &str) -> Result<Self> {
1397 let config: Self = serde_yaml::from_str(yaml).context("parsing YAML config")?;
1398 config.validate()?;
1399 Ok(config)
1400 }
1401
1402 fn validate(&self) -> Result<()> {
1403 if self.backends.is_empty() {
1404 anyhow::bail!("at least one backend is required");
1405 }
1406
1407 match self.cache.backend.as_str() {
1409 "memory" => {}
1410 "redis" => {
1411 if self.cache.url.is_none() {
1412 anyhow::bail!(
1413 "cache.url is required when cache.backend = \"{}\"",
1414 self.cache.backend
1415 );
1416 }
1417 #[cfg(not(feature = "redis-cache"))]
1418 anyhow::bail!(
1419 "cache.backend = \"redis\" requires the 'redis-cache' feature. \
1420 Rebuild with: cargo install mcp-proxy --features redis-cache"
1421 );
1422 }
1423 "sqlite" => {
1424 if self.cache.url.is_none() {
1425 anyhow::bail!(
1426 "cache.url is required when cache.backend = \"{}\"",
1427 self.cache.backend
1428 );
1429 }
1430 #[cfg(not(feature = "sqlite-cache"))]
1431 anyhow::bail!(
1432 "cache.backend = \"sqlite\" requires the 'sqlite-cache' feature. \
1433 Rebuild with: cargo install mcp-proxy --features sqlite-cache"
1434 );
1435 }
1436 other => {
1437 anyhow::bail!(
1438 "unknown cache backend \"{}\", expected \"memory\", \"redis\", or \"sqlite\"",
1439 other
1440 );
1441 }
1442 }
1443
1444 if let Some(rl) = &self.proxy.rate_limit {
1446 if rl.requests == 0 {
1447 anyhow::bail!("proxy.rate_limit.requests must be > 0");
1448 }
1449 if rl.period_seconds == 0 {
1450 anyhow::bail!("proxy.rate_limit.period_seconds must be > 0");
1451 }
1452 }
1453
1454 if let Some(AuthConfig::Bearer {
1456 tokens,
1457 scoped_tokens,
1458 }) = &self.auth
1459 {
1460 if tokens.is_empty() && scoped_tokens.is_empty() {
1461 anyhow::bail!(
1462 "bearer auth requires at least one token in 'tokens' or 'scoped_tokens'"
1463 );
1464 }
1465 let mut seen_tokens = HashSet::new();
1467 for t in tokens {
1468 if !seen_tokens.insert(t.as_str()) {
1469 anyhow::bail!("duplicate bearer token in 'tokens'");
1470 }
1471 }
1472 for st in scoped_tokens {
1473 if !seen_tokens.insert(st.token.as_str()) {
1474 anyhow::bail!(
1475 "duplicate bearer token (appears in both 'tokens' and 'scoped_tokens' or duplicated within 'scoped_tokens')"
1476 );
1477 }
1478 if !st.allow_tools.is_empty() && !st.deny_tools.is_empty() {
1479 anyhow::bail!(
1480 "scoped_tokens: cannot specify both allow_tools and deny_tools for the same token"
1481 );
1482 }
1483 }
1484 }
1485
1486 if let Some(AuthConfig::OAuth {
1488 token_validation,
1489 client_id,
1490 client_secret,
1491 ..
1492 }) = &self.auth
1493 && matches!(
1494 token_validation,
1495 TokenValidationStrategy::Introspection | TokenValidationStrategy::Both
1496 )
1497 && (client_id.is_none() || client_secret.is_none())
1498 {
1499 anyhow::bail!("OAuth introspection requires both 'client_id' and 'client_secret'");
1500 }
1501
1502 let mut seen_names = HashSet::new();
1504 for backend in &self.backends {
1505 if !seen_names.insert(&backend.name) {
1506 anyhow::bail!("duplicate backend name '{}'", backend.name);
1507 }
1508 }
1509
1510 for backend in &self.backends {
1511 match backend.transport {
1512 TransportType::Stdio => {
1513 if backend.command.is_none() {
1514 anyhow::bail!(
1515 "backend '{}': stdio transport requires 'command'",
1516 backend.name
1517 );
1518 }
1519 }
1520 TransportType::Http => {
1521 if backend.url.is_none() {
1522 anyhow::bail!("backend '{}': http transport requires 'url'", backend.name);
1523 }
1524 }
1525 TransportType::Websocket => {
1526 if backend.url.is_none() {
1527 anyhow::bail!(
1528 "backend '{}': websocket transport requires 'url'",
1529 backend.name
1530 );
1531 }
1532 }
1533 }
1534
1535 if let Some(cb) = &backend.circuit_breaker
1536 && (cb.failure_rate_threshold <= 0.0 || cb.failure_rate_threshold > 1.0)
1537 {
1538 anyhow::bail!(
1539 "backend '{}': circuit_breaker.failure_rate_threshold must be in (0.0, 1.0]",
1540 backend.name
1541 );
1542 }
1543
1544 if let Some(rl) = &backend.rate_limit
1545 && rl.requests == 0
1546 {
1547 anyhow::bail!(
1548 "backend '{}': rate_limit.requests must be > 0",
1549 backend.name
1550 );
1551 }
1552
1553 if let Some(cc) = &backend.concurrency
1554 && cc.max_concurrent == 0
1555 {
1556 anyhow::bail!(
1557 "backend '{}': concurrency.max_concurrent must be > 0",
1558 backend.name
1559 );
1560 }
1561
1562 if !backend.expose_tools.is_empty() && !backend.hide_tools.is_empty() {
1563 anyhow::bail!(
1564 "backend '{}': cannot specify both expose_tools and hide_tools",
1565 backend.name
1566 );
1567 }
1568 if !backend.expose_resources.is_empty() && !backend.hide_resources.is_empty() {
1569 anyhow::bail!(
1570 "backend '{}': cannot specify both expose_resources and hide_resources",
1571 backend.name
1572 );
1573 }
1574 if !backend.expose_prompts.is_empty() && !backend.hide_prompts.is_empty() {
1575 anyhow::bail!(
1576 "backend '{}': cannot specify both expose_prompts and hide_prompts",
1577 backend.name
1578 );
1579 }
1580 }
1581
1582 let backend_names: HashSet<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
1584 for backend in &self.backends {
1585 if let Some(ref source) = backend.mirror_of {
1586 if !backend_names.contains(source.as_str()) {
1587 anyhow::bail!(
1588 "backend '{}': mirror_of references unknown backend '{}'",
1589 backend.name,
1590 source
1591 );
1592 }
1593 if source == &backend.name {
1594 anyhow::bail!(
1595 "backend '{}': mirror_of cannot reference itself",
1596 backend.name
1597 );
1598 }
1599 }
1600 }
1601
1602 for backend in &self.backends {
1604 if let Some(ref primary) = backend.failover_for {
1605 if !backend_names.contains(primary.as_str()) {
1606 anyhow::bail!(
1607 "backend '{}': failover_for references unknown backend '{}'",
1608 backend.name,
1609 primary
1610 );
1611 }
1612 if primary == &backend.name {
1613 anyhow::bail!(
1614 "backend '{}': failover_for cannot reference itself",
1615 backend.name
1616 );
1617 }
1618 }
1619 }
1620
1621 {
1623 let mut composite_names = HashSet::new();
1624 for ct in &self.composite_tools {
1625 if ct.name.is_empty() {
1626 anyhow::bail!("composite_tools: name must not be empty");
1627 }
1628 if ct.tools.is_empty() {
1629 anyhow::bail!(
1630 "composite_tools '{}': must reference at least one tool",
1631 ct.name
1632 );
1633 }
1634 if !composite_names.insert(&ct.name) {
1635 anyhow::bail!("duplicate composite_tools name '{}'", ct.name);
1636 }
1637 }
1638 }
1639
1640 for backend in &self.backends {
1642 if let Some(ref primary) = backend.canary_of {
1643 if !backend_names.contains(primary.as_str()) {
1644 anyhow::bail!(
1645 "backend '{}': canary_of references unknown backend '{}'",
1646 backend.name,
1647 primary
1648 );
1649 }
1650 if primary == &backend.name {
1651 anyhow::bail!(
1652 "backend '{}': canary_of cannot reference itself",
1653 backend.name
1654 );
1655 }
1656 if backend.weight == 0 {
1657 anyhow::bail!("backend '{}': weight must be > 0", backend.name);
1658 }
1659 }
1660 }
1661
1662 #[cfg(not(feature = "discovery"))]
1664 if self.proxy.tool_exposure == ToolExposure::Search {
1665 anyhow::bail!(
1666 "tool_exposure = \"search\" requires the 'discovery' feature. \
1667 Rebuild with: cargo install mcp-proxy --features discovery"
1668 );
1669 }
1670
1671 for backend in &self.backends {
1673 let mut seen_tools = HashSet::new();
1674 for po in &backend.param_overrides {
1675 if po.tool.is_empty() {
1676 anyhow::bail!(
1677 "backend '{}': param_overrides.tool must not be empty",
1678 backend.name
1679 );
1680 }
1681 if !seen_tools.insert(&po.tool) {
1682 anyhow::bail!(
1683 "backend '{}': duplicate param_overrides for tool '{}'",
1684 backend.name,
1685 po.tool
1686 );
1687 }
1688 for hidden in &po.hide {
1691 if po.rename.contains_key(hidden) {
1692 anyhow::bail!(
1693 "backend '{}': param_overrides for tool '{}': \
1694 parameter '{}' cannot be both hidden and renamed",
1695 backend.name,
1696 po.tool,
1697 hidden
1698 );
1699 }
1700 }
1701 let mut rename_targets = HashSet::new();
1703 for target in po.rename.values() {
1704 if !rename_targets.insert(target) {
1705 anyhow::bail!(
1706 "backend '{}': param_overrides for tool '{}': \
1707 duplicate rename target '{}'",
1708 backend.name,
1709 po.tool,
1710 target
1711 );
1712 }
1713 }
1714 }
1715 }
1716
1717 Ok(())
1718 }
1719
1720 pub fn resolve_env_vars(&mut self) {
1723 for backend in &mut self.backends {
1724 for value in backend.env.values_mut() {
1725 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1726 && let Ok(env_val) = std::env::var(var_name)
1727 {
1728 *value = env_val;
1729 }
1730 }
1731 if let Some(ref mut token) = backend.bearer_token
1732 && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1733 && let Ok(env_val) = std::env::var(var_name)
1734 {
1735 *token = env_val;
1736 }
1737 }
1738
1739 if let Some(AuthConfig::Bearer {
1741 tokens,
1742 scoped_tokens,
1743 }) = &mut self.auth
1744 {
1745 for token in tokens.iter_mut() {
1746 if let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1747 && let Ok(env_val) = std::env::var(var_name)
1748 {
1749 *token = env_val;
1750 }
1751 }
1752 for st in scoped_tokens.iter_mut() {
1753 if let Some(var_name) = st
1754 .token
1755 .strip_prefix("${")
1756 .and_then(|s| s.strip_suffix('}'))
1757 && let Ok(env_val) = std::env::var(var_name)
1758 {
1759 st.token = env_val;
1760 }
1761 }
1762 }
1763
1764 if let Some(ref mut token) = self.security.admin_token
1766 && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1767 && let Ok(env_val) = std::env::var(var_name)
1768 {
1769 *token = env_val;
1770 }
1771
1772 if let Some(AuthConfig::OAuth { client_secret, .. }) = &mut self.auth
1774 && let Some(secret) = client_secret
1775 && let Some(var_name) = secret.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1776 && let Ok(env_val) = std::env::var(var_name)
1777 {
1778 *secret = env_val;
1779 }
1780 }
1781
1782 pub fn check_env_vars(&self) -> Vec<String> {
1809 fn is_unset_env_ref(value: &str) -> Option<&str> {
1810 let var_name = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))?;
1811 if std::env::var(var_name).is_err() {
1812 Some(var_name)
1813 } else {
1814 None
1815 }
1816 }
1817
1818 let mut warnings = Vec::new();
1819
1820 for backend in &self.backends {
1821 if let Some(ref token) = backend.bearer_token
1823 && let Some(var) = is_unset_env_ref(token)
1824 {
1825 warnings.push(format!(
1826 "backend '{}': bearer_token references unset env var '{}'",
1827 backend.name, var
1828 ));
1829 }
1830 for (key, value) in &backend.env {
1832 if let Some(var) = is_unset_env_ref(value) {
1833 warnings.push(format!(
1834 "backend '{}': env.{} references unset env var '{}'",
1835 backend.name, key, var
1836 ));
1837 }
1838 }
1839 }
1840
1841 match &self.auth {
1842 Some(AuthConfig::Bearer {
1843 tokens,
1844 scoped_tokens,
1845 }) => {
1846 for (i, token) in tokens.iter().enumerate() {
1847 if let Some(var) = is_unset_env_ref(token) {
1848 warnings.push(format!(
1849 "auth.bearer: tokens[{}] references unset env var '{}'",
1850 i, var
1851 ));
1852 }
1853 }
1854 for (i, st) in scoped_tokens.iter().enumerate() {
1855 if let Some(var) = is_unset_env_ref(&st.token) {
1856 warnings.push(format!(
1857 "auth.bearer: scoped_tokens[{}] references unset env var '{}'",
1858 i, var
1859 ));
1860 }
1861 }
1862 }
1863 Some(AuthConfig::OAuth {
1864 client_secret: Some(secret),
1865 ..
1866 }) => {
1867 if let Some(var) = is_unset_env_ref(secret) {
1868 warnings.push(format!(
1869 "auth.oauth: client_secret references unset env var '{}'",
1870 var
1871 ));
1872 }
1873 }
1874 _ => {}
1875 }
1876
1877 warnings
1878 }
1879}
1880
1881#[cfg(test)]
1882mod tests {
1883 use super::*;
1884
1885 fn minimal_config() -> &'static str {
1886 r#"
1887 [proxy]
1888 name = "test"
1889 [proxy.listen]
1890
1891 [[backends]]
1892 name = "echo"
1893 transport = "stdio"
1894 command = "echo"
1895 "#
1896 }
1897
1898 #[test]
1899 fn test_parse_minimal_config() {
1900 let config = ProxyConfig::parse(minimal_config()).unwrap();
1901 assert_eq!(config.proxy.name, "test");
1902 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);
1909 assert_eq!(config.backends[0].name, "echo");
1910 assert!(config.auth.is_none());
1911 assert!(!config.observability.audit);
1912 assert!(!config.observability.metrics.enabled);
1913 }
1914
1915 #[test]
1916 fn test_parse_full_config() {
1917 let toml = r#"
1918 [proxy]
1919 name = "full-gw"
1920 version = "2.0.0"
1921 separator = "."
1922 shutdown_timeout_seconds = 60
1923 hot_reload = true
1924 instructions = "A test proxy"
1925 [proxy.listen]
1926 host = "0.0.0.0"
1927 port = 9090
1928
1929 [[backends]]
1930 name = "files"
1931 transport = "stdio"
1932 command = "file-server"
1933 args = ["--root", "/tmp"]
1934 expose_tools = ["read_file"]
1935
1936 [backends.env]
1937 LOG_LEVEL = "debug"
1938
1939 [backends.timeout]
1940 seconds = 30
1941
1942 [backends.concurrency]
1943 max_concurrent = 5
1944
1945 [backends.rate_limit]
1946 requests = 100
1947 period_seconds = 10
1948
1949 [backends.circuit_breaker]
1950 failure_rate_threshold = 0.5
1951 minimum_calls = 10
1952 wait_duration_seconds = 60
1953 permitted_calls_in_half_open = 2
1954
1955 [backends.cache]
1956 resource_ttl_seconds = 300
1957 tool_ttl_seconds = 60
1958 max_entries = 500
1959
1960 [[backends.aliases]]
1961 from = "read_file"
1962 to = "read"
1963
1964 [[backends]]
1965 name = "remote"
1966 transport = "http"
1967 url = "http://localhost:3000"
1968
1969 [observability]
1970 audit = true
1971 log_level = "debug"
1972 json_logs = true
1973
1974 [observability.metrics]
1975 enabled = true
1976
1977 [observability.tracing]
1978 enabled = true
1979 endpoint = "http://jaeger:4317"
1980 service_name = "test-gw"
1981
1982 [performance]
1983 coalesce_requests = true
1984
1985 [security]
1986 max_argument_size = 1048576
1987 "#;
1988
1989 let config = ProxyConfig::parse(toml).unwrap();
1990 assert_eq!(config.proxy.name, "full-gw");
1991 assert_eq!(config.proxy.version, "2.0.0");
1992 assert_eq!(config.proxy.separator, ".");
1993 assert_eq!(config.proxy.shutdown_timeout_seconds, 60);
1994 assert!(config.proxy.hot_reload);
1995 assert_eq!(config.proxy.instructions.as_deref(), Some("A test proxy"));
1996 assert_eq!(config.proxy.listen.host, "0.0.0.0");
1997 assert_eq!(config.proxy.listen.port, 9090);
1998
1999 assert_eq!(config.backends.len(), 2);
2000
2001 let files = &config.backends[0];
2002 assert_eq!(files.command.as_deref(), Some("file-server"));
2003 assert_eq!(files.args, vec!["--root", "/tmp"]);
2004 assert_eq!(files.expose_tools, vec!["read_file"]);
2005 assert_eq!(files.env.get("LOG_LEVEL").unwrap(), "debug");
2006 assert_eq!(files.timeout.as_ref().unwrap().seconds, 30);
2007 assert_eq!(files.concurrency.as_ref().unwrap().max_concurrent, 5);
2008 assert_eq!(files.rate_limit.as_ref().unwrap().requests, 100);
2009 assert_eq!(files.cache.as_ref().unwrap().resource_ttl_seconds, 300);
2010 assert_eq!(files.cache.as_ref().unwrap().tool_ttl_seconds, 60);
2011 assert_eq!(files.cache.as_ref().unwrap().max_entries, 500);
2012 assert_eq!(files.aliases.len(), 1);
2013 assert_eq!(files.aliases[0].from, "read_file");
2014 assert_eq!(files.aliases[0].to, "read");
2015
2016 let cb = files.circuit_breaker.as_ref().unwrap();
2017 assert_eq!(cb.failure_rate_threshold, 0.5);
2018 assert_eq!(cb.minimum_calls, 10);
2019 assert_eq!(cb.wait_duration_seconds, 60);
2020 assert_eq!(cb.permitted_calls_in_half_open, 2);
2021
2022 let remote = &config.backends[1];
2023 assert_eq!(remote.url.as_deref(), Some("http://localhost:3000"));
2024
2025 assert!(config.observability.audit);
2026 assert_eq!(config.observability.log_level, "debug");
2027 assert!(config.observability.json_logs);
2028 assert!(config.observability.metrics.enabled);
2029 assert!(config.observability.tracing.enabled);
2030 assert_eq!(config.observability.tracing.endpoint, "http://jaeger:4317");
2031
2032 assert!(config.performance.coalesce_requests);
2033 assert_eq!(config.security.max_argument_size, Some(1048576));
2034 }
2035
2036 #[test]
2037 fn test_parse_bearer_auth() {
2038 let toml = r#"
2039 [proxy]
2040 name = "auth-gw"
2041 [proxy.listen]
2042
2043 [[backends]]
2044 name = "echo"
2045 transport = "stdio"
2046 command = "echo"
2047
2048 [auth]
2049 type = "bearer"
2050 tokens = ["token-1", "token-2"]
2051 "#;
2052
2053 let config = ProxyConfig::parse(toml).unwrap();
2054 match &config.auth {
2055 Some(AuthConfig::Bearer { tokens, .. }) => {
2056 assert_eq!(tokens, &["token-1", "token-2"]);
2057 }
2058 other => panic!("expected Bearer auth, got: {:?}", other),
2059 }
2060 }
2061
2062 #[test]
2063 fn test_parse_jwt_auth_with_rbac() {
2064 let toml = r#"
2065 [proxy]
2066 name = "jwt-gw"
2067 [proxy.listen]
2068
2069 [[backends]]
2070 name = "echo"
2071 transport = "stdio"
2072 command = "echo"
2073
2074 [auth]
2075 type = "jwt"
2076 issuer = "https://auth.example.com"
2077 audience = "mcp-proxy"
2078 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2079
2080 [[auth.roles]]
2081 name = "reader"
2082 allow_tools = ["echo/read"]
2083
2084 [[auth.roles]]
2085 name = "admin"
2086
2087 [auth.role_mapping]
2088 claim = "scope"
2089 mapping = { "mcp:read" = "reader", "mcp:admin" = "admin" }
2090 "#;
2091
2092 let config = ProxyConfig::parse(toml).unwrap();
2093 match &config.auth {
2094 Some(AuthConfig::Jwt {
2095 issuer,
2096 audience,
2097 jwks_uri,
2098 roles,
2099 role_mapping,
2100 }) => {
2101 assert_eq!(issuer, "https://auth.example.com");
2102 assert_eq!(audience, "mcp-proxy");
2103 assert_eq!(jwks_uri, "https://auth.example.com/.well-known/jwks.json");
2104 assert_eq!(roles.len(), 2);
2105 assert_eq!(roles[0].name, "reader");
2106 assert_eq!(roles[0].allow_tools, vec!["echo/read"]);
2107 let mapping = role_mapping.as_ref().unwrap();
2108 assert_eq!(mapping.claim, "scope");
2109 assert_eq!(mapping.mapping.get("mcp:read").unwrap(), "reader");
2110 }
2111 other => panic!("expected Jwt auth, got: {:?}", other),
2112 }
2113 }
2114
2115 #[test]
2120 fn test_reject_no_backends() {
2121 let toml = r#"
2122 [proxy]
2123 name = "empty"
2124 [proxy.listen]
2125 "#;
2126
2127 let err = ProxyConfig::parse(toml).unwrap_err();
2128 assert!(
2129 format!("{err}").contains("at least one backend"),
2130 "unexpected error: {err}"
2131 );
2132 }
2133
2134 #[test]
2135 fn test_reject_stdio_without_command() {
2136 let toml = r#"
2137 [proxy]
2138 name = "bad"
2139 [proxy.listen]
2140
2141 [[backends]]
2142 name = "broken"
2143 transport = "stdio"
2144 "#;
2145
2146 let err = ProxyConfig::parse(toml).unwrap_err();
2147 assert!(
2148 format!("{err}").contains("stdio transport requires 'command'"),
2149 "unexpected error: {err}"
2150 );
2151 }
2152
2153 #[test]
2154 fn test_reject_http_without_url() {
2155 let toml = r#"
2156 [proxy]
2157 name = "bad"
2158 [proxy.listen]
2159
2160 [[backends]]
2161 name = "broken"
2162 transport = "http"
2163 "#;
2164
2165 let err = ProxyConfig::parse(toml).unwrap_err();
2166 assert!(
2167 format!("{err}").contains("http transport requires 'url'"),
2168 "unexpected error: {err}"
2169 );
2170 }
2171
2172 #[test]
2173 fn test_reject_invalid_circuit_breaker_threshold() {
2174 let toml = r#"
2175 [proxy]
2176 name = "bad"
2177 [proxy.listen]
2178
2179 [[backends]]
2180 name = "svc"
2181 transport = "stdio"
2182 command = "echo"
2183
2184 [backends.circuit_breaker]
2185 failure_rate_threshold = 1.5
2186 "#;
2187
2188 let err = ProxyConfig::parse(toml).unwrap_err();
2189 assert!(
2190 format!("{err}").contains("failure_rate_threshold must be in (0.0, 1.0]"),
2191 "unexpected error: {err}"
2192 );
2193 }
2194
2195 #[test]
2196 fn test_reject_zero_rate_limit() {
2197 let toml = r#"
2198 [proxy]
2199 name = "bad"
2200 [proxy.listen]
2201
2202 [[backends]]
2203 name = "svc"
2204 transport = "stdio"
2205 command = "echo"
2206
2207 [backends.rate_limit]
2208 requests = 0
2209 "#;
2210
2211 let err = ProxyConfig::parse(toml).unwrap_err();
2212 assert!(
2213 format!("{err}").contains("rate_limit.requests must be > 0"),
2214 "unexpected error: {err}"
2215 );
2216 }
2217
2218 #[test]
2219 fn test_reject_zero_concurrency() {
2220 let toml = r#"
2221 [proxy]
2222 name = "bad"
2223 [proxy.listen]
2224
2225 [[backends]]
2226 name = "svc"
2227 transport = "stdio"
2228 command = "echo"
2229
2230 [backends.concurrency]
2231 max_concurrent = 0
2232 "#;
2233
2234 let err = ProxyConfig::parse(toml).unwrap_err();
2235 assert!(
2236 format!("{err}").contains("concurrency.max_concurrent must be > 0"),
2237 "unexpected error: {err}"
2238 );
2239 }
2240
2241 #[test]
2242 fn test_reject_expose_and_hide_tools() {
2243 let toml = r#"
2244 [proxy]
2245 name = "bad"
2246 [proxy.listen]
2247
2248 [[backends]]
2249 name = "svc"
2250 transport = "stdio"
2251 command = "echo"
2252 expose_tools = ["read"]
2253 hide_tools = ["write"]
2254 "#;
2255
2256 let err = ProxyConfig::parse(toml).unwrap_err();
2257 assert!(
2258 format!("{err}").contains("cannot specify both expose_tools and hide_tools"),
2259 "unexpected error: {err}"
2260 );
2261 }
2262
2263 #[test]
2264 fn test_reject_expose_and_hide_resources() {
2265 let toml = r#"
2266 [proxy]
2267 name = "bad"
2268 [proxy.listen]
2269
2270 [[backends]]
2271 name = "svc"
2272 transport = "stdio"
2273 command = "echo"
2274 expose_resources = ["file:///a"]
2275 hide_resources = ["file:///b"]
2276 "#;
2277
2278 let err = ProxyConfig::parse(toml).unwrap_err();
2279 assert!(
2280 format!("{err}").contains("cannot specify both expose_resources and hide_resources"),
2281 "unexpected error: {err}"
2282 );
2283 }
2284
2285 #[test]
2286 fn test_reject_expose_and_hide_prompts() {
2287 let toml = r#"
2288 [proxy]
2289 name = "bad"
2290 [proxy.listen]
2291
2292 [[backends]]
2293 name = "svc"
2294 transport = "stdio"
2295 command = "echo"
2296 expose_prompts = ["help"]
2297 hide_prompts = ["admin"]
2298 "#;
2299
2300 let err = ProxyConfig::parse(toml).unwrap_err();
2301 assert!(
2302 format!("{err}").contains("cannot specify both expose_prompts and hide_prompts"),
2303 "unexpected error: {err}"
2304 );
2305 }
2306
2307 #[test]
2312 fn test_resolve_env_vars() {
2313 unsafe { std::env::set_var("MCP_GW_TEST_TOKEN", "secret-123") };
2315
2316 let toml = r#"
2317 [proxy]
2318 name = "env-test"
2319 [proxy.listen]
2320
2321 [[backends]]
2322 name = "svc"
2323 transport = "stdio"
2324 command = "echo"
2325
2326 [backends.env]
2327 API_TOKEN = "${MCP_GW_TEST_TOKEN}"
2328 STATIC_VAL = "unchanged"
2329 "#;
2330
2331 let mut config = ProxyConfig::parse(toml).unwrap();
2332 config.resolve_env_vars();
2333
2334 assert_eq!(
2335 config.backends[0].env.get("API_TOKEN").unwrap(),
2336 "secret-123"
2337 );
2338 assert_eq!(
2339 config.backends[0].env.get("STATIC_VAL").unwrap(),
2340 "unchanged"
2341 );
2342
2343 unsafe { std::env::remove_var("MCP_GW_TEST_TOKEN") };
2345 }
2346
2347 #[test]
2348 fn test_parse_bearer_token_and_forward_auth() {
2349 let toml = r#"
2350 [proxy]
2351 name = "token-gw"
2352 [proxy.listen]
2353
2354 [[backends]]
2355 name = "github"
2356 transport = "http"
2357 url = "http://localhost:3000"
2358 bearer_token = "ghp_abc123"
2359 forward_auth = true
2360
2361 [[backends]]
2362 name = "db"
2363 transport = "http"
2364 url = "http://localhost:5432"
2365 "#;
2366
2367 let config = ProxyConfig::parse(toml).unwrap();
2368 assert_eq!(
2369 config.backends[0].bearer_token.as_deref(),
2370 Some("ghp_abc123")
2371 );
2372 assert!(config.backends[0].forward_auth);
2373 assert!(config.backends[1].bearer_token.is_none());
2374 assert!(!config.backends[1].forward_auth);
2375 }
2376
2377 #[test]
2378 fn test_resolve_bearer_token_env_var() {
2379 unsafe { std::env::set_var("MCP_GW_TEST_BEARER", "resolved-token") };
2380
2381 let toml = r#"
2382 [proxy]
2383 name = "env-token"
2384 [proxy.listen]
2385
2386 [[backends]]
2387 name = "api"
2388 transport = "http"
2389 url = "http://localhost:3000"
2390 bearer_token = "${MCP_GW_TEST_BEARER}"
2391 "#;
2392
2393 let mut config = ProxyConfig::parse(toml).unwrap();
2394 config.resolve_env_vars();
2395
2396 assert_eq!(
2397 config.backends[0].bearer_token.as_deref(),
2398 Some("resolved-token")
2399 );
2400
2401 unsafe { std::env::remove_var("MCP_GW_TEST_BEARER") };
2402 }
2403
2404 #[test]
2405 fn test_parse_outlier_detection() {
2406 let toml = r#"
2407 [proxy]
2408 name = "od-gw"
2409 [proxy.listen]
2410
2411 [[backends]]
2412 name = "flaky"
2413 transport = "http"
2414 url = "http://localhost:8080"
2415
2416 [backends.outlier_detection]
2417 consecutive_errors = 3
2418 interval_seconds = 5
2419 base_ejection_seconds = 60
2420 max_ejection_percent = 25
2421 "#;
2422
2423 let config = ProxyConfig::parse(toml).unwrap();
2424 let od = config.backends[0]
2425 .outlier_detection
2426 .as_ref()
2427 .expect("should have outlier_detection");
2428 assert_eq!(od.consecutive_errors, 3);
2429 assert_eq!(od.interval_seconds, 5);
2430 assert_eq!(od.base_ejection_seconds, 60);
2431 assert_eq!(od.max_ejection_percent, 25);
2432 }
2433
2434 #[test]
2435 fn test_parse_outlier_detection_defaults() {
2436 let toml = r#"
2437 [proxy]
2438 name = "od-gw"
2439 [proxy.listen]
2440
2441 [[backends]]
2442 name = "flaky"
2443 transport = "http"
2444 url = "http://localhost:8080"
2445
2446 [backends.outlier_detection]
2447 "#;
2448
2449 let config = ProxyConfig::parse(toml).unwrap();
2450 let od = config.backends[0]
2451 .outlier_detection
2452 .as_ref()
2453 .expect("should have outlier_detection");
2454 assert_eq!(od.consecutive_errors, 5);
2455 assert_eq!(od.interval_seconds, 10);
2456 assert_eq!(od.base_ejection_seconds, 30);
2457 assert_eq!(od.max_ejection_percent, 50);
2458 }
2459
2460 #[test]
2461 fn test_parse_mirror_config() {
2462 let toml = r#"
2463 [proxy]
2464 name = "mirror-gw"
2465 [proxy.listen]
2466
2467 [[backends]]
2468 name = "api"
2469 transport = "http"
2470 url = "http://localhost:8080"
2471
2472 [[backends]]
2473 name = "api-v2"
2474 transport = "http"
2475 url = "http://localhost:8081"
2476 mirror_of = "api"
2477 mirror_percent = 10
2478 "#;
2479
2480 let config = ProxyConfig::parse(toml).unwrap();
2481 assert!(config.backends[0].mirror_of.is_none());
2482 assert_eq!(config.backends[1].mirror_of.as_deref(), Some("api"));
2483 assert_eq!(config.backends[1].mirror_percent, 10);
2484 }
2485
2486 #[test]
2487 fn test_mirror_percent_defaults_to_100() {
2488 let toml = r#"
2489 [proxy]
2490 name = "mirror-gw"
2491 [proxy.listen]
2492
2493 [[backends]]
2494 name = "api"
2495 transport = "http"
2496 url = "http://localhost:8080"
2497
2498 [[backends]]
2499 name = "api-v2"
2500 transport = "http"
2501 url = "http://localhost:8081"
2502 mirror_of = "api"
2503 "#;
2504
2505 let config = ProxyConfig::parse(toml).unwrap();
2506 assert_eq!(config.backends[1].mirror_percent, 100);
2507 }
2508
2509 #[test]
2510 fn test_reject_mirror_unknown_backend() {
2511 let toml = r#"
2512 [proxy]
2513 name = "bad"
2514 [proxy.listen]
2515
2516 [[backends]]
2517 name = "api-v2"
2518 transport = "http"
2519 url = "http://localhost:8081"
2520 mirror_of = "nonexistent"
2521 "#;
2522
2523 let err = ProxyConfig::parse(toml).unwrap_err();
2524 assert!(
2525 format!("{err}").contains("mirror_of references unknown backend"),
2526 "unexpected error: {err}"
2527 );
2528 }
2529
2530 #[test]
2531 fn test_reject_mirror_self() {
2532 let toml = r#"
2533 [proxy]
2534 name = "bad"
2535 [proxy.listen]
2536
2537 [[backends]]
2538 name = "api"
2539 transport = "http"
2540 url = "http://localhost:8080"
2541 mirror_of = "api"
2542 "#;
2543
2544 let err = ProxyConfig::parse(toml).unwrap_err();
2545 assert!(
2546 format!("{err}").contains("mirror_of cannot reference itself"),
2547 "unexpected error: {err}"
2548 );
2549 }
2550
2551 #[test]
2552 fn test_parse_hedging_config() {
2553 let toml = r#"
2554 [proxy]
2555 name = "hedge-gw"
2556 [proxy.listen]
2557
2558 [[backends]]
2559 name = "api"
2560 transport = "http"
2561 url = "http://localhost:8080"
2562
2563 [backends.hedging]
2564 delay_ms = 150
2565 max_hedges = 2
2566 "#;
2567
2568 let config = ProxyConfig::parse(toml).unwrap();
2569 let hedge = config.backends[0]
2570 .hedging
2571 .as_ref()
2572 .expect("should have hedging");
2573 assert_eq!(hedge.delay_ms, 150);
2574 assert_eq!(hedge.max_hedges, 2);
2575 }
2576
2577 #[test]
2578 fn test_parse_hedging_defaults() {
2579 let toml = r#"
2580 [proxy]
2581 name = "hedge-gw"
2582 [proxy.listen]
2583
2584 [[backends]]
2585 name = "api"
2586 transport = "http"
2587 url = "http://localhost:8080"
2588
2589 [backends.hedging]
2590 "#;
2591
2592 let config = ProxyConfig::parse(toml).unwrap();
2593 let hedge = config.backends[0]
2594 .hedging
2595 .as_ref()
2596 .expect("should have hedging");
2597 assert_eq!(hedge.delay_ms, 200);
2598 assert_eq!(hedge.max_hedges, 1);
2599 }
2600
2601 #[test]
2606 fn test_build_filter_allowlist() {
2607 let toml = r#"
2608 [proxy]
2609 name = "filter"
2610 [proxy.listen]
2611
2612 [[backends]]
2613 name = "svc"
2614 transport = "stdio"
2615 command = "echo"
2616 expose_tools = ["read", "list"]
2617 "#;
2618
2619 let config = ProxyConfig::parse(toml).unwrap();
2620 let filter = config.backends[0]
2621 .build_filter(&config.proxy.separator)
2622 .unwrap()
2623 .expect("should have filter");
2624 assert_eq!(filter.namespace, "svc/");
2625 assert!(filter.tool_filter.allows("read"));
2626 assert!(filter.tool_filter.allows("list"));
2627 assert!(!filter.tool_filter.allows("delete"));
2628 }
2629
2630 #[test]
2631 fn test_build_filter_denylist() {
2632 let toml = r#"
2633 [proxy]
2634 name = "filter"
2635 [proxy.listen]
2636
2637 [[backends]]
2638 name = "svc"
2639 transport = "stdio"
2640 command = "echo"
2641 hide_tools = ["delete", "write"]
2642 "#;
2643
2644 let config = ProxyConfig::parse(toml).unwrap();
2645 let filter = config.backends[0]
2646 .build_filter(&config.proxy.separator)
2647 .unwrap()
2648 .expect("should have filter");
2649 assert!(filter.tool_filter.allows("read"));
2650 assert!(!filter.tool_filter.allows("delete"));
2651 assert!(!filter.tool_filter.allows("write"));
2652 }
2653
2654 #[test]
2655 fn test_parse_inject_args() {
2656 let toml = r#"
2657 [proxy]
2658 name = "inject-gw"
2659 [proxy.listen]
2660
2661 [[backends]]
2662 name = "db"
2663 transport = "http"
2664 url = "http://localhost:8080"
2665
2666 [backends.default_args]
2667 timeout = 30
2668
2669 [[backends.inject_args]]
2670 tool = "query"
2671 args = { read_only = true, max_rows = 1000 }
2672
2673 [[backends.inject_args]]
2674 tool = "dangerous_op"
2675 args = { dry_run = true }
2676 overwrite = true
2677 "#;
2678
2679 let config = ProxyConfig::parse(toml).unwrap();
2680 let backend = &config.backends[0];
2681
2682 assert_eq!(backend.default_args.len(), 1);
2683 assert_eq!(backend.default_args["timeout"], 30);
2684
2685 assert_eq!(backend.inject_args.len(), 2);
2686 assert_eq!(backend.inject_args[0].tool, "query");
2687 assert_eq!(backend.inject_args[0].args["read_only"], true);
2688 assert_eq!(backend.inject_args[0].args["max_rows"], 1000);
2689 assert!(!backend.inject_args[0].overwrite);
2690
2691 assert_eq!(backend.inject_args[1].tool, "dangerous_op");
2692 assert_eq!(backend.inject_args[1].args["dry_run"], true);
2693 assert!(backend.inject_args[1].overwrite);
2694 }
2695
2696 #[test]
2697 fn test_parse_inject_args_defaults_to_empty() {
2698 let config = ProxyConfig::parse(minimal_config()).unwrap();
2699 assert!(config.backends[0].default_args.is_empty());
2700 assert!(config.backends[0].inject_args.is_empty());
2701 }
2702
2703 #[test]
2704 fn test_build_filter_none_when_no_filtering() {
2705 let config = ProxyConfig::parse(minimal_config()).unwrap();
2706 assert!(
2707 config.backends[0]
2708 .build_filter(&config.proxy.separator)
2709 .unwrap()
2710 .is_none()
2711 );
2712 }
2713
2714 #[test]
2715 fn test_validate_rejects_duplicate_backend_names() {
2716 let toml = r#"
2717 [proxy]
2718 name = "test"
2719 [proxy.listen]
2720
2721 [[backends]]
2722 name = "echo"
2723 transport = "stdio"
2724 command = "echo"
2725
2726 [[backends]]
2727 name = "echo"
2728 transport = "stdio"
2729 command = "cat"
2730 "#;
2731 let err = ProxyConfig::parse(toml).unwrap_err();
2732 assert!(
2733 err.to_string().contains("duplicate backend name"),
2734 "expected duplicate error, got: {}",
2735 err
2736 );
2737 }
2738
2739 #[test]
2740 fn test_validate_global_rate_limit_zero_requests() {
2741 let toml = r#"
2742 [proxy]
2743 name = "test"
2744 [proxy.listen]
2745 [proxy.rate_limit]
2746 requests = 0
2747
2748 [[backends]]
2749 name = "echo"
2750 transport = "stdio"
2751 command = "echo"
2752 "#;
2753 let err = ProxyConfig::parse(toml).unwrap_err();
2754 assert!(err.to_string().contains("requests must be > 0"));
2755 }
2756
2757 #[test]
2758 fn test_parse_global_rate_limit() {
2759 let toml = r#"
2760 [proxy]
2761 name = "test"
2762 [proxy.listen]
2763 [proxy.rate_limit]
2764 requests = 500
2765 period_seconds = 1
2766
2767 [[backends]]
2768 name = "echo"
2769 transport = "stdio"
2770 command = "echo"
2771 "#;
2772 let config = ProxyConfig::parse(toml).unwrap();
2773 let rl = config.proxy.rate_limit.unwrap();
2774 assert_eq!(rl.requests, 500);
2775 assert_eq!(rl.period_seconds, 1);
2776 }
2777
2778 #[test]
2779 fn test_name_filter_glob_wildcard() {
2780 let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
2781 assert!(filter.allows("read_file"));
2782 assert!(filter.allows("write_file"));
2783 assert!(!filter.allows("query"));
2784 assert!(!filter.allows("file_read"));
2785 }
2786
2787 #[test]
2788 fn test_name_filter_glob_prefix() {
2789 let filter = NameFilter::allow_list(["list_*".to_string()]).unwrap();
2790 assert!(filter.allows("list_files"));
2791 assert!(filter.allows("list_users"));
2792 assert!(!filter.allows("get_files"));
2793 }
2794
2795 #[test]
2796 fn test_name_filter_glob_question_mark() {
2797 let filter = NameFilter::allow_list(["get_?".to_string()]).unwrap();
2798 assert!(filter.allows("get_a"));
2799 assert!(filter.allows("get_1"));
2800 assert!(!filter.allows("get_ab"));
2801 assert!(!filter.allows("get_"));
2802 }
2803
2804 #[test]
2805 fn test_name_filter_glob_deny_list() {
2806 let filter = NameFilter::deny_list(["*_delete*".to_string()]).unwrap();
2807 assert!(filter.allows("read_file"));
2808 assert!(filter.allows("create_issue"));
2809 assert!(!filter.allows("force_delete_all"));
2810 assert!(!filter.allows("soft_delete"));
2811 }
2812
2813 #[test]
2814 fn test_name_filter_glob_exact_match_still_works() {
2815 let filter = NameFilter::allow_list(["read_file".to_string()]).unwrap();
2816 assert!(filter.allows("read_file"));
2817 assert!(!filter.allows("write_file"));
2818 }
2819
2820 #[test]
2821 fn test_name_filter_glob_multiple_patterns() {
2822 let filter = NameFilter::allow_list(["read_*".to_string(), "list_*".to_string()]).unwrap();
2823 assert!(filter.allows("read_file"));
2824 assert!(filter.allows("list_users"));
2825 assert!(!filter.allows("delete_file"));
2826 }
2827
2828 #[test]
2829 fn test_name_filter_regex_allow_list() {
2830 let filter =
2831 NameFilter::allow_list(["re:^list_.*$".to_string(), "re:^get_\\w+$".to_string()])
2832 .unwrap();
2833 assert!(filter.allows("list_files"));
2834 assert!(filter.allows("list_users"));
2835 assert!(filter.allows("get_item"));
2836 assert!(!filter.allows("delete_file"));
2837 assert!(!filter.allows("create_issue"));
2838 }
2839
2840 #[test]
2841 fn test_name_filter_regex_deny_list() {
2842 let filter = NameFilter::deny_list(["re:^delete_".to_string()]).unwrap();
2843 assert!(filter.allows("read_file"));
2844 assert!(filter.allows("list_users"));
2845 assert!(!filter.allows("delete_file"));
2846 assert!(!filter.allows("delete_all"));
2847 }
2848
2849 #[test]
2850 fn test_name_filter_mixed_glob_and_regex() {
2851 let filter =
2852 NameFilter::allow_list(["read_*".to_string(), "re:^list_\\w+$".to_string()]).unwrap();
2853 assert!(filter.allows("read_file"));
2854 assert!(filter.allows("read_dir"));
2855 assert!(filter.allows("list_users"));
2856 assert!(!filter.allows("delete_file"));
2857 }
2858
2859 #[test]
2860 fn test_name_filter_regex_invalid_pattern() {
2861 let result = NameFilter::allow_list(["re:[invalid".to_string()]);
2862 assert!(result.is_err(), "invalid regex should produce an error");
2863 }
2864
2865 #[test]
2866 fn test_name_filter_regex_partial_match() {
2867 let filter = NameFilter::allow_list(["re:list".to_string()]).unwrap();
2869 assert!(filter.allows("list_files"));
2870 assert!(filter.allows("my_list_tool"));
2871 assert!(!filter.allows("read_file"));
2872 }
2873
2874 #[test]
2875 fn test_config_parse_regex_filter() {
2876 let toml = r#"
2877 [proxy]
2878 name = "regex-gw"
2879 [proxy.listen]
2880
2881 [[backends]]
2882 name = "svc"
2883 transport = "stdio"
2884 command = "echo"
2885 expose_tools = ["*_issue", "re:^list_.*$"]
2886 "#;
2887
2888 let config = ProxyConfig::parse(toml).unwrap();
2889 let filter = config.backends[0]
2890 .build_filter(&config.proxy.separator)
2891 .unwrap()
2892 .expect("should have filter");
2893 assert!(filter.tool_filter.allows("create_issue"));
2894 assert!(filter.tool_filter.allows("list_files"));
2895 assert!(filter.tool_filter.allows("list_users"));
2896 assert!(!filter.tool_filter.allows("delete_file"));
2897 }
2898
2899 #[test]
2900 fn test_parse_param_overrides() {
2901 let toml = r#"
2902 [proxy]
2903 name = "override-gw"
2904 [proxy.listen]
2905
2906 [[backends]]
2907 name = "fs"
2908 transport = "http"
2909 url = "http://localhost:8080"
2910
2911 [[backends.param_overrides]]
2912 tool = "list_directory"
2913 hide = ["path"]
2914 rename = { recursive = "deep_search" }
2915
2916 [backends.param_overrides.defaults]
2917 path = "/home/docs"
2918 "#;
2919
2920 let config = ProxyConfig::parse(toml).unwrap();
2921 assert_eq!(config.backends[0].param_overrides.len(), 1);
2922 let po = &config.backends[0].param_overrides[0];
2923 assert_eq!(po.tool, "list_directory");
2924 assert_eq!(po.hide, vec!["path"]);
2925 assert_eq!(po.defaults.get("path").unwrap(), "/home/docs");
2926 assert_eq!(po.rename.get("recursive").unwrap(), "deep_search");
2927 }
2928
2929 #[test]
2930 fn test_reject_param_override_empty_tool() {
2931 let toml = r#"
2932 [proxy]
2933 name = "bad"
2934 [proxy.listen]
2935
2936 [[backends]]
2937 name = "fs"
2938 transport = "http"
2939 url = "http://localhost:8080"
2940
2941 [[backends.param_overrides]]
2942 tool = ""
2943 hide = ["path"]
2944 "#;
2945
2946 let err = ProxyConfig::parse(toml).unwrap_err();
2947 assert!(
2948 format!("{err}").contains("tool must not be empty"),
2949 "unexpected error: {err}"
2950 );
2951 }
2952
2953 #[test]
2954 fn test_reject_param_override_duplicate_tool() {
2955 let toml = r#"
2956 [proxy]
2957 name = "bad"
2958 [proxy.listen]
2959
2960 [[backends]]
2961 name = "fs"
2962 transport = "http"
2963 url = "http://localhost:8080"
2964
2965 [[backends.param_overrides]]
2966 tool = "list_directory"
2967 hide = ["path"]
2968
2969 [[backends.param_overrides]]
2970 tool = "list_directory"
2971 hide = ["pattern"]
2972 "#;
2973
2974 let err = ProxyConfig::parse(toml).unwrap_err();
2975 assert!(
2976 format!("{err}").contains("duplicate param_overrides"),
2977 "unexpected error: {err}"
2978 );
2979 }
2980
2981 #[test]
2982 fn test_reject_param_override_hide_and_rename_same_param() {
2983 let toml = r#"
2984 [proxy]
2985 name = "bad"
2986 [proxy.listen]
2987
2988 [[backends]]
2989 name = "fs"
2990 transport = "http"
2991 url = "http://localhost:8080"
2992
2993 [[backends.param_overrides]]
2994 tool = "list_directory"
2995 hide = ["path"]
2996 rename = { path = "dir" }
2997 "#;
2998
2999 let err = ProxyConfig::parse(toml).unwrap_err();
3000 assert!(
3001 format!("{err}").contains("cannot be both hidden and renamed"),
3002 "unexpected error: {err}"
3003 );
3004 }
3005
3006 #[test]
3007 fn test_reject_param_override_duplicate_rename_target() {
3008 let toml = r#"
3009 [proxy]
3010 name = "bad"
3011 [proxy.listen]
3012
3013 [[backends]]
3014 name = "fs"
3015 transport = "http"
3016 url = "http://localhost:8080"
3017
3018 [[backends.param_overrides]]
3019 tool = "list_directory"
3020 rename = { path = "location", dir = "location" }
3021 "#;
3022
3023 let err = ProxyConfig::parse(toml).unwrap_err();
3024 assert!(
3025 format!("{err}").contains("duplicate rename target"),
3026 "unexpected error: {err}"
3027 );
3028 }
3029
3030 #[test]
3031 fn test_cache_backend_defaults_to_memory() {
3032 let config = ProxyConfig::parse(minimal_config()).unwrap();
3033 assert_eq!(config.cache.backend, "memory");
3034 assert!(config.cache.url.is_none());
3035 }
3036
3037 #[test]
3038 fn test_cache_backend_redis_requires_url() {
3039 let toml = r#"
3040 [proxy]
3041 name = "test"
3042 [proxy.listen]
3043 [cache]
3044 backend = "redis"
3045
3046 [[backends]]
3047 name = "echo"
3048 transport = "stdio"
3049 command = "echo"
3050 "#;
3051 let err = ProxyConfig::parse(toml).unwrap_err();
3052 assert!(err.to_string().contains("cache.url is required"));
3053 }
3054
3055 #[test]
3056 fn test_cache_backend_unknown_rejected() {
3057 let toml = r#"
3058 [proxy]
3059 name = "test"
3060 [proxy.listen]
3061 [cache]
3062 backend = "memcached"
3063
3064 [[backends]]
3065 name = "echo"
3066 transport = "stdio"
3067 command = "echo"
3068 "#;
3069 let err = ProxyConfig::parse(toml).unwrap_err();
3070 assert!(err.to_string().contains("unknown cache backend"));
3071 }
3072
3073 #[test]
3074 fn test_cache_backend_redis_with_url() {
3075 let toml = r#"
3076 [proxy]
3077 name = "test"
3078 [proxy.listen]
3079 [cache]
3080 backend = "redis"
3081 url = "redis://localhost:6379"
3082 prefix = "myapp:"
3083
3084 [[backends]]
3085 name = "echo"
3086 transport = "stdio"
3087 command = "echo"
3088 "#;
3089 let config = ProxyConfig::parse(toml).unwrap();
3090 assert_eq!(config.cache.backend, "redis");
3091 assert_eq!(config.cache.url.as_deref(), Some("redis://localhost:6379"));
3092 assert_eq!(config.cache.prefix, "myapp:");
3093 }
3094
3095 #[test]
3096 fn test_parse_bearer_scoped_tokens() {
3097 let toml = r#"
3098 [proxy]
3099 name = "scoped"
3100 [proxy.listen]
3101
3102 [[backends]]
3103 name = "echo"
3104 transport = "stdio"
3105 command = "echo"
3106
3107 [auth]
3108 type = "bearer"
3109
3110 [[auth.scoped_tokens]]
3111 token = "frontend-token"
3112 allow_tools = ["echo/read_file"]
3113
3114 [[auth.scoped_tokens]]
3115 token = "admin-token"
3116 "#;
3117
3118 let config = ProxyConfig::parse(toml).unwrap();
3119 match &config.auth {
3120 Some(AuthConfig::Bearer {
3121 tokens,
3122 scoped_tokens,
3123 }) => {
3124 assert!(tokens.is_empty());
3125 assert_eq!(scoped_tokens.len(), 2);
3126 assert_eq!(scoped_tokens[0].token, "frontend-token");
3127 assert_eq!(scoped_tokens[0].allow_tools, vec!["echo/read_file"]);
3128 assert!(scoped_tokens[1].allow_tools.is_empty());
3129 }
3130 other => panic!("expected Bearer auth, got: {other:?}"),
3131 }
3132 }
3133
3134 #[test]
3135 fn test_parse_bearer_mixed_tokens() {
3136 let toml = r#"
3137 [proxy]
3138 name = "mixed"
3139 [proxy.listen]
3140
3141 [[backends]]
3142 name = "echo"
3143 transport = "stdio"
3144 command = "echo"
3145
3146 [auth]
3147 type = "bearer"
3148 tokens = ["simple-token"]
3149
3150 [[auth.scoped_tokens]]
3151 token = "scoped-token"
3152 deny_tools = ["echo/delete"]
3153 "#;
3154
3155 let config = ProxyConfig::parse(toml).unwrap();
3156 match &config.auth {
3157 Some(AuthConfig::Bearer {
3158 tokens,
3159 scoped_tokens,
3160 }) => {
3161 assert_eq!(tokens, &["simple-token"]);
3162 assert_eq!(scoped_tokens.len(), 1);
3163 assert_eq!(scoped_tokens[0].deny_tools, vec!["echo/delete"]);
3164 }
3165 other => panic!("expected Bearer auth, got: {other:?}"),
3166 }
3167 }
3168
3169 #[test]
3170 fn test_bearer_empty_tokens_rejected() {
3171 let toml = r#"
3172 [proxy]
3173 name = "empty"
3174 [proxy.listen]
3175
3176 [[backends]]
3177 name = "echo"
3178 transport = "stdio"
3179 command = "echo"
3180
3181 [auth]
3182 type = "bearer"
3183 "#;
3184
3185 let err = ProxyConfig::parse(toml).unwrap_err();
3186 assert!(
3187 err.to_string().contains("at least one token"),
3188 "unexpected error: {err}"
3189 );
3190 }
3191
3192 #[test]
3193 fn test_bearer_duplicate_across_lists_rejected() {
3194 let toml = r#"
3195 [proxy]
3196 name = "dup"
3197 [proxy.listen]
3198
3199 [[backends]]
3200 name = "echo"
3201 transport = "stdio"
3202 command = "echo"
3203
3204 [auth]
3205 type = "bearer"
3206 tokens = ["shared-token"]
3207
3208 [[auth.scoped_tokens]]
3209 token = "shared-token"
3210 allow_tools = ["echo/read"]
3211 "#;
3212
3213 let err = ProxyConfig::parse(toml).unwrap_err();
3214 assert!(
3215 err.to_string().contains("duplicate bearer token"),
3216 "unexpected error: {err}"
3217 );
3218 }
3219
3220 #[test]
3221 fn test_bearer_allow_and_deny_rejected() {
3222 let toml = r#"
3223 [proxy]
3224 name = "both"
3225 [proxy.listen]
3226
3227 [[backends]]
3228 name = "echo"
3229 transport = "stdio"
3230 command = "echo"
3231
3232 [auth]
3233 type = "bearer"
3234
3235 [[auth.scoped_tokens]]
3236 token = "conflict"
3237 allow_tools = ["echo/read"]
3238 deny_tools = ["echo/write"]
3239 "#;
3240
3241 let err = ProxyConfig::parse(toml).unwrap_err();
3242 assert!(
3243 err.to_string().contains("cannot specify both"),
3244 "unexpected error: {err}"
3245 );
3246 }
3247
3248 #[test]
3249 fn test_parse_websocket_transport() {
3250 let toml = r#"
3251 [proxy]
3252 name = "ws-proxy"
3253 [proxy.listen]
3254
3255 [[backends]]
3256 name = "ws-backend"
3257 transport = "websocket"
3258 url = "ws://localhost:9090/ws"
3259 "#;
3260
3261 let config = ProxyConfig::parse(toml).unwrap();
3262 assert!(matches!(
3263 config.backends[0].transport,
3264 TransportType::Websocket
3265 ));
3266 assert_eq!(
3267 config.backends[0].url.as_deref(),
3268 Some("ws://localhost:9090/ws")
3269 );
3270 }
3271
3272 #[test]
3273 fn test_websocket_transport_requires_url() {
3274 let toml = r#"
3275 [proxy]
3276 name = "ws-proxy"
3277 [proxy.listen]
3278
3279 [[backends]]
3280 name = "ws-backend"
3281 transport = "websocket"
3282 "#;
3283
3284 let err = ProxyConfig::parse(toml).unwrap_err();
3285 assert!(
3286 err.to_string()
3287 .contains("websocket transport requires 'url'"),
3288 "unexpected error: {err}"
3289 );
3290 }
3291
3292 #[test]
3293 fn test_websocket_with_bearer_token() {
3294 let toml = r#"
3295 [proxy]
3296 name = "ws-proxy"
3297 [proxy.listen]
3298
3299 [[backends]]
3300 name = "ws-backend"
3301 transport = "websocket"
3302 url = "wss://secure.example.com/mcp"
3303 bearer_token = "my-secret"
3304 "#;
3305
3306 let config = ProxyConfig::parse(toml).unwrap();
3307 assert_eq!(
3308 config.backends[0].bearer_token.as_deref(),
3309 Some("my-secret")
3310 );
3311 }
3312
3313 #[test]
3314 fn test_tool_discovery_defaults_false() {
3315 let config = ProxyConfig::parse(minimal_config()).unwrap();
3316 assert!(!config.proxy.tool_discovery);
3317 }
3318
3319 #[test]
3320 fn test_tool_discovery_enabled() {
3321 let toml = r#"
3322 [proxy]
3323 name = "discovery"
3324 tool_discovery = true
3325 [proxy.listen]
3326
3327 [[backends]]
3328 name = "echo"
3329 transport = "stdio"
3330 command = "echo"
3331 "#;
3332
3333 let config = ProxyConfig::parse(toml).unwrap();
3334 assert!(config.proxy.tool_discovery);
3335 }
3336
3337 #[test]
3338 fn test_parse_oauth_config() {
3339 let toml = r#"
3340 [proxy]
3341 name = "oauth-proxy"
3342 [proxy.listen]
3343
3344 [[backends]]
3345 name = "echo"
3346 transport = "stdio"
3347 command = "echo"
3348
3349 [auth]
3350 type = "oauth"
3351 issuer = "https://accounts.google.com"
3352 audience = "mcp-proxy"
3353 "#;
3354
3355 let config = ProxyConfig::parse(toml).unwrap();
3356 match &config.auth {
3357 Some(AuthConfig::OAuth {
3358 issuer,
3359 audience,
3360 token_validation,
3361 ..
3362 }) => {
3363 assert_eq!(issuer, "https://accounts.google.com");
3364 assert_eq!(audience, "mcp-proxy");
3365 assert_eq!(token_validation, &TokenValidationStrategy::Jwt);
3366 }
3367 other => panic!("expected OAuth auth, got: {other:?}"),
3368 }
3369 }
3370
3371 #[test]
3372 fn test_parse_oauth_with_introspection() {
3373 let toml = r#"
3374 [proxy]
3375 name = "oauth-proxy"
3376 [proxy.listen]
3377
3378 [[backends]]
3379 name = "echo"
3380 transport = "stdio"
3381 command = "echo"
3382
3383 [auth]
3384 type = "oauth"
3385 issuer = "https://auth.example.com"
3386 audience = "mcp-proxy"
3387 client_id = "my-client"
3388 client_secret = "my-secret"
3389 token_validation = "introspection"
3390 "#;
3391
3392 let config = ProxyConfig::parse(toml).unwrap();
3393 match &config.auth {
3394 Some(AuthConfig::OAuth {
3395 token_validation,
3396 client_id,
3397 client_secret,
3398 ..
3399 }) => {
3400 assert_eq!(token_validation, &TokenValidationStrategy::Introspection);
3401 assert_eq!(client_id.as_deref(), Some("my-client"));
3402 assert_eq!(client_secret.as_deref(), Some("my-secret"));
3403 }
3404 other => panic!("expected OAuth auth, got: {other:?}"),
3405 }
3406 }
3407
3408 #[test]
3409 fn test_oauth_introspection_requires_credentials() {
3410 let toml = r#"
3411 [proxy]
3412 name = "oauth-proxy"
3413 [proxy.listen]
3414
3415 [[backends]]
3416 name = "echo"
3417 transport = "stdio"
3418 command = "echo"
3419
3420 [auth]
3421 type = "oauth"
3422 issuer = "https://auth.example.com"
3423 audience = "mcp-proxy"
3424 token_validation = "introspection"
3425 "#;
3426
3427 let err = ProxyConfig::parse(toml).unwrap_err();
3428 assert!(
3429 err.to_string().contains("client_id"),
3430 "unexpected error: {err}"
3431 );
3432 }
3433
3434 #[test]
3435 fn test_parse_oauth_with_overrides() {
3436 let toml = r#"
3437 [proxy]
3438 name = "oauth-proxy"
3439 [proxy.listen]
3440
3441 [[backends]]
3442 name = "echo"
3443 transport = "stdio"
3444 command = "echo"
3445
3446 [auth]
3447 type = "oauth"
3448 issuer = "https://auth.example.com"
3449 audience = "mcp-proxy"
3450 jwks_uri = "https://auth.example.com/custom/jwks"
3451 introspection_endpoint = "https://auth.example.com/custom/introspect"
3452 client_id = "my-client"
3453 client_secret = "my-secret"
3454 token_validation = "both"
3455 required_scopes = ["read", "write"]
3456 "#;
3457
3458 let config = ProxyConfig::parse(toml).unwrap();
3459 match &config.auth {
3460 Some(AuthConfig::OAuth {
3461 jwks_uri,
3462 introspection_endpoint,
3463 token_validation,
3464 required_scopes,
3465 ..
3466 }) => {
3467 assert_eq!(
3468 jwks_uri.as_deref(),
3469 Some("https://auth.example.com/custom/jwks")
3470 );
3471 assert_eq!(
3472 introspection_endpoint.as_deref(),
3473 Some("https://auth.example.com/custom/introspect")
3474 );
3475 assert_eq!(token_validation, &TokenValidationStrategy::Both);
3476 assert_eq!(required_scopes, &["read", "write"]);
3477 }
3478 other => panic!("expected OAuth auth, got: {other:?}"),
3479 }
3480 }
3481
3482 #[test]
3483 fn test_check_env_vars_warns_on_unset() {
3484 let toml = r#"
3485 [proxy]
3486 name = "env-check"
3487 [proxy.listen]
3488
3489 [[backends]]
3490 name = "svc"
3491 transport = "stdio"
3492 command = "echo"
3493 bearer_token = "${TOTALLY_UNSET_VAR_1}"
3494
3495 [backends.env]
3496 API_KEY = "${TOTALLY_UNSET_VAR_2}"
3497 STATIC = "plain-value"
3498
3499 [auth]
3500 type = "bearer"
3501 tokens = ["${TOTALLY_UNSET_VAR_3}", "literal-token"]
3502
3503 [[auth.scoped_tokens]]
3504 token = "${TOTALLY_UNSET_VAR_4}"
3505 allow_tools = ["svc/echo"]
3506 "#;
3507
3508 let config = ProxyConfig::parse(toml).unwrap();
3509 let warnings = config.check_env_vars();
3510
3511 assert_eq!(warnings.len(), 4, "warnings: {warnings:?}");
3512 assert!(warnings[0].contains("TOTALLY_UNSET_VAR_1"));
3513 assert!(warnings[0].contains("bearer_token"));
3514 assert!(warnings[1].contains("TOTALLY_UNSET_VAR_2"));
3515 assert!(warnings[1].contains("env.API_KEY"));
3516 assert!(warnings[2].contains("TOTALLY_UNSET_VAR_3"));
3517 assert!(warnings[2].contains("tokens[0]"));
3518 assert!(warnings[3].contains("TOTALLY_UNSET_VAR_4"));
3519 assert!(warnings[3].contains("scoped_tokens[0]"));
3520 }
3521
3522 #[test]
3523 fn test_check_env_vars_no_warnings_when_set() {
3524 unsafe { std::env::set_var("MCP_CHECK_TEST_VAR", "value") };
3526
3527 let toml = r#"
3528 [proxy]
3529 name = "env-check"
3530 [proxy.listen]
3531
3532 [[backends]]
3533 name = "svc"
3534 transport = "stdio"
3535 command = "echo"
3536 bearer_token = "${MCP_CHECK_TEST_VAR}"
3537 "#;
3538
3539 let config = ProxyConfig::parse(toml).unwrap();
3540 let warnings = config.check_env_vars();
3541 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3542
3543 unsafe { std::env::remove_var("MCP_CHECK_TEST_VAR") };
3545 }
3546
3547 #[test]
3548 fn test_check_env_vars_no_warnings_for_literals() {
3549 let toml = r#"
3550 [proxy]
3551 name = "env-check"
3552 [proxy.listen]
3553
3554 [[backends]]
3555 name = "svc"
3556 transport = "stdio"
3557 command = "echo"
3558 bearer_token = "literal-token"
3559 "#;
3560
3561 let config = ProxyConfig::parse(toml).unwrap();
3562 let warnings = config.check_env_vars();
3563 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3564 }
3565
3566 #[test]
3567 fn test_check_env_vars_oauth_client_secret() {
3568 let toml = r#"
3569 [proxy]
3570 name = "oauth-check"
3571 [proxy.listen]
3572
3573 [[backends]]
3574 name = "svc"
3575 transport = "http"
3576 url = "http://localhost:3000"
3577
3578 [auth]
3579 type = "oauth"
3580 issuer = "https://auth.example.com"
3581 audience = "mcp-proxy"
3582 client_id = "my-client"
3583 client_secret = "${TOTALLY_UNSET_OAUTH_SECRET}"
3584 token_validation = "introspection"
3585 "#;
3586
3587 let config = ProxyConfig::parse(toml).unwrap();
3588 let warnings = config.check_env_vars();
3589 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
3590 assert!(warnings[0].contains("TOTALLY_UNSET_OAUTH_SECRET"));
3591 assert!(warnings[0].contains("client_secret"));
3592 }
3593
3594 #[cfg(feature = "yaml")]
3595 #[test]
3596 fn test_parse_yaml_config() {
3597 let yaml = r#"
3598proxy:
3599 name: yaml-proxy
3600 listen:
3601 host: "127.0.0.1"
3602 port: 8080
3603backends:
3604 - name: echo
3605 transport: stdio
3606 command: echo
3607"#;
3608 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3609 assert_eq!(config.proxy.name, "yaml-proxy");
3610 assert_eq!(config.backends.len(), 1);
3611 assert_eq!(config.backends[0].name, "echo");
3612 }
3613
3614 #[cfg(feature = "yaml")]
3615 #[test]
3616 fn test_parse_yaml_with_auth() {
3617 let yaml = r#"
3618proxy:
3619 name: auth-proxy
3620 listen:
3621 host: "127.0.0.1"
3622 port: 9090
3623backends:
3624 - name: api
3625 transport: stdio
3626 command: echo
3627auth:
3628 type: bearer
3629 tokens:
3630 - token-1
3631 - token-2
3632"#;
3633 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3634 match &config.auth {
3635 Some(AuthConfig::Bearer { tokens, .. }) => {
3636 assert_eq!(tokens, &["token-1", "token-2"]);
3637 }
3638 other => panic!("expected Bearer auth, got: {other:?}"),
3639 }
3640 }
3641
3642 #[cfg(feature = "yaml")]
3643 #[test]
3644 fn test_parse_yaml_with_middleware() {
3645 let yaml = r#"
3646proxy:
3647 name: mw-proxy
3648 listen:
3649 host: "127.0.0.1"
3650 port: 8080
3651backends:
3652 - name: api
3653 transport: stdio
3654 command: echo
3655 timeout:
3656 seconds: 30
3657 rate_limit:
3658 requests: 100
3659 period_seconds: 1
3660 expose_tools:
3661 - read_file
3662 - list_directory
3663"#;
3664 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3665 assert_eq!(config.backends[0].timeout.as_ref().unwrap().seconds, 30);
3666 assert_eq!(
3667 config.backends[0].rate_limit.as_ref().unwrap().requests,
3668 100
3669 );
3670 assert_eq!(
3671 config.backends[0].expose_tools,
3672 vec!["read_file", "list_directory"]
3673 );
3674 }
3675
3676 #[test]
3677 fn test_from_mcp_json() {
3678 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json");
3679 let project_dir = dir.join("my-project");
3680 std::fs::create_dir_all(&project_dir).unwrap();
3681
3682 let mcp_json_path = project_dir.join(".mcp.json");
3683 std::fs::write(
3684 &mcp_json_path,
3685 r#"{
3686 "mcpServers": {
3687 "github": {
3688 "command": "npx",
3689 "args": ["-y", "@modelcontextprotocol/server-github"]
3690 },
3691 "api": {
3692 "url": "http://localhost:9000"
3693 }
3694 }
3695 }"#,
3696 )
3697 .unwrap();
3698
3699 let config = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap();
3700
3701 assert_eq!(config.proxy.name, "my-project");
3703 assert_eq!(config.proxy.listen.host, "127.0.0.1");
3705 assert_eq!(config.proxy.listen.port, 8080);
3706 assert_eq!(config.proxy.version, "0.1.0");
3707 assert_eq!(config.proxy.separator, "/");
3708 assert!(config.auth.is_none());
3710 assert!(config.composite_tools.is_empty());
3711 assert_eq!(config.backends.len(), 2);
3713 assert_eq!(config.backends[0].name, "api");
3714 assert_eq!(config.backends[1].name, "github");
3715
3716 std::fs::remove_dir_all(&dir).unwrap();
3717 }
3718
3719 #[test]
3720 fn test_from_mcp_json_empty_rejects() {
3721 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json_empty");
3722 std::fs::create_dir_all(&dir).unwrap();
3723
3724 let mcp_json_path = dir.join(".mcp.json");
3725 std::fs::write(&mcp_json_path, r#"{ "mcpServers": {} }"#).unwrap();
3726
3727 let err = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap_err();
3728 assert!(
3729 err.to_string().contains("at least one backend"),
3730 "unexpected error: {err}"
3731 );
3732
3733 std::fs::remove_dir_all(&dir).unwrap();
3734 }
3735
3736 #[test]
3737 fn test_priority_defaults_to_zero() {
3738 let toml = r#"
3739 [proxy]
3740 name = "test"
3741 [proxy.listen]
3742
3743 [[backends]]
3744 name = "api"
3745 transport = "stdio"
3746 command = "echo"
3747 "#;
3748
3749 let config = ProxyConfig::parse(toml).unwrap();
3750 assert_eq!(config.backends[0].priority, 0);
3751 }
3752
3753 #[test]
3754 fn test_priority_parsed_from_config() {
3755 let toml = r#"
3756 [proxy]
3757 name = "test"
3758 [proxy.listen]
3759
3760 [[backends]]
3761 name = "api"
3762 transport = "stdio"
3763 command = "echo"
3764
3765 [[backends]]
3766 name = "api-backup-1"
3767 transport = "stdio"
3768 command = "echo"
3769 failover_for = "api"
3770 priority = 10
3771
3772 [[backends]]
3773 name = "api-backup-2"
3774 transport = "stdio"
3775 command = "echo"
3776 failover_for = "api"
3777 priority = 5
3778 "#;
3779
3780 let config = ProxyConfig::parse(toml).unwrap();
3781 assert_eq!(config.backends[0].priority, 0);
3782 assert_eq!(config.backends[1].priority, 10);
3783 assert_eq!(config.backends[2].priority, 5);
3784 }
3785}