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