1use std::collections::HashMap;
4use std::collections::HashSet;
5use std::path::Path;
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Deserialize, Serialize)]
12pub struct ProxyConfig {
13 pub proxy: ProxySettings,
15 #[serde(default)]
17 pub backends: Vec<BackendConfig>,
18 pub auth: Option<AuthConfig>,
20 #[serde(default)]
22 pub performance: PerformanceConfig,
23 #[serde(default)]
25 pub security: SecurityConfig,
26 #[serde(default)]
28 pub cache: CacheBackendConfig,
29 #[serde(default)]
31 pub observability: ObservabilityConfig,
32 #[serde(default)]
34 pub composite_tools: Vec<CompositeToolConfig>,
35}
36
37#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum CompositeStrategy {
41 #[default]
43 Parallel,
44}
45
46#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct CompositeToolConfig {
63 pub name: String,
65 pub description: String,
67 pub tools: Vec<String>,
69 #[serde(default)]
71 pub strategy: CompositeStrategy,
72}
73
74#[derive(Debug, Deserialize, Serialize)]
76pub struct ProxySettings {
77 pub name: String,
79 #[serde(default = "default_version")]
81 pub version: String,
82 #[serde(default = "default_separator")]
84 pub separator: String,
85 pub listen: ListenConfig,
87 pub instructions: Option<String>,
89 #[serde(default = "default_shutdown_timeout")]
91 pub shutdown_timeout_seconds: u64,
92 #[serde(default)]
94 pub hot_reload: bool,
95 pub import_backends: Option<String>,
98 pub rate_limit: Option<GlobalRateLimitConfig>,
100 #[serde(default)]
104 pub tool_discovery: bool,
105 #[serde(default)]
113 pub tool_exposure: ToolExposure,
114}
115
116#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
133#[serde(rename_all = "lowercase")]
134pub enum ToolExposure {
135 #[default]
137 Direct,
138 Search,
141}
142
143#[derive(Debug, Deserialize, Serialize, Clone)]
145pub struct GlobalRateLimitConfig {
146 pub requests: usize,
148 #[serde(default = "default_rate_period")]
150 pub period_seconds: u64,
151}
152
153#[derive(Debug, Deserialize, Serialize)]
155pub struct ListenConfig {
156 #[serde(default = "default_host")]
158 pub host: String,
159 #[serde(default = "default_port")]
161 pub port: u16,
162}
163
164#[derive(Debug, Deserialize, Serialize)]
166pub struct BackendConfig {
167 pub name: String,
169 pub transport: TransportType,
171 pub command: Option<String>,
173 #[serde(default)]
175 pub args: Vec<String>,
176 pub url: Option<String>,
178 #[serde(default)]
180 pub env: HashMap<String, String>,
181 pub timeout: Option<TimeoutConfig>,
183 pub circuit_breaker: Option<CircuitBreakerConfig>,
185 pub rate_limit: Option<RateLimitConfig>,
187 pub concurrency: Option<ConcurrencyConfig>,
189 pub retry: Option<RetryConfig>,
191 pub outlier_detection: Option<OutlierDetectionConfig>,
193 pub hedging: Option<HedgingConfig>,
195 pub mirror_of: Option<String>,
198 #[serde(default = "default_mirror_percent")]
200 pub mirror_percent: u32,
201 pub cache: Option<BackendCacheConfig>,
203 pub bearer_token: Option<String>,
206 #[serde(default)]
209 pub forward_auth: bool,
210 #[serde(default)]
212 pub aliases: Vec<AliasConfig>,
213 #[serde(default)]
216 pub default_args: serde_json::Map<String, serde_json::Value>,
217 #[serde(default)]
219 pub inject_args: Vec<InjectArgsConfig>,
220 #[serde(default)]
222 pub param_overrides: Vec<ParamOverrideConfig>,
223 #[serde(default)]
225 pub expose_tools: Vec<String>,
226 #[serde(default)]
228 pub hide_tools: Vec<String>,
229 #[serde(default)]
231 pub expose_resources: Vec<String>,
232 #[serde(default)]
234 pub hide_resources: Vec<String>,
235 #[serde(default)]
237 pub expose_prompts: Vec<String>,
238 #[serde(default)]
240 pub hide_prompts: Vec<String>,
241 #[serde(default)]
243 pub hide_destructive: bool,
244 #[serde(default)]
246 pub read_only_only: bool,
247 pub failover_for: Option<String>,
251 #[serde(default)]
256 pub priority: u32,
257 pub canary_of: Option<String>,
261 #[serde(default = "default_weight")]
264 pub weight: u32,
265}
266
267#[derive(Debug, Deserialize, Serialize)]
269#[serde(rename_all = "lowercase")]
270pub enum TransportType {
271 Stdio,
273 Http,
275 Websocket,
277}
278
279#[derive(Debug, Deserialize, Serialize)]
281pub struct TimeoutConfig {
282 pub seconds: u64,
284}
285
286#[derive(Debug, Deserialize, Serialize)]
288pub struct CircuitBreakerConfig {
289 #[serde(default = "default_failure_rate")]
291 pub failure_rate_threshold: f64,
292 #[serde(default = "default_min_calls")]
294 pub minimum_calls: usize,
295 #[serde(default = "default_wait_duration")]
297 pub wait_duration_seconds: u64,
298 #[serde(default = "default_half_open_calls")]
300 pub permitted_calls_in_half_open: usize,
301}
302
303#[derive(Debug, Deserialize, Serialize)]
305pub struct RateLimitConfig {
306 pub requests: usize,
308 #[serde(default = "default_rate_period")]
310 pub period_seconds: u64,
311}
312
313#[derive(Debug, Deserialize, Serialize)]
315pub struct ConcurrencyConfig {
316 pub max_concurrent: usize,
318}
319
320#[derive(Debug, Clone, Deserialize, Serialize)]
322pub struct RetryConfig {
323 #[serde(default = "default_max_retries")]
325 pub max_retries: u32,
326 #[serde(default = "default_initial_backoff_ms")]
328 pub initial_backoff_ms: u64,
329 #[serde(default = "default_max_backoff_ms")]
331 pub max_backoff_ms: u64,
332 pub budget_percent: Option<f64>,
337 #[serde(default = "default_min_retries_per_sec")]
340 pub min_retries_per_sec: u32,
341}
342
343#[derive(Debug, Clone, Deserialize, Serialize)]
347pub struct OutlierDetectionConfig {
348 #[serde(default = "default_consecutive_errors")]
350 pub consecutive_errors: u32,
351 #[serde(default = "default_interval_seconds")]
353 pub interval_seconds: u64,
354 #[serde(default = "default_base_ejection_seconds")]
356 pub base_ejection_seconds: u64,
357 #[serde(default = "default_max_ejection_percent")]
359 pub max_ejection_percent: u32,
360}
361
362#[derive(Debug, Clone, Deserialize, Serialize)]
364pub struct InjectArgsConfig {
365 pub tool: String,
367 pub args: serde_json::Map<String, serde_json::Value>,
370 #[serde(default)]
372 pub overwrite: bool,
373}
374
375#[derive(Debug, Clone, Deserialize, Serialize)]
390pub struct ParamOverrideConfig {
391 pub tool: String,
393 #[serde(default)]
397 pub hide: Vec<String>,
398 #[serde(default)]
401 pub defaults: serde_json::Map<String, serde_json::Value>,
402 #[serde(default)]
406 pub rename: HashMap<String, String>,
407}
408
409#[derive(Debug, Clone, Deserialize, Serialize)]
415pub struct HedgingConfig {
416 #[serde(default = "default_hedge_delay_ms")]
419 pub delay_ms: u64,
420 #[serde(default = "default_max_hedges")]
422 pub max_hedges: usize,
423}
424
425#[derive(Debug, Deserialize, Serialize)]
427#[serde(tag = "type", rename_all = "lowercase")]
428pub enum AuthConfig {
429 Bearer {
431 #[serde(default)]
433 tokens: Vec<String>,
434 #[serde(default)]
436 scoped_tokens: Vec<BearerTokenConfig>,
437 },
438 Jwt {
440 issuer: String,
442 audience: String,
444 jwks_uri: String,
446 #[serde(default)]
448 roles: Vec<RoleConfig>,
449 role_mapping: Option<RoleMappingConfig>,
451 },
452 OAuth {
458 issuer: String,
461 audience: String,
463 #[serde(default)]
465 client_id: Option<String>,
466 #[serde(default)]
469 client_secret: Option<String>,
470 #[serde(default)]
472 token_validation: TokenValidationStrategy,
473 #[serde(default)]
475 jwks_uri: Option<String>,
476 #[serde(default)]
478 introspection_endpoint: Option<String>,
479 #[serde(default)]
481 required_scopes: Vec<String>,
482 #[serde(default)]
484 roles: Vec<RoleConfig>,
485 role_mapping: Option<RoleMappingConfig>,
487 },
488}
489
490#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
492#[serde(rename_all = "lowercase")]
493pub enum TokenValidationStrategy {
494 #[default]
496 Jwt,
497 Introspection,
500 Both,
503}
504
505#[derive(Debug, Clone, Deserialize, Serialize)]
528pub struct BearerTokenConfig {
529 pub token: String,
531 #[serde(default)]
534 pub allow_tools: Vec<String>,
535 #[serde(default)]
537 pub deny_tools: Vec<String>,
538}
539
540#[derive(Debug, Deserialize, Serialize)]
542pub struct RoleConfig {
543 pub name: String,
545 #[serde(default)]
547 pub allow_tools: Vec<String>,
548 #[serde(default)]
550 pub deny_tools: Vec<String>,
551}
552
553#[derive(Debug, Deserialize, Serialize)]
555pub struct RoleMappingConfig {
556 pub claim: String,
558 pub mapping: HashMap<String, String>,
560}
561
562#[derive(Debug, Deserialize, Serialize)]
564pub struct AliasConfig {
565 pub from: String,
567 pub to: String,
569}
570
571#[derive(Debug, Deserialize, Serialize)]
573pub struct BackendCacheConfig {
574 #[serde(default)]
576 pub resource_ttl_seconds: u64,
577 #[serde(default)]
579 pub tool_ttl_seconds: u64,
580 #[serde(default = "default_max_cache_entries")]
582 pub max_entries: u64,
583}
584
585#[derive(Debug, Deserialize, Serialize, Clone)]
599pub struct CacheBackendConfig {
600 #[serde(default = "default_cache_backend")]
602 pub backend: String,
603 pub url: Option<String>,
605 #[serde(default = "default_cache_prefix")]
607 pub prefix: String,
608}
609
610impl Default for CacheBackendConfig {
611 fn default() -> Self {
612 Self {
613 backend: default_cache_backend(),
614 url: None,
615 prefix: default_cache_prefix(),
616 }
617 }
618}
619
620fn default_cache_backend() -> String {
621 "memory".to_string()
622}
623
624fn default_cache_prefix() -> String {
625 "mcp-proxy:".to_string()
626}
627
628#[derive(Debug, Default, Deserialize, Serialize)]
630pub struct PerformanceConfig {
631 #[serde(default)]
633 pub coalesce_requests: bool,
634}
635
636#[derive(Debug, Default, Deserialize, Serialize)]
638pub struct SecurityConfig {
639 pub max_argument_size: Option<usize>,
641}
642
643#[derive(Debug, Default, Deserialize, Serialize)]
645pub struct ObservabilityConfig {
646 #[serde(default)]
648 pub audit: bool,
649 #[serde(default = "default_log_level")]
651 pub log_level: String,
652 #[serde(default)]
654 pub json_logs: bool,
655 #[serde(default)]
657 pub metrics: MetricsConfig,
658 #[serde(default)]
660 pub tracing: TracingConfig,
661 #[serde(default)]
663 pub access_log: AccessLogConfig,
664}
665
666#[derive(Debug, Default, Deserialize, Serialize)]
668pub struct AccessLogConfig {
669 #[serde(default)]
671 pub enabled: bool,
672}
673
674#[derive(Debug, Default, Deserialize, Serialize)]
676pub struct MetricsConfig {
677 #[serde(default)]
679 pub enabled: bool,
680}
681
682#[derive(Debug, Default, Deserialize, Serialize)]
684pub struct TracingConfig {
685 #[serde(default)]
687 pub enabled: bool,
688 #[serde(default = "default_otlp_endpoint")]
690 pub endpoint: String,
691 #[serde(default = "default_service_name")]
693 pub service_name: String,
694}
695
696fn default_version() -> String {
699 "0.1.0".to_string()
700}
701
702fn default_separator() -> String {
703 "/".to_string()
704}
705
706fn default_host() -> String {
707 "127.0.0.1".to_string()
708}
709
710fn default_port() -> u16 {
711 8080
712}
713
714fn default_log_level() -> String {
715 "info".to_string()
716}
717
718fn default_failure_rate() -> f64 {
719 0.5
720}
721
722fn default_min_calls() -> usize {
723 5
724}
725
726fn default_wait_duration() -> u64 {
727 30
728}
729
730fn default_half_open_calls() -> usize {
731 3
732}
733
734fn default_rate_period() -> u64 {
735 1
736}
737
738fn default_max_retries() -> u32 {
739 3
740}
741
742fn default_initial_backoff_ms() -> u64 {
743 100
744}
745
746fn default_max_backoff_ms() -> u64 {
747 5000
748}
749
750fn default_min_retries_per_sec() -> u32 {
751 10
752}
753
754fn default_consecutive_errors() -> u32 {
755 5
756}
757
758fn default_interval_seconds() -> u64 {
759 10
760}
761
762fn default_base_ejection_seconds() -> u64 {
763 30
764}
765
766fn default_max_ejection_percent() -> u32 {
767 50
768}
769
770fn default_hedge_delay_ms() -> u64 {
771 200
772}
773
774fn default_max_hedges() -> usize {
775 1
776}
777
778fn default_mirror_percent() -> u32 {
779 100
780}
781
782fn default_weight() -> u32 {
783 100
784}
785
786fn default_max_cache_entries() -> u64 {
787 1000
788}
789
790fn default_shutdown_timeout() -> u64 {
791 30
792}
793
794fn default_otlp_endpoint() -> String {
795 "http://localhost:4317".to_string()
796}
797
798fn default_service_name() -> String {
799 "mcp-proxy".to_string()
800}
801
802#[derive(Debug, Clone)]
804pub struct BackendFilter {
805 pub namespace: String,
807 pub tool_filter: NameFilter,
809 pub resource_filter: NameFilter,
811 pub prompt_filter: NameFilter,
813 pub hide_destructive: bool,
815 pub read_only_only: bool,
817}
818
819#[derive(Debug, Clone)]
824pub enum CompiledPattern {
825 Glob(String),
827 Regex(regex::Regex),
829}
830
831impl CompiledPattern {
832 fn compile(pattern: &str) -> Result<Self> {
835 if let Some(re_pat) = pattern.strip_prefix("re:") {
836 let re = regex::Regex::new(re_pat)
837 .with_context(|| format!("invalid regex in filter pattern: {pattern}"))?;
838 Ok(Self::Regex(re))
839 } else {
840 Ok(Self::Glob(pattern.to_string()))
841 }
842 }
843
844 fn matches(&self, name: &str) -> bool {
846 match self {
847 Self::Glob(pat) => glob_match::glob_match(pat, name),
848 Self::Regex(re) => re.is_match(name),
849 }
850 }
851}
852
853#[derive(Debug, Clone)]
861pub enum NameFilter {
862 PassAll,
864 AllowList(Vec<CompiledPattern>),
866 DenyList(Vec<CompiledPattern>),
868}
869
870impl NameFilter {
871 pub fn allow_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
880 let compiled: Result<Vec<_>> = patterns
881 .into_iter()
882 .map(|p| CompiledPattern::compile(&p))
883 .collect();
884 Ok(Self::AllowList(compiled?))
885 }
886
887 pub fn deny_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
896 let compiled: Result<Vec<_>> = patterns
897 .into_iter()
898 .map(|p| CompiledPattern::compile(&p))
899 .collect();
900 Ok(Self::DenyList(compiled?))
901 }
902
903 pub fn allows(&self, name: &str) -> bool {
935 match self {
936 Self::PassAll => true,
937 Self::AllowList(patterns) => patterns.iter().any(|p| p.matches(name)),
938 Self::DenyList(patterns) => !patterns.iter().any(|p| p.matches(name)),
939 }
940 }
941}
942
943impl BackendConfig {
944 pub fn build_filter(&self, separator: &str) -> Result<Option<BackendFilter>> {
951 if self.canary_of.is_some() || self.failover_for.is_some() {
954 return Ok(Some(BackendFilter {
955 namespace: format!("{}{}", self.name, separator),
956 tool_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
957 resource_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
958 prompt_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
959 hide_destructive: false,
960 read_only_only: false,
961 }));
962 }
963
964 let tool_filter = if !self.expose_tools.is_empty() {
965 NameFilter::allow_list(self.expose_tools.iter().cloned())?
966 } else if !self.hide_tools.is_empty() {
967 NameFilter::deny_list(self.hide_tools.iter().cloned())?
968 } else {
969 NameFilter::PassAll
970 };
971
972 let resource_filter = if !self.expose_resources.is_empty() {
973 NameFilter::allow_list(self.expose_resources.iter().cloned())?
974 } else if !self.hide_resources.is_empty() {
975 NameFilter::deny_list(self.hide_resources.iter().cloned())?
976 } else {
977 NameFilter::PassAll
978 };
979
980 let prompt_filter = if !self.expose_prompts.is_empty() {
981 NameFilter::allow_list(self.expose_prompts.iter().cloned())?
982 } else if !self.hide_prompts.is_empty() {
983 NameFilter::deny_list(self.hide_prompts.iter().cloned())?
984 } else {
985 NameFilter::PassAll
986 };
987
988 if matches!(tool_filter, NameFilter::PassAll)
990 && matches!(resource_filter, NameFilter::PassAll)
991 && matches!(prompt_filter, NameFilter::PassAll)
992 && !self.hide_destructive
993 && !self.read_only_only
994 {
995 return Ok(None);
996 }
997
998 Ok(Some(BackendFilter {
999 namespace: format!("{}{}", self.name, separator),
1000 tool_filter,
1001 resource_filter,
1002 prompt_filter,
1003 hide_destructive: self.hide_destructive,
1004 read_only_only: self.read_only_only,
1005 }))
1006 }
1007}
1008
1009impl ProxyConfig {
1010 pub fn load(path: &Path) -> Result<Self> {
1015 let content =
1016 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1017
1018 let mut config: Self = match path.extension().and_then(|e| e.to_str()) {
1019 #[cfg(feature = "yaml")]
1020 Some("yaml" | "yml") => serde_yaml::from_str(&content)
1021 .with_context(|| format!("parsing YAML {}", path.display()))?,
1022 #[cfg(not(feature = "yaml"))]
1023 Some("yaml" | "yml") => {
1024 anyhow::bail!(
1025 "YAML config requires the 'yaml' feature. Rebuild with: cargo install mcp-proxy --features yaml"
1026 );
1027 }
1028 _ => toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?,
1029 };
1030
1031 if let Some(ref mcp_json_path) = config.proxy.import_backends {
1033 let mcp_path = if std::path::Path::new(mcp_json_path).is_relative() {
1034 path.parent().unwrap_or(Path::new(".")).join(mcp_json_path)
1036 } else {
1037 std::path::PathBuf::from(mcp_json_path)
1038 };
1039
1040 let mcp_json = crate::mcp_json::McpJsonConfig::load(&mcp_path)
1041 .with_context(|| format!("importing backends from {}", mcp_path.display()))?;
1042
1043 let existing_names: HashSet<String> =
1044 config.backends.iter().map(|b| b.name.clone()).collect();
1045
1046 for backend in mcp_json.into_backends()? {
1047 if !existing_names.contains(&backend.name) {
1048 config.backends.push(backend);
1049 }
1050 }
1051 }
1052
1053 config.validate()?;
1054 Ok(config)
1055 }
1056
1057 pub fn from_mcp_json(path: &Path) -> Result<Self> {
1074 let mcp_json = crate::mcp_json::McpJsonConfig::load(path)?;
1075 let backends = mcp_json.into_backends()?;
1076
1077 let name = path
1079 .parent()
1080 .and_then(|p| p.file_name())
1081 .or_else(|| path.file_stem())
1082 .map(|s| s.to_string_lossy().into_owned())
1083 .unwrap_or_else(|| "mcp-proxy".to_string());
1084
1085 let config = Self {
1086 proxy: ProxySettings {
1087 name,
1088 version: default_version(),
1089 separator: default_separator(),
1090 listen: ListenConfig {
1091 host: default_host(),
1092 port: default_port(),
1093 },
1094 instructions: None,
1095 shutdown_timeout_seconds: default_shutdown_timeout(),
1096 hot_reload: false,
1097 import_backends: None,
1098 rate_limit: None,
1099 tool_discovery: false,
1100 tool_exposure: ToolExposure::default(),
1101 },
1102 backends,
1103 auth: None,
1104 performance: PerformanceConfig::default(),
1105 security: SecurityConfig::default(),
1106 cache: CacheBackendConfig::default(),
1107 observability: ObservabilityConfig::default(),
1108 composite_tools: Vec::new(),
1109 };
1110
1111 config.validate()?;
1112 Ok(config)
1113 }
1114
1115 pub fn parse(toml: &str) -> Result<Self> {
1137 let config: Self = toml::from_str(toml).context("parsing config")?;
1138 config.validate()?;
1139 Ok(config)
1140 }
1141
1142 #[cfg(feature = "yaml")]
1164 pub fn parse_yaml(yaml: &str) -> Result<Self> {
1165 let config: Self = serde_yaml::from_str(yaml).context("parsing YAML config")?;
1166 config.validate()?;
1167 Ok(config)
1168 }
1169
1170 fn validate(&self) -> Result<()> {
1171 if self.backends.is_empty() {
1172 anyhow::bail!("at least one backend is required");
1173 }
1174
1175 match self.cache.backend.as_str() {
1177 "memory" => {}
1178 "redis" => {
1179 if self.cache.url.is_none() {
1180 anyhow::bail!(
1181 "cache.url is required when cache.backend = \"{}\"",
1182 self.cache.backend
1183 );
1184 }
1185 #[cfg(not(feature = "redis-cache"))]
1186 anyhow::bail!(
1187 "cache.backend = \"redis\" requires the 'redis-cache' feature. \
1188 Rebuild with: cargo install mcp-proxy --features redis-cache"
1189 );
1190 }
1191 "sqlite" => {
1192 if self.cache.url.is_none() {
1193 anyhow::bail!(
1194 "cache.url is required when cache.backend = \"{}\"",
1195 self.cache.backend
1196 );
1197 }
1198 #[cfg(not(feature = "sqlite-cache"))]
1199 anyhow::bail!(
1200 "cache.backend = \"sqlite\" requires the 'sqlite-cache' feature. \
1201 Rebuild with: cargo install mcp-proxy --features sqlite-cache"
1202 );
1203 }
1204 other => {
1205 anyhow::bail!(
1206 "unknown cache backend \"{}\", expected \"memory\", \"redis\", or \"sqlite\"",
1207 other
1208 );
1209 }
1210 }
1211
1212 if let Some(rl) = &self.proxy.rate_limit {
1214 if rl.requests == 0 {
1215 anyhow::bail!("proxy.rate_limit.requests must be > 0");
1216 }
1217 if rl.period_seconds == 0 {
1218 anyhow::bail!("proxy.rate_limit.period_seconds must be > 0");
1219 }
1220 }
1221
1222 if let Some(AuthConfig::Bearer {
1224 tokens,
1225 scoped_tokens,
1226 }) = &self.auth
1227 {
1228 if tokens.is_empty() && scoped_tokens.is_empty() {
1229 anyhow::bail!(
1230 "bearer auth requires at least one token in 'tokens' or 'scoped_tokens'"
1231 );
1232 }
1233 let mut seen_tokens = HashSet::new();
1235 for t in tokens {
1236 if !seen_tokens.insert(t.as_str()) {
1237 anyhow::bail!("duplicate bearer token in 'tokens'");
1238 }
1239 }
1240 for st in scoped_tokens {
1241 if !seen_tokens.insert(st.token.as_str()) {
1242 anyhow::bail!(
1243 "duplicate bearer token (appears in both 'tokens' and 'scoped_tokens' or duplicated within 'scoped_tokens')"
1244 );
1245 }
1246 if !st.allow_tools.is_empty() && !st.deny_tools.is_empty() {
1247 anyhow::bail!(
1248 "scoped_tokens: cannot specify both allow_tools and deny_tools for the same token"
1249 );
1250 }
1251 }
1252 }
1253
1254 if let Some(AuthConfig::OAuth {
1256 token_validation,
1257 client_id,
1258 client_secret,
1259 ..
1260 }) = &self.auth
1261 && matches!(
1262 token_validation,
1263 TokenValidationStrategy::Introspection | TokenValidationStrategy::Both
1264 )
1265 && (client_id.is_none() || client_secret.is_none())
1266 {
1267 anyhow::bail!("OAuth introspection requires both 'client_id' and 'client_secret'");
1268 }
1269
1270 let mut seen_names = HashSet::new();
1272 for backend in &self.backends {
1273 if !seen_names.insert(&backend.name) {
1274 anyhow::bail!("duplicate backend name '{}'", backend.name);
1275 }
1276 }
1277
1278 for backend in &self.backends {
1279 match backend.transport {
1280 TransportType::Stdio => {
1281 if backend.command.is_none() {
1282 anyhow::bail!(
1283 "backend '{}': stdio transport requires 'command'",
1284 backend.name
1285 );
1286 }
1287 }
1288 TransportType::Http => {
1289 if backend.url.is_none() {
1290 anyhow::bail!("backend '{}': http transport requires 'url'", backend.name);
1291 }
1292 }
1293 TransportType::Websocket => {
1294 if backend.url.is_none() {
1295 anyhow::bail!(
1296 "backend '{}': websocket transport requires 'url'",
1297 backend.name
1298 );
1299 }
1300 }
1301 }
1302
1303 if let Some(cb) = &backend.circuit_breaker
1304 && (cb.failure_rate_threshold <= 0.0 || cb.failure_rate_threshold > 1.0)
1305 {
1306 anyhow::bail!(
1307 "backend '{}': circuit_breaker.failure_rate_threshold must be in (0.0, 1.0]",
1308 backend.name
1309 );
1310 }
1311
1312 if let Some(rl) = &backend.rate_limit
1313 && rl.requests == 0
1314 {
1315 anyhow::bail!(
1316 "backend '{}': rate_limit.requests must be > 0",
1317 backend.name
1318 );
1319 }
1320
1321 if let Some(cc) = &backend.concurrency
1322 && cc.max_concurrent == 0
1323 {
1324 anyhow::bail!(
1325 "backend '{}': concurrency.max_concurrent must be > 0",
1326 backend.name
1327 );
1328 }
1329
1330 if !backend.expose_tools.is_empty() && !backend.hide_tools.is_empty() {
1331 anyhow::bail!(
1332 "backend '{}': cannot specify both expose_tools and hide_tools",
1333 backend.name
1334 );
1335 }
1336 if !backend.expose_resources.is_empty() && !backend.hide_resources.is_empty() {
1337 anyhow::bail!(
1338 "backend '{}': cannot specify both expose_resources and hide_resources",
1339 backend.name
1340 );
1341 }
1342 if !backend.expose_prompts.is_empty() && !backend.hide_prompts.is_empty() {
1343 anyhow::bail!(
1344 "backend '{}': cannot specify both expose_prompts and hide_prompts",
1345 backend.name
1346 );
1347 }
1348 }
1349
1350 let backend_names: HashSet<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
1352 for backend in &self.backends {
1353 if let Some(ref source) = backend.mirror_of {
1354 if !backend_names.contains(source.as_str()) {
1355 anyhow::bail!(
1356 "backend '{}': mirror_of references unknown backend '{}'",
1357 backend.name,
1358 source
1359 );
1360 }
1361 if source == &backend.name {
1362 anyhow::bail!(
1363 "backend '{}': mirror_of cannot reference itself",
1364 backend.name
1365 );
1366 }
1367 }
1368 }
1369
1370 for backend in &self.backends {
1372 if let Some(ref primary) = backend.failover_for {
1373 if !backend_names.contains(primary.as_str()) {
1374 anyhow::bail!(
1375 "backend '{}': failover_for references unknown backend '{}'",
1376 backend.name,
1377 primary
1378 );
1379 }
1380 if primary == &backend.name {
1381 anyhow::bail!(
1382 "backend '{}': failover_for cannot reference itself",
1383 backend.name
1384 );
1385 }
1386 }
1387 }
1388
1389 {
1391 let mut composite_names = HashSet::new();
1392 for ct in &self.composite_tools {
1393 if ct.name.is_empty() {
1394 anyhow::bail!("composite_tools: name must not be empty");
1395 }
1396 if ct.tools.is_empty() {
1397 anyhow::bail!(
1398 "composite_tools '{}': must reference at least one tool",
1399 ct.name
1400 );
1401 }
1402 if !composite_names.insert(&ct.name) {
1403 anyhow::bail!("duplicate composite_tools name '{}'", ct.name);
1404 }
1405 }
1406 }
1407
1408 for backend in &self.backends {
1410 if let Some(ref primary) = backend.canary_of {
1411 if !backend_names.contains(primary.as_str()) {
1412 anyhow::bail!(
1413 "backend '{}': canary_of references unknown backend '{}'",
1414 backend.name,
1415 primary
1416 );
1417 }
1418 if primary == &backend.name {
1419 anyhow::bail!(
1420 "backend '{}': canary_of cannot reference itself",
1421 backend.name
1422 );
1423 }
1424 if backend.weight == 0 {
1425 anyhow::bail!("backend '{}': weight must be > 0", backend.name);
1426 }
1427 }
1428 }
1429
1430 #[cfg(not(feature = "discovery"))]
1432 if self.proxy.tool_exposure == ToolExposure::Search {
1433 anyhow::bail!(
1434 "tool_exposure = \"search\" requires the 'discovery' feature. \
1435 Rebuild with: cargo install mcp-proxy --features discovery"
1436 );
1437 }
1438
1439 for backend in &self.backends {
1441 let mut seen_tools = HashSet::new();
1442 for po in &backend.param_overrides {
1443 if po.tool.is_empty() {
1444 anyhow::bail!(
1445 "backend '{}': param_overrides.tool must not be empty",
1446 backend.name
1447 );
1448 }
1449 if !seen_tools.insert(&po.tool) {
1450 anyhow::bail!(
1451 "backend '{}': duplicate param_overrides for tool '{}'",
1452 backend.name,
1453 po.tool
1454 );
1455 }
1456 for hidden in &po.hide {
1459 if po.rename.contains_key(hidden) {
1460 anyhow::bail!(
1461 "backend '{}': param_overrides for tool '{}': \
1462 parameter '{}' cannot be both hidden and renamed",
1463 backend.name,
1464 po.tool,
1465 hidden
1466 );
1467 }
1468 }
1469 let mut rename_targets = HashSet::new();
1471 for target in po.rename.values() {
1472 if !rename_targets.insert(target) {
1473 anyhow::bail!(
1474 "backend '{}': param_overrides for tool '{}': \
1475 duplicate rename target '{}'",
1476 backend.name,
1477 po.tool,
1478 target
1479 );
1480 }
1481 }
1482 }
1483 }
1484
1485 Ok(())
1486 }
1487
1488 pub fn resolve_env_vars(&mut self) {
1491 for backend in &mut self.backends {
1492 for value in backend.env.values_mut() {
1493 if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1494 && let Ok(env_val) = std::env::var(var_name)
1495 {
1496 *value = env_val;
1497 }
1498 }
1499 if let Some(ref mut token) = backend.bearer_token
1500 && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1501 && let Ok(env_val) = std::env::var(var_name)
1502 {
1503 *token = env_val;
1504 }
1505 }
1506
1507 if let Some(AuthConfig::Bearer {
1509 tokens,
1510 scoped_tokens,
1511 }) = &mut self.auth
1512 {
1513 for token in tokens.iter_mut() {
1514 if let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1515 && let Ok(env_val) = std::env::var(var_name)
1516 {
1517 *token = env_val;
1518 }
1519 }
1520 for st in scoped_tokens.iter_mut() {
1521 if let Some(var_name) = st
1522 .token
1523 .strip_prefix("${")
1524 .and_then(|s| s.strip_suffix('}'))
1525 && let Ok(env_val) = std::env::var(var_name)
1526 {
1527 st.token = env_val;
1528 }
1529 }
1530 }
1531
1532 if let Some(AuthConfig::OAuth { client_secret, .. }) = &mut self.auth
1534 && let Some(secret) = client_secret
1535 && let Some(var_name) = secret.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1536 && let Ok(env_val) = std::env::var(var_name)
1537 {
1538 *secret = env_val;
1539 }
1540 }
1541
1542 pub fn check_env_vars(&self) -> Vec<String> {
1569 fn is_unset_env_ref(value: &str) -> Option<&str> {
1570 let var_name = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))?;
1571 if std::env::var(var_name).is_err() {
1572 Some(var_name)
1573 } else {
1574 None
1575 }
1576 }
1577
1578 let mut warnings = Vec::new();
1579
1580 for backend in &self.backends {
1581 if let Some(ref token) = backend.bearer_token
1583 && let Some(var) = is_unset_env_ref(token)
1584 {
1585 warnings.push(format!(
1586 "backend '{}': bearer_token references unset env var '{}'",
1587 backend.name, var
1588 ));
1589 }
1590 for (key, value) in &backend.env {
1592 if let Some(var) = is_unset_env_ref(value) {
1593 warnings.push(format!(
1594 "backend '{}': env.{} references unset env var '{}'",
1595 backend.name, key, var
1596 ));
1597 }
1598 }
1599 }
1600
1601 match &self.auth {
1602 Some(AuthConfig::Bearer {
1603 tokens,
1604 scoped_tokens,
1605 }) => {
1606 for (i, token) in tokens.iter().enumerate() {
1607 if let Some(var) = is_unset_env_ref(token) {
1608 warnings.push(format!(
1609 "auth.bearer: tokens[{}] references unset env var '{}'",
1610 i, var
1611 ));
1612 }
1613 }
1614 for (i, st) in scoped_tokens.iter().enumerate() {
1615 if let Some(var) = is_unset_env_ref(&st.token) {
1616 warnings.push(format!(
1617 "auth.bearer: scoped_tokens[{}] references unset env var '{}'",
1618 i, var
1619 ));
1620 }
1621 }
1622 }
1623 Some(AuthConfig::OAuth {
1624 client_secret: Some(secret),
1625 ..
1626 }) => {
1627 if let Some(var) = is_unset_env_ref(secret) {
1628 warnings.push(format!(
1629 "auth.oauth: client_secret references unset env var '{}'",
1630 var
1631 ));
1632 }
1633 }
1634 _ => {}
1635 }
1636
1637 warnings
1638 }
1639}
1640
1641#[cfg(test)]
1642mod tests {
1643 use super::*;
1644
1645 fn minimal_config() -> &'static str {
1646 r#"
1647 [proxy]
1648 name = "test"
1649 [proxy.listen]
1650
1651 [[backends]]
1652 name = "echo"
1653 transport = "stdio"
1654 command = "echo"
1655 "#
1656 }
1657
1658 #[test]
1659 fn test_parse_minimal_config() {
1660 let config = ProxyConfig::parse(minimal_config()).unwrap();
1661 assert_eq!(config.proxy.name, "test");
1662 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);
1669 assert_eq!(config.backends[0].name, "echo");
1670 assert!(config.auth.is_none());
1671 assert!(!config.observability.audit);
1672 assert!(!config.observability.metrics.enabled);
1673 }
1674
1675 #[test]
1676 fn test_parse_full_config() {
1677 let toml = r#"
1678 [proxy]
1679 name = "full-gw"
1680 version = "2.0.0"
1681 separator = "."
1682 shutdown_timeout_seconds = 60
1683 hot_reload = true
1684 instructions = "A test proxy"
1685 [proxy.listen]
1686 host = "0.0.0.0"
1687 port = 9090
1688
1689 [[backends]]
1690 name = "files"
1691 transport = "stdio"
1692 command = "file-server"
1693 args = ["--root", "/tmp"]
1694 expose_tools = ["read_file"]
1695
1696 [backends.env]
1697 LOG_LEVEL = "debug"
1698
1699 [backends.timeout]
1700 seconds = 30
1701
1702 [backends.concurrency]
1703 max_concurrent = 5
1704
1705 [backends.rate_limit]
1706 requests = 100
1707 period_seconds = 10
1708
1709 [backends.circuit_breaker]
1710 failure_rate_threshold = 0.5
1711 minimum_calls = 10
1712 wait_duration_seconds = 60
1713 permitted_calls_in_half_open = 2
1714
1715 [backends.cache]
1716 resource_ttl_seconds = 300
1717 tool_ttl_seconds = 60
1718 max_entries = 500
1719
1720 [[backends.aliases]]
1721 from = "read_file"
1722 to = "read"
1723
1724 [[backends]]
1725 name = "remote"
1726 transport = "http"
1727 url = "http://localhost:3000"
1728
1729 [observability]
1730 audit = true
1731 log_level = "debug"
1732 json_logs = true
1733
1734 [observability.metrics]
1735 enabled = true
1736
1737 [observability.tracing]
1738 enabled = true
1739 endpoint = "http://jaeger:4317"
1740 service_name = "test-gw"
1741
1742 [performance]
1743 coalesce_requests = true
1744
1745 [security]
1746 max_argument_size = 1048576
1747 "#;
1748
1749 let config = ProxyConfig::parse(toml).unwrap();
1750 assert_eq!(config.proxy.name, "full-gw");
1751 assert_eq!(config.proxy.version, "2.0.0");
1752 assert_eq!(config.proxy.separator, ".");
1753 assert_eq!(config.proxy.shutdown_timeout_seconds, 60);
1754 assert!(config.proxy.hot_reload);
1755 assert_eq!(config.proxy.instructions.as_deref(), Some("A test proxy"));
1756 assert_eq!(config.proxy.listen.host, "0.0.0.0");
1757 assert_eq!(config.proxy.listen.port, 9090);
1758
1759 assert_eq!(config.backends.len(), 2);
1760
1761 let files = &config.backends[0];
1762 assert_eq!(files.command.as_deref(), Some("file-server"));
1763 assert_eq!(files.args, vec!["--root", "/tmp"]);
1764 assert_eq!(files.expose_tools, vec!["read_file"]);
1765 assert_eq!(files.env.get("LOG_LEVEL").unwrap(), "debug");
1766 assert_eq!(files.timeout.as_ref().unwrap().seconds, 30);
1767 assert_eq!(files.concurrency.as_ref().unwrap().max_concurrent, 5);
1768 assert_eq!(files.rate_limit.as_ref().unwrap().requests, 100);
1769 assert_eq!(files.cache.as_ref().unwrap().resource_ttl_seconds, 300);
1770 assert_eq!(files.cache.as_ref().unwrap().tool_ttl_seconds, 60);
1771 assert_eq!(files.cache.as_ref().unwrap().max_entries, 500);
1772 assert_eq!(files.aliases.len(), 1);
1773 assert_eq!(files.aliases[0].from, "read_file");
1774 assert_eq!(files.aliases[0].to, "read");
1775
1776 let cb = files.circuit_breaker.as_ref().unwrap();
1777 assert_eq!(cb.failure_rate_threshold, 0.5);
1778 assert_eq!(cb.minimum_calls, 10);
1779 assert_eq!(cb.wait_duration_seconds, 60);
1780 assert_eq!(cb.permitted_calls_in_half_open, 2);
1781
1782 let remote = &config.backends[1];
1783 assert_eq!(remote.url.as_deref(), Some("http://localhost:3000"));
1784
1785 assert!(config.observability.audit);
1786 assert_eq!(config.observability.log_level, "debug");
1787 assert!(config.observability.json_logs);
1788 assert!(config.observability.metrics.enabled);
1789 assert!(config.observability.tracing.enabled);
1790 assert_eq!(config.observability.tracing.endpoint, "http://jaeger:4317");
1791
1792 assert!(config.performance.coalesce_requests);
1793 assert_eq!(config.security.max_argument_size, Some(1048576));
1794 }
1795
1796 #[test]
1797 fn test_parse_bearer_auth() {
1798 let toml = r#"
1799 [proxy]
1800 name = "auth-gw"
1801 [proxy.listen]
1802
1803 [[backends]]
1804 name = "echo"
1805 transport = "stdio"
1806 command = "echo"
1807
1808 [auth]
1809 type = "bearer"
1810 tokens = ["token-1", "token-2"]
1811 "#;
1812
1813 let config = ProxyConfig::parse(toml).unwrap();
1814 match &config.auth {
1815 Some(AuthConfig::Bearer { tokens, .. }) => {
1816 assert_eq!(tokens, &["token-1", "token-2"]);
1817 }
1818 other => panic!("expected Bearer auth, got: {:?}", other),
1819 }
1820 }
1821
1822 #[test]
1823 fn test_parse_jwt_auth_with_rbac() {
1824 let toml = r#"
1825 [proxy]
1826 name = "jwt-gw"
1827 [proxy.listen]
1828
1829 [[backends]]
1830 name = "echo"
1831 transport = "stdio"
1832 command = "echo"
1833
1834 [auth]
1835 type = "jwt"
1836 issuer = "https://auth.example.com"
1837 audience = "mcp-proxy"
1838 jwks_uri = "https://auth.example.com/.well-known/jwks.json"
1839
1840 [[auth.roles]]
1841 name = "reader"
1842 allow_tools = ["echo/read"]
1843
1844 [[auth.roles]]
1845 name = "admin"
1846
1847 [auth.role_mapping]
1848 claim = "scope"
1849 mapping = { "mcp:read" = "reader", "mcp:admin" = "admin" }
1850 "#;
1851
1852 let config = ProxyConfig::parse(toml).unwrap();
1853 match &config.auth {
1854 Some(AuthConfig::Jwt {
1855 issuer,
1856 audience,
1857 jwks_uri,
1858 roles,
1859 role_mapping,
1860 }) => {
1861 assert_eq!(issuer, "https://auth.example.com");
1862 assert_eq!(audience, "mcp-proxy");
1863 assert_eq!(jwks_uri, "https://auth.example.com/.well-known/jwks.json");
1864 assert_eq!(roles.len(), 2);
1865 assert_eq!(roles[0].name, "reader");
1866 assert_eq!(roles[0].allow_tools, vec!["echo/read"]);
1867 let mapping = role_mapping.as_ref().unwrap();
1868 assert_eq!(mapping.claim, "scope");
1869 assert_eq!(mapping.mapping.get("mcp:read").unwrap(), "reader");
1870 }
1871 other => panic!("expected Jwt auth, got: {:?}", other),
1872 }
1873 }
1874
1875 #[test]
1880 fn test_reject_no_backends() {
1881 let toml = r#"
1882 [proxy]
1883 name = "empty"
1884 [proxy.listen]
1885 "#;
1886
1887 let err = ProxyConfig::parse(toml).unwrap_err();
1888 assert!(
1889 format!("{err}").contains("at least one backend"),
1890 "unexpected error: {err}"
1891 );
1892 }
1893
1894 #[test]
1895 fn test_reject_stdio_without_command() {
1896 let toml = r#"
1897 [proxy]
1898 name = "bad"
1899 [proxy.listen]
1900
1901 [[backends]]
1902 name = "broken"
1903 transport = "stdio"
1904 "#;
1905
1906 let err = ProxyConfig::parse(toml).unwrap_err();
1907 assert!(
1908 format!("{err}").contains("stdio transport requires 'command'"),
1909 "unexpected error: {err}"
1910 );
1911 }
1912
1913 #[test]
1914 fn test_reject_http_without_url() {
1915 let toml = r#"
1916 [proxy]
1917 name = "bad"
1918 [proxy.listen]
1919
1920 [[backends]]
1921 name = "broken"
1922 transport = "http"
1923 "#;
1924
1925 let err = ProxyConfig::parse(toml).unwrap_err();
1926 assert!(
1927 format!("{err}").contains("http transport requires 'url'"),
1928 "unexpected error: {err}"
1929 );
1930 }
1931
1932 #[test]
1933 fn test_reject_invalid_circuit_breaker_threshold() {
1934 let toml = r#"
1935 [proxy]
1936 name = "bad"
1937 [proxy.listen]
1938
1939 [[backends]]
1940 name = "svc"
1941 transport = "stdio"
1942 command = "echo"
1943
1944 [backends.circuit_breaker]
1945 failure_rate_threshold = 1.5
1946 "#;
1947
1948 let err = ProxyConfig::parse(toml).unwrap_err();
1949 assert!(
1950 format!("{err}").contains("failure_rate_threshold must be in (0.0, 1.0]"),
1951 "unexpected error: {err}"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_reject_zero_rate_limit() {
1957 let toml = r#"
1958 [proxy]
1959 name = "bad"
1960 [proxy.listen]
1961
1962 [[backends]]
1963 name = "svc"
1964 transport = "stdio"
1965 command = "echo"
1966
1967 [backends.rate_limit]
1968 requests = 0
1969 "#;
1970
1971 let err = ProxyConfig::parse(toml).unwrap_err();
1972 assert!(
1973 format!("{err}").contains("rate_limit.requests must be > 0"),
1974 "unexpected error: {err}"
1975 );
1976 }
1977
1978 #[test]
1979 fn test_reject_zero_concurrency() {
1980 let toml = r#"
1981 [proxy]
1982 name = "bad"
1983 [proxy.listen]
1984
1985 [[backends]]
1986 name = "svc"
1987 transport = "stdio"
1988 command = "echo"
1989
1990 [backends.concurrency]
1991 max_concurrent = 0
1992 "#;
1993
1994 let err = ProxyConfig::parse(toml).unwrap_err();
1995 assert!(
1996 format!("{err}").contains("concurrency.max_concurrent must be > 0"),
1997 "unexpected error: {err}"
1998 );
1999 }
2000
2001 #[test]
2002 fn test_reject_expose_and_hide_tools() {
2003 let toml = r#"
2004 [proxy]
2005 name = "bad"
2006 [proxy.listen]
2007
2008 [[backends]]
2009 name = "svc"
2010 transport = "stdio"
2011 command = "echo"
2012 expose_tools = ["read"]
2013 hide_tools = ["write"]
2014 "#;
2015
2016 let err = ProxyConfig::parse(toml).unwrap_err();
2017 assert!(
2018 format!("{err}").contains("cannot specify both expose_tools and hide_tools"),
2019 "unexpected error: {err}"
2020 );
2021 }
2022
2023 #[test]
2024 fn test_reject_expose_and_hide_resources() {
2025 let toml = r#"
2026 [proxy]
2027 name = "bad"
2028 [proxy.listen]
2029
2030 [[backends]]
2031 name = "svc"
2032 transport = "stdio"
2033 command = "echo"
2034 expose_resources = ["file:///a"]
2035 hide_resources = ["file:///b"]
2036 "#;
2037
2038 let err = ProxyConfig::parse(toml).unwrap_err();
2039 assert!(
2040 format!("{err}").contains("cannot specify both expose_resources and hide_resources"),
2041 "unexpected error: {err}"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_reject_expose_and_hide_prompts() {
2047 let toml = r#"
2048 [proxy]
2049 name = "bad"
2050 [proxy.listen]
2051
2052 [[backends]]
2053 name = "svc"
2054 transport = "stdio"
2055 command = "echo"
2056 expose_prompts = ["help"]
2057 hide_prompts = ["admin"]
2058 "#;
2059
2060 let err = ProxyConfig::parse(toml).unwrap_err();
2061 assert!(
2062 format!("{err}").contains("cannot specify both expose_prompts and hide_prompts"),
2063 "unexpected error: {err}"
2064 );
2065 }
2066
2067 #[test]
2072 fn test_resolve_env_vars() {
2073 unsafe { std::env::set_var("MCP_GW_TEST_TOKEN", "secret-123") };
2075
2076 let toml = r#"
2077 [proxy]
2078 name = "env-test"
2079 [proxy.listen]
2080
2081 [[backends]]
2082 name = "svc"
2083 transport = "stdio"
2084 command = "echo"
2085
2086 [backends.env]
2087 API_TOKEN = "${MCP_GW_TEST_TOKEN}"
2088 STATIC_VAL = "unchanged"
2089 "#;
2090
2091 let mut config = ProxyConfig::parse(toml).unwrap();
2092 config.resolve_env_vars();
2093
2094 assert_eq!(
2095 config.backends[0].env.get("API_TOKEN").unwrap(),
2096 "secret-123"
2097 );
2098 assert_eq!(
2099 config.backends[0].env.get("STATIC_VAL").unwrap(),
2100 "unchanged"
2101 );
2102
2103 unsafe { std::env::remove_var("MCP_GW_TEST_TOKEN") };
2105 }
2106
2107 #[test]
2108 fn test_parse_bearer_token_and_forward_auth() {
2109 let toml = r#"
2110 [proxy]
2111 name = "token-gw"
2112 [proxy.listen]
2113
2114 [[backends]]
2115 name = "github"
2116 transport = "http"
2117 url = "http://localhost:3000"
2118 bearer_token = "ghp_abc123"
2119 forward_auth = true
2120
2121 [[backends]]
2122 name = "db"
2123 transport = "http"
2124 url = "http://localhost:5432"
2125 "#;
2126
2127 let config = ProxyConfig::parse(toml).unwrap();
2128 assert_eq!(
2129 config.backends[0].bearer_token.as_deref(),
2130 Some("ghp_abc123")
2131 );
2132 assert!(config.backends[0].forward_auth);
2133 assert!(config.backends[1].bearer_token.is_none());
2134 assert!(!config.backends[1].forward_auth);
2135 }
2136
2137 #[test]
2138 fn test_resolve_bearer_token_env_var() {
2139 unsafe { std::env::set_var("MCP_GW_TEST_BEARER", "resolved-token") };
2140
2141 let toml = r#"
2142 [proxy]
2143 name = "env-token"
2144 [proxy.listen]
2145
2146 [[backends]]
2147 name = "api"
2148 transport = "http"
2149 url = "http://localhost:3000"
2150 bearer_token = "${MCP_GW_TEST_BEARER}"
2151 "#;
2152
2153 let mut config = ProxyConfig::parse(toml).unwrap();
2154 config.resolve_env_vars();
2155
2156 assert_eq!(
2157 config.backends[0].bearer_token.as_deref(),
2158 Some("resolved-token")
2159 );
2160
2161 unsafe { std::env::remove_var("MCP_GW_TEST_BEARER") };
2162 }
2163
2164 #[test]
2165 fn test_parse_outlier_detection() {
2166 let toml = r#"
2167 [proxy]
2168 name = "od-gw"
2169 [proxy.listen]
2170
2171 [[backends]]
2172 name = "flaky"
2173 transport = "http"
2174 url = "http://localhost:8080"
2175
2176 [backends.outlier_detection]
2177 consecutive_errors = 3
2178 interval_seconds = 5
2179 base_ejection_seconds = 60
2180 max_ejection_percent = 25
2181 "#;
2182
2183 let config = ProxyConfig::parse(toml).unwrap();
2184 let od = config.backends[0]
2185 .outlier_detection
2186 .as_ref()
2187 .expect("should have outlier_detection");
2188 assert_eq!(od.consecutive_errors, 3);
2189 assert_eq!(od.interval_seconds, 5);
2190 assert_eq!(od.base_ejection_seconds, 60);
2191 assert_eq!(od.max_ejection_percent, 25);
2192 }
2193
2194 #[test]
2195 fn test_parse_outlier_detection_defaults() {
2196 let toml = r#"
2197 [proxy]
2198 name = "od-gw"
2199 [proxy.listen]
2200
2201 [[backends]]
2202 name = "flaky"
2203 transport = "http"
2204 url = "http://localhost:8080"
2205
2206 [backends.outlier_detection]
2207 "#;
2208
2209 let config = ProxyConfig::parse(toml).unwrap();
2210 let od = config.backends[0]
2211 .outlier_detection
2212 .as_ref()
2213 .expect("should have outlier_detection");
2214 assert_eq!(od.consecutive_errors, 5);
2215 assert_eq!(od.interval_seconds, 10);
2216 assert_eq!(od.base_ejection_seconds, 30);
2217 assert_eq!(od.max_ejection_percent, 50);
2218 }
2219
2220 #[test]
2221 fn test_parse_mirror_config() {
2222 let toml = r#"
2223 [proxy]
2224 name = "mirror-gw"
2225 [proxy.listen]
2226
2227 [[backends]]
2228 name = "api"
2229 transport = "http"
2230 url = "http://localhost:8080"
2231
2232 [[backends]]
2233 name = "api-v2"
2234 transport = "http"
2235 url = "http://localhost:8081"
2236 mirror_of = "api"
2237 mirror_percent = 10
2238 "#;
2239
2240 let config = ProxyConfig::parse(toml).unwrap();
2241 assert!(config.backends[0].mirror_of.is_none());
2242 assert_eq!(config.backends[1].mirror_of.as_deref(), Some("api"));
2243 assert_eq!(config.backends[1].mirror_percent, 10);
2244 }
2245
2246 #[test]
2247 fn test_mirror_percent_defaults_to_100() {
2248 let toml = r#"
2249 [proxy]
2250 name = "mirror-gw"
2251 [proxy.listen]
2252
2253 [[backends]]
2254 name = "api"
2255 transport = "http"
2256 url = "http://localhost:8080"
2257
2258 [[backends]]
2259 name = "api-v2"
2260 transport = "http"
2261 url = "http://localhost:8081"
2262 mirror_of = "api"
2263 "#;
2264
2265 let config = ProxyConfig::parse(toml).unwrap();
2266 assert_eq!(config.backends[1].mirror_percent, 100);
2267 }
2268
2269 #[test]
2270 fn test_reject_mirror_unknown_backend() {
2271 let toml = r#"
2272 [proxy]
2273 name = "bad"
2274 [proxy.listen]
2275
2276 [[backends]]
2277 name = "api-v2"
2278 transport = "http"
2279 url = "http://localhost:8081"
2280 mirror_of = "nonexistent"
2281 "#;
2282
2283 let err = ProxyConfig::parse(toml).unwrap_err();
2284 assert!(
2285 format!("{err}").contains("mirror_of references unknown backend"),
2286 "unexpected error: {err}"
2287 );
2288 }
2289
2290 #[test]
2291 fn test_reject_mirror_self() {
2292 let toml = r#"
2293 [proxy]
2294 name = "bad"
2295 [proxy.listen]
2296
2297 [[backends]]
2298 name = "api"
2299 transport = "http"
2300 url = "http://localhost:8080"
2301 mirror_of = "api"
2302 "#;
2303
2304 let err = ProxyConfig::parse(toml).unwrap_err();
2305 assert!(
2306 format!("{err}").contains("mirror_of cannot reference itself"),
2307 "unexpected error: {err}"
2308 );
2309 }
2310
2311 #[test]
2312 fn test_parse_hedging_config() {
2313 let toml = r#"
2314 [proxy]
2315 name = "hedge-gw"
2316 [proxy.listen]
2317
2318 [[backends]]
2319 name = "api"
2320 transport = "http"
2321 url = "http://localhost:8080"
2322
2323 [backends.hedging]
2324 delay_ms = 150
2325 max_hedges = 2
2326 "#;
2327
2328 let config = ProxyConfig::parse(toml).unwrap();
2329 let hedge = config.backends[0]
2330 .hedging
2331 .as_ref()
2332 .expect("should have hedging");
2333 assert_eq!(hedge.delay_ms, 150);
2334 assert_eq!(hedge.max_hedges, 2);
2335 }
2336
2337 #[test]
2338 fn test_parse_hedging_defaults() {
2339 let toml = r#"
2340 [proxy]
2341 name = "hedge-gw"
2342 [proxy.listen]
2343
2344 [[backends]]
2345 name = "api"
2346 transport = "http"
2347 url = "http://localhost:8080"
2348
2349 [backends.hedging]
2350 "#;
2351
2352 let config = ProxyConfig::parse(toml).unwrap();
2353 let hedge = config.backends[0]
2354 .hedging
2355 .as_ref()
2356 .expect("should have hedging");
2357 assert_eq!(hedge.delay_ms, 200);
2358 assert_eq!(hedge.max_hedges, 1);
2359 }
2360
2361 #[test]
2366 fn test_build_filter_allowlist() {
2367 let toml = r#"
2368 [proxy]
2369 name = "filter"
2370 [proxy.listen]
2371
2372 [[backends]]
2373 name = "svc"
2374 transport = "stdio"
2375 command = "echo"
2376 expose_tools = ["read", "list"]
2377 "#;
2378
2379 let config = ProxyConfig::parse(toml).unwrap();
2380 let filter = config.backends[0]
2381 .build_filter(&config.proxy.separator)
2382 .unwrap()
2383 .expect("should have filter");
2384 assert_eq!(filter.namespace, "svc/");
2385 assert!(filter.tool_filter.allows("read"));
2386 assert!(filter.tool_filter.allows("list"));
2387 assert!(!filter.tool_filter.allows("delete"));
2388 }
2389
2390 #[test]
2391 fn test_build_filter_denylist() {
2392 let toml = r#"
2393 [proxy]
2394 name = "filter"
2395 [proxy.listen]
2396
2397 [[backends]]
2398 name = "svc"
2399 transport = "stdio"
2400 command = "echo"
2401 hide_tools = ["delete", "write"]
2402 "#;
2403
2404 let config = ProxyConfig::parse(toml).unwrap();
2405 let filter = config.backends[0]
2406 .build_filter(&config.proxy.separator)
2407 .unwrap()
2408 .expect("should have filter");
2409 assert!(filter.tool_filter.allows("read"));
2410 assert!(!filter.tool_filter.allows("delete"));
2411 assert!(!filter.tool_filter.allows("write"));
2412 }
2413
2414 #[test]
2415 fn test_parse_inject_args() {
2416 let toml = r#"
2417 [proxy]
2418 name = "inject-gw"
2419 [proxy.listen]
2420
2421 [[backends]]
2422 name = "db"
2423 transport = "http"
2424 url = "http://localhost:8080"
2425
2426 [backends.default_args]
2427 timeout = 30
2428
2429 [[backends.inject_args]]
2430 tool = "query"
2431 args = { read_only = true, max_rows = 1000 }
2432
2433 [[backends.inject_args]]
2434 tool = "dangerous_op"
2435 args = { dry_run = true }
2436 overwrite = true
2437 "#;
2438
2439 let config = ProxyConfig::parse(toml).unwrap();
2440 let backend = &config.backends[0];
2441
2442 assert_eq!(backend.default_args.len(), 1);
2443 assert_eq!(backend.default_args["timeout"], 30);
2444
2445 assert_eq!(backend.inject_args.len(), 2);
2446 assert_eq!(backend.inject_args[0].tool, "query");
2447 assert_eq!(backend.inject_args[0].args["read_only"], true);
2448 assert_eq!(backend.inject_args[0].args["max_rows"], 1000);
2449 assert!(!backend.inject_args[0].overwrite);
2450
2451 assert_eq!(backend.inject_args[1].tool, "dangerous_op");
2452 assert_eq!(backend.inject_args[1].args["dry_run"], true);
2453 assert!(backend.inject_args[1].overwrite);
2454 }
2455
2456 #[test]
2457 fn test_parse_inject_args_defaults_to_empty() {
2458 let config = ProxyConfig::parse(minimal_config()).unwrap();
2459 assert!(config.backends[0].default_args.is_empty());
2460 assert!(config.backends[0].inject_args.is_empty());
2461 }
2462
2463 #[test]
2464 fn test_build_filter_none_when_no_filtering() {
2465 let config = ProxyConfig::parse(minimal_config()).unwrap();
2466 assert!(
2467 config.backends[0]
2468 .build_filter(&config.proxy.separator)
2469 .unwrap()
2470 .is_none()
2471 );
2472 }
2473
2474 #[test]
2475 fn test_validate_rejects_duplicate_backend_names() {
2476 let toml = r#"
2477 [proxy]
2478 name = "test"
2479 [proxy.listen]
2480
2481 [[backends]]
2482 name = "echo"
2483 transport = "stdio"
2484 command = "echo"
2485
2486 [[backends]]
2487 name = "echo"
2488 transport = "stdio"
2489 command = "cat"
2490 "#;
2491 let err = ProxyConfig::parse(toml).unwrap_err();
2492 assert!(
2493 err.to_string().contains("duplicate backend name"),
2494 "expected duplicate error, got: {}",
2495 err
2496 );
2497 }
2498
2499 #[test]
2500 fn test_validate_global_rate_limit_zero_requests() {
2501 let toml = r#"
2502 [proxy]
2503 name = "test"
2504 [proxy.listen]
2505 [proxy.rate_limit]
2506 requests = 0
2507
2508 [[backends]]
2509 name = "echo"
2510 transport = "stdio"
2511 command = "echo"
2512 "#;
2513 let err = ProxyConfig::parse(toml).unwrap_err();
2514 assert!(err.to_string().contains("requests must be > 0"));
2515 }
2516
2517 #[test]
2518 fn test_parse_global_rate_limit() {
2519 let toml = r#"
2520 [proxy]
2521 name = "test"
2522 [proxy.listen]
2523 [proxy.rate_limit]
2524 requests = 500
2525 period_seconds = 1
2526
2527 [[backends]]
2528 name = "echo"
2529 transport = "stdio"
2530 command = "echo"
2531 "#;
2532 let config = ProxyConfig::parse(toml).unwrap();
2533 let rl = config.proxy.rate_limit.unwrap();
2534 assert_eq!(rl.requests, 500);
2535 assert_eq!(rl.period_seconds, 1);
2536 }
2537
2538 #[test]
2539 fn test_name_filter_glob_wildcard() {
2540 let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
2541 assert!(filter.allows("read_file"));
2542 assert!(filter.allows("write_file"));
2543 assert!(!filter.allows("query"));
2544 assert!(!filter.allows("file_read"));
2545 }
2546
2547 #[test]
2548 fn test_name_filter_glob_prefix() {
2549 let filter = NameFilter::allow_list(["list_*".to_string()]).unwrap();
2550 assert!(filter.allows("list_files"));
2551 assert!(filter.allows("list_users"));
2552 assert!(!filter.allows("get_files"));
2553 }
2554
2555 #[test]
2556 fn test_name_filter_glob_question_mark() {
2557 let filter = NameFilter::allow_list(["get_?".to_string()]).unwrap();
2558 assert!(filter.allows("get_a"));
2559 assert!(filter.allows("get_1"));
2560 assert!(!filter.allows("get_ab"));
2561 assert!(!filter.allows("get_"));
2562 }
2563
2564 #[test]
2565 fn test_name_filter_glob_deny_list() {
2566 let filter = NameFilter::deny_list(["*_delete*".to_string()]).unwrap();
2567 assert!(filter.allows("read_file"));
2568 assert!(filter.allows("create_issue"));
2569 assert!(!filter.allows("force_delete_all"));
2570 assert!(!filter.allows("soft_delete"));
2571 }
2572
2573 #[test]
2574 fn test_name_filter_glob_exact_match_still_works() {
2575 let filter = NameFilter::allow_list(["read_file".to_string()]).unwrap();
2576 assert!(filter.allows("read_file"));
2577 assert!(!filter.allows("write_file"));
2578 }
2579
2580 #[test]
2581 fn test_name_filter_glob_multiple_patterns() {
2582 let filter = NameFilter::allow_list(["read_*".to_string(), "list_*".to_string()]).unwrap();
2583 assert!(filter.allows("read_file"));
2584 assert!(filter.allows("list_users"));
2585 assert!(!filter.allows("delete_file"));
2586 }
2587
2588 #[test]
2589 fn test_name_filter_regex_allow_list() {
2590 let filter =
2591 NameFilter::allow_list(["re:^list_.*$".to_string(), "re:^get_\\w+$".to_string()])
2592 .unwrap();
2593 assert!(filter.allows("list_files"));
2594 assert!(filter.allows("list_users"));
2595 assert!(filter.allows("get_item"));
2596 assert!(!filter.allows("delete_file"));
2597 assert!(!filter.allows("create_issue"));
2598 }
2599
2600 #[test]
2601 fn test_name_filter_regex_deny_list() {
2602 let filter = NameFilter::deny_list(["re:^delete_".to_string()]).unwrap();
2603 assert!(filter.allows("read_file"));
2604 assert!(filter.allows("list_users"));
2605 assert!(!filter.allows("delete_file"));
2606 assert!(!filter.allows("delete_all"));
2607 }
2608
2609 #[test]
2610 fn test_name_filter_mixed_glob_and_regex() {
2611 let filter =
2612 NameFilter::allow_list(["read_*".to_string(), "re:^list_\\w+$".to_string()]).unwrap();
2613 assert!(filter.allows("read_file"));
2614 assert!(filter.allows("read_dir"));
2615 assert!(filter.allows("list_users"));
2616 assert!(!filter.allows("delete_file"));
2617 }
2618
2619 #[test]
2620 fn test_name_filter_regex_invalid_pattern() {
2621 let result = NameFilter::allow_list(["re:[invalid".to_string()]);
2622 assert!(result.is_err(), "invalid regex should produce an error");
2623 }
2624
2625 #[test]
2626 fn test_name_filter_regex_partial_match() {
2627 let filter = NameFilter::allow_list(["re:list".to_string()]).unwrap();
2629 assert!(filter.allows("list_files"));
2630 assert!(filter.allows("my_list_tool"));
2631 assert!(!filter.allows("read_file"));
2632 }
2633
2634 #[test]
2635 fn test_config_parse_regex_filter() {
2636 let toml = r#"
2637 [proxy]
2638 name = "regex-gw"
2639 [proxy.listen]
2640
2641 [[backends]]
2642 name = "svc"
2643 transport = "stdio"
2644 command = "echo"
2645 expose_tools = ["*_issue", "re:^list_.*$"]
2646 "#;
2647
2648 let config = ProxyConfig::parse(toml).unwrap();
2649 let filter = config.backends[0]
2650 .build_filter(&config.proxy.separator)
2651 .unwrap()
2652 .expect("should have filter");
2653 assert!(filter.tool_filter.allows("create_issue"));
2654 assert!(filter.tool_filter.allows("list_files"));
2655 assert!(filter.tool_filter.allows("list_users"));
2656 assert!(!filter.tool_filter.allows("delete_file"));
2657 }
2658
2659 #[test]
2660 fn test_parse_param_overrides() {
2661 let toml = r#"
2662 [proxy]
2663 name = "override-gw"
2664 [proxy.listen]
2665
2666 [[backends]]
2667 name = "fs"
2668 transport = "http"
2669 url = "http://localhost:8080"
2670
2671 [[backends.param_overrides]]
2672 tool = "list_directory"
2673 hide = ["path"]
2674 rename = { recursive = "deep_search" }
2675
2676 [backends.param_overrides.defaults]
2677 path = "/home/docs"
2678 "#;
2679
2680 let config = ProxyConfig::parse(toml).unwrap();
2681 assert_eq!(config.backends[0].param_overrides.len(), 1);
2682 let po = &config.backends[0].param_overrides[0];
2683 assert_eq!(po.tool, "list_directory");
2684 assert_eq!(po.hide, vec!["path"]);
2685 assert_eq!(po.defaults.get("path").unwrap(), "/home/docs");
2686 assert_eq!(po.rename.get("recursive").unwrap(), "deep_search");
2687 }
2688
2689 #[test]
2690 fn test_reject_param_override_empty_tool() {
2691 let toml = r#"
2692 [proxy]
2693 name = "bad"
2694 [proxy.listen]
2695
2696 [[backends]]
2697 name = "fs"
2698 transport = "http"
2699 url = "http://localhost:8080"
2700
2701 [[backends.param_overrides]]
2702 tool = ""
2703 hide = ["path"]
2704 "#;
2705
2706 let err = ProxyConfig::parse(toml).unwrap_err();
2707 assert!(
2708 format!("{err}").contains("tool must not be empty"),
2709 "unexpected error: {err}"
2710 );
2711 }
2712
2713 #[test]
2714 fn test_reject_param_override_duplicate_tool() {
2715 let toml = r#"
2716 [proxy]
2717 name = "bad"
2718 [proxy.listen]
2719
2720 [[backends]]
2721 name = "fs"
2722 transport = "http"
2723 url = "http://localhost:8080"
2724
2725 [[backends.param_overrides]]
2726 tool = "list_directory"
2727 hide = ["path"]
2728
2729 [[backends.param_overrides]]
2730 tool = "list_directory"
2731 hide = ["pattern"]
2732 "#;
2733
2734 let err = ProxyConfig::parse(toml).unwrap_err();
2735 assert!(
2736 format!("{err}").contains("duplicate param_overrides"),
2737 "unexpected error: {err}"
2738 );
2739 }
2740
2741 #[test]
2742 fn test_reject_param_override_hide_and_rename_same_param() {
2743 let toml = r#"
2744 [proxy]
2745 name = "bad"
2746 [proxy.listen]
2747
2748 [[backends]]
2749 name = "fs"
2750 transport = "http"
2751 url = "http://localhost:8080"
2752
2753 [[backends.param_overrides]]
2754 tool = "list_directory"
2755 hide = ["path"]
2756 rename = { path = "dir" }
2757 "#;
2758
2759 let err = ProxyConfig::parse(toml).unwrap_err();
2760 assert!(
2761 format!("{err}").contains("cannot be both hidden and renamed"),
2762 "unexpected error: {err}"
2763 );
2764 }
2765
2766 #[test]
2767 fn test_reject_param_override_duplicate_rename_target() {
2768 let toml = r#"
2769 [proxy]
2770 name = "bad"
2771 [proxy.listen]
2772
2773 [[backends]]
2774 name = "fs"
2775 transport = "http"
2776 url = "http://localhost:8080"
2777
2778 [[backends.param_overrides]]
2779 tool = "list_directory"
2780 rename = { path = "location", dir = "location" }
2781 "#;
2782
2783 let err = ProxyConfig::parse(toml).unwrap_err();
2784 assert!(
2785 format!("{err}").contains("duplicate rename target"),
2786 "unexpected error: {err}"
2787 );
2788 }
2789
2790 #[test]
2791 fn test_cache_backend_defaults_to_memory() {
2792 let config = ProxyConfig::parse(minimal_config()).unwrap();
2793 assert_eq!(config.cache.backend, "memory");
2794 assert!(config.cache.url.is_none());
2795 }
2796
2797 #[test]
2798 fn test_cache_backend_redis_requires_url() {
2799 let toml = r#"
2800 [proxy]
2801 name = "test"
2802 [proxy.listen]
2803 [cache]
2804 backend = "redis"
2805
2806 [[backends]]
2807 name = "echo"
2808 transport = "stdio"
2809 command = "echo"
2810 "#;
2811 let err = ProxyConfig::parse(toml).unwrap_err();
2812 assert!(err.to_string().contains("cache.url is required"));
2813 }
2814
2815 #[test]
2816 fn test_cache_backend_unknown_rejected() {
2817 let toml = r#"
2818 [proxy]
2819 name = "test"
2820 [proxy.listen]
2821 [cache]
2822 backend = "memcached"
2823
2824 [[backends]]
2825 name = "echo"
2826 transport = "stdio"
2827 command = "echo"
2828 "#;
2829 let err = ProxyConfig::parse(toml).unwrap_err();
2830 assert!(err.to_string().contains("unknown cache backend"));
2831 }
2832
2833 #[test]
2834 fn test_cache_backend_redis_with_url() {
2835 let toml = r#"
2836 [proxy]
2837 name = "test"
2838 [proxy.listen]
2839 [cache]
2840 backend = "redis"
2841 url = "redis://localhost:6379"
2842 prefix = "myapp:"
2843
2844 [[backends]]
2845 name = "echo"
2846 transport = "stdio"
2847 command = "echo"
2848 "#;
2849 let config = ProxyConfig::parse(toml).unwrap();
2850 assert_eq!(config.cache.backend, "redis");
2851 assert_eq!(config.cache.url.as_deref(), Some("redis://localhost:6379"));
2852 assert_eq!(config.cache.prefix, "myapp:");
2853 }
2854
2855 #[test]
2856 fn test_parse_bearer_scoped_tokens() {
2857 let toml = r#"
2858 [proxy]
2859 name = "scoped"
2860 [proxy.listen]
2861
2862 [[backends]]
2863 name = "echo"
2864 transport = "stdio"
2865 command = "echo"
2866
2867 [auth]
2868 type = "bearer"
2869
2870 [[auth.scoped_tokens]]
2871 token = "frontend-token"
2872 allow_tools = ["echo/read_file"]
2873
2874 [[auth.scoped_tokens]]
2875 token = "admin-token"
2876 "#;
2877
2878 let config = ProxyConfig::parse(toml).unwrap();
2879 match &config.auth {
2880 Some(AuthConfig::Bearer {
2881 tokens,
2882 scoped_tokens,
2883 }) => {
2884 assert!(tokens.is_empty());
2885 assert_eq!(scoped_tokens.len(), 2);
2886 assert_eq!(scoped_tokens[0].token, "frontend-token");
2887 assert_eq!(scoped_tokens[0].allow_tools, vec!["echo/read_file"]);
2888 assert!(scoped_tokens[1].allow_tools.is_empty());
2889 }
2890 other => panic!("expected Bearer auth, got: {other:?}"),
2891 }
2892 }
2893
2894 #[test]
2895 fn test_parse_bearer_mixed_tokens() {
2896 let toml = r#"
2897 [proxy]
2898 name = "mixed"
2899 [proxy.listen]
2900
2901 [[backends]]
2902 name = "echo"
2903 transport = "stdio"
2904 command = "echo"
2905
2906 [auth]
2907 type = "bearer"
2908 tokens = ["simple-token"]
2909
2910 [[auth.scoped_tokens]]
2911 token = "scoped-token"
2912 deny_tools = ["echo/delete"]
2913 "#;
2914
2915 let config = ProxyConfig::parse(toml).unwrap();
2916 match &config.auth {
2917 Some(AuthConfig::Bearer {
2918 tokens,
2919 scoped_tokens,
2920 }) => {
2921 assert_eq!(tokens, &["simple-token"]);
2922 assert_eq!(scoped_tokens.len(), 1);
2923 assert_eq!(scoped_tokens[0].deny_tools, vec!["echo/delete"]);
2924 }
2925 other => panic!("expected Bearer auth, got: {other:?}"),
2926 }
2927 }
2928
2929 #[test]
2930 fn test_bearer_empty_tokens_rejected() {
2931 let toml = r#"
2932 [proxy]
2933 name = "empty"
2934 [proxy.listen]
2935
2936 [[backends]]
2937 name = "echo"
2938 transport = "stdio"
2939 command = "echo"
2940
2941 [auth]
2942 type = "bearer"
2943 "#;
2944
2945 let err = ProxyConfig::parse(toml).unwrap_err();
2946 assert!(
2947 err.to_string().contains("at least one token"),
2948 "unexpected error: {err}"
2949 );
2950 }
2951
2952 #[test]
2953 fn test_bearer_duplicate_across_lists_rejected() {
2954 let toml = r#"
2955 [proxy]
2956 name = "dup"
2957 [proxy.listen]
2958
2959 [[backends]]
2960 name = "echo"
2961 transport = "stdio"
2962 command = "echo"
2963
2964 [auth]
2965 type = "bearer"
2966 tokens = ["shared-token"]
2967
2968 [[auth.scoped_tokens]]
2969 token = "shared-token"
2970 allow_tools = ["echo/read"]
2971 "#;
2972
2973 let err = ProxyConfig::parse(toml).unwrap_err();
2974 assert!(
2975 err.to_string().contains("duplicate bearer token"),
2976 "unexpected error: {err}"
2977 );
2978 }
2979
2980 #[test]
2981 fn test_bearer_allow_and_deny_rejected() {
2982 let toml = r#"
2983 [proxy]
2984 name = "both"
2985 [proxy.listen]
2986
2987 [[backends]]
2988 name = "echo"
2989 transport = "stdio"
2990 command = "echo"
2991
2992 [auth]
2993 type = "bearer"
2994
2995 [[auth.scoped_tokens]]
2996 token = "conflict"
2997 allow_tools = ["echo/read"]
2998 deny_tools = ["echo/write"]
2999 "#;
3000
3001 let err = ProxyConfig::parse(toml).unwrap_err();
3002 assert!(
3003 err.to_string().contains("cannot specify both"),
3004 "unexpected error: {err}"
3005 );
3006 }
3007
3008 #[test]
3009 fn test_parse_websocket_transport() {
3010 let toml = r#"
3011 [proxy]
3012 name = "ws-proxy"
3013 [proxy.listen]
3014
3015 [[backends]]
3016 name = "ws-backend"
3017 transport = "websocket"
3018 url = "ws://localhost:9090/ws"
3019 "#;
3020
3021 let config = ProxyConfig::parse(toml).unwrap();
3022 assert!(matches!(
3023 config.backends[0].transport,
3024 TransportType::Websocket
3025 ));
3026 assert_eq!(
3027 config.backends[0].url.as_deref(),
3028 Some("ws://localhost:9090/ws")
3029 );
3030 }
3031
3032 #[test]
3033 fn test_websocket_transport_requires_url() {
3034 let toml = r#"
3035 [proxy]
3036 name = "ws-proxy"
3037 [proxy.listen]
3038
3039 [[backends]]
3040 name = "ws-backend"
3041 transport = "websocket"
3042 "#;
3043
3044 let err = ProxyConfig::parse(toml).unwrap_err();
3045 assert!(
3046 err.to_string()
3047 .contains("websocket transport requires 'url'"),
3048 "unexpected error: {err}"
3049 );
3050 }
3051
3052 #[test]
3053 fn test_websocket_with_bearer_token() {
3054 let toml = r#"
3055 [proxy]
3056 name = "ws-proxy"
3057 [proxy.listen]
3058
3059 [[backends]]
3060 name = "ws-backend"
3061 transport = "websocket"
3062 url = "wss://secure.example.com/mcp"
3063 bearer_token = "my-secret"
3064 "#;
3065
3066 let config = ProxyConfig::parse(toml).unwrap();
3067 assert_eq!(
3068 config.backends[0].bearer_token.as_deref(),
3069 Some("my-secret")
3070 );
3071 }
3072
3073 #[test]
3074 fn test_tool_discovery_defaults_false() {
3075 let config = ProxyConfig::parse(minimal_config()).unwrap();
3076 assert!(!config.proxy.tool_discovery);
3077 }
3078
3079 #[test]
3080 fn test_tool_discovery_enabled() {
3081 let toml = r#"
3082 [proxy]
3083 name = "discovery"
3084 tool_discovery = true
3085 [proxy.listen]
3086
3087 [[backends]]
3088 name = "echo"
3089 transport = "stdio"
3090 command = "echo"
3091 "#;
3092
3093 let config = ProxyConfig::parse(toml).unwrap();
3094 assert!(config.proxy.tool_discovery);
3095 }
3096
3097 #[test]
3098 fn test_parse_oauth_config() {
3099 let toml = r#"
3100 [proxy]
3101 name = "oauth-proxy"
3102 [proxy.listen]
3103
3104 [[backends]]
3105 name = "echo"
3106 transport = "stdio"
3107 command = "echo"
3108
3109 [auth]
3110 type = "oauth"
3111 issuer = "https://accounts.google.com"
3112 audience = "mcp-proxy"
3113 "#;
3114
3115 let config = ProxyConfig::parse(toml).unwrap();
3116 match &config.auth {
3117 Some(AuthConfig::OAuth {
3118 issuer,
3119 audience,
3120 token_validation,
3121 ..
3122 }) => {
3123 assert_eq!(issuer, "https://accounts.google.com");
3124 assert_eq!(audience, "mcp-proxy");
3125 assert_eq!(token_validation, &TokenValidationStrategy::Jwt);
3126 }
3127 other => panic!("expected OAuth auth, got: {other:?}"),
3128 }
3129 }
3130
3131 #[test]
3132 fn test_parse_oauth_with_introspection() {
3133 let toml = r#"
3134 [proxy]
3135 name = "oauth-proxy"
3136 [proxy.listen]
3137
3138 [[backends]]
3139 name = "echo"
3140 transport = "stdio"
3141 command = "echo"
3142
3143 [auth]
3144 type = "oauth"
3145 issuer = "https://auth.example.com"
3146 audience = "mcp-proxy"
3147 client_id = "my-client"
3148 client_secret = "my-secret"
3149 token_validation = "introspection"
3150 "#;
3151
3152 let config = ProxyConfig::parse(toml).unwrap();
3153 match &config.auth {
3154 Some(AuthConfig::OAuth {
3155 token_validation,
3156 client_id,
3157 client_secret,
3158 ..
3159 }) => {
3160 assert_eq!(token_validation, &TokenValidationStrategy::Introspection);
3161 assert_eq!(client_id.as_deref(), Some("my-client"));
3162 assert_eq!(client_secret.as_deref(), Some("my-secret"));
3163 }
3164 other => panic!("expected OAuth auth, got: {other:?}"),
3165 }
3166 }
3167
3168 #[test]
3169 fn test_oauth_introspection_requires_credentials() {
3170 let toml = r#"
3171 [proxy]
3172 name = "oauth-proxy"
3173 [proxy.listen]
3174
3175 [[backends]]
3176 name = "echo"
3177 transport = "stdio"
3178 command = "echo"
3179
3180 [auth]
3181 type = "oauth"
3182 issuer = "https://auth.example.com"
3183 audience = "mcp-proxy"
3184 token_validation = "introspection"
3185 "#;
3186
3187 let err = ProxyConfig::parse(toml).unwrap_err();
3188 assert!(
3189 err.to_string().contains("client_id"),
3190 "unexpected error: {err}"
3191 );
3192 }
3193
3194 #[test]
3195 fn test_parse_oauth_with_overrides() {
3196 let toml = r#"
3197 [proxy]
3198 name = "oauth-proxy"
3199 [proxy.listen]
3200
3201 [[backends]]
3202 name = "echo"
3203 transport = "stdio"
3204 command = "echo"
3205
3206 [auth]
3207 type = "oauth"
3208 issuer = "https://auth.example.com"
3209 audience = "mcp-proxy"
3210 jwks_uri = "https://auth.example.com/custom/jwks"
3211 introspection_endpoint = "https://auth.example.com/custom/introspect"
3212 client_id = "my-client"
3213 client_secret = "my-secret"
3214 token_validation = "both"
3215 required_scopes = ["read", "write"]
3216 "#;
3217
3218 let config = ProxyConfig::parse(toml).unwrap();
3219 match &config.auth {
3220 Some(AuthConfig::OAuth {
3221 jwks_uri,
3222 introspection_endpoint,
3223 token_validation,
3224 required_scopes,
3225 ..
3226 }) => {
3227 assert_eq!(
3228 jwks_uri.as_deref(),
3229 Some("https://auth.example.com/custom/jwks")
3230 );
3231 assert_eq!(
3232 introspection_endpoint.as_deref(),
3233 Some("https://auth.example.com/custom/introspect")
3234 );
3235 assert_eq!(token_validation, &TokenValidationStrategy::Both);
3236 assert_eq!(required_scopes, &["read", "write"]);
3237 }
3238 other => panic!("expected OAuth auth, got: {other:?}"),
3239 }
3240 }
3241
3242 #[test]
3243 fn test_check_env_vars_warns_on_unset() {
3244 let toml = r#"
3245 [proxy]
3246 name = "env-check"
3247 [proxy.listen]
3248
3249 [[backends]]
3250 name = "svc"
3251 transport = "stdio"
3252 command = "echo"
3253 bearer_token = "${TOTALLY_UNSET_VAR_1}"
3254
3255 [backends.env]
3256 API_KEY = "${TOTALLY_UNSET_VAR_2}"
3257 STATIC = "plain-value"
3258
3259 [auth]
3260 type = "bearer"
3261 tokens = ["${TOTALLY_UNSET_VAR_3}", "literal-token"]
3262
3263 [[auth.scoped_tokens]]
3264 token = "${TOTALLY_UNSET_VAR_4}"
3265 allow_tools = ["svc/echo"]
3266 "#;
3267
3268 let config = ProxyConfig::parse(toml).unwrap();
3269 let warnings = config.check_env_vars();
3270
3271 assert_eq!(warnings.len(), 4, "warnings: {warnings:?}");
3272 assert!(warnings[0].contains("TOTALLY_UNSET_VAR_1"));
3273 assert!(warnings[0].contains("bearer_token"));
3274 assert!(warnings[1].contains("TOTALLY_UNSET_VAR_2"));
3275 assert!(warnings[1].contains("env.API_KEY"));
3276 assert!(warnings[2].contains("TOTALLY_UNSET_VAR_3"));
3277 assert!(warnings[2].contains("tokens[0]"));
3278 assert!(warnings[3].contains("TOTALLY_UNSET_VAR_4"));
3279 assert!(warnings[3].contains("scoped_tokens[0]"));
3280 }
3281
3282 #[test]
3283 fn test_check_env_vars_no_warnings_when_set() {
3284 unsafe { std::env::set_var("MCP_CHECK_TEST_VAR", "value") };
3286
3287 let toml = r#"
3288 [proxy]
3289 name = "env-check"
3290 [proxy.listen]
3291
3292 [[backends]]
3293 name = "svc"
3294 transport = "stdio"
3295 command = "echo"
3296 bearer_token = "${MCP_CHECK_TEST_VAR}"
3297 "#;
3298
3299 let config = ProxyConfig::parse(toml).unwrap();
3300 let warnings = config.check_env_vars();
3301 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3302
3303 unsafe { std::env::remove_var("MCP_CHECK_TEST_VAR") };
3305 }
3306
3307 #[test]
3308 fn test_check_env_vars_no_warnings_for_literals() {
3309 let toml = r#"
3310 [proxy]
3311 name = "env-check"
3312 [proxy.listen]
3313
3314 [[backends]]
3315 name = "svc"
3316 transport = "stdio"
3317 command = "echo"
3318 bearer_token = "literal-token"
3319 "#;
3320
3321 let config = ProxyConfig::parse(toml).unwrap();
3322 let warnings = config.check_env_vars();
3323 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3324 }
3325
3326 #[test]
3327 fn test_check_env_vars_oauth_client_secret() {
3328 let toml = r#"
3329 [proxy]
3330 name = "oauth-check"
3331 [proxy.listen]
3332
3333 [[backends]]
3334 name = "svc"
3335 transport = "http"
3336 url = "http://localhost:3000"
3337
3338 [auth]
3339 type = "oauth"
3340 issuer = "https://auth.example.com"
3341 audience = "mcp-proxy"
3342 client_id = "my-client"
3343 client_secret = "${TOTALLY_UNSET_OAUTH_SECRET}"
3344 token_validation = "introspection"
3345 "#;
3346
3347 let config = ProxyConfig::parse(toml).unwrap();
3348 let warnings = config.check_env_vars();
3349 assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
3350 assert!(warnings[0].contains("TOTALLY_UNSET_OAUTH_SECRET"));
3351 assert!(warnings[0].contains("client_secret"));
3352 }
3353
3354 #[cfg(feature = "yaml")]
3355 #[test]
3356 fn test_parse_yaml_config() {
3357 let yaml = r#"
3358proxy:
3359 name: yaml-proxy
3360 listen:
3361 host: "127.0.0.1"
3362 port: 8080
3363backends:
3364 - name: echo
3365 transport: stdio
3366 command: echo
3367"#;
3368 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3369 assert_eq!(config.proxy.name, "yaml-proxy");
3370 assert_eq!(config.backends.len(), 1);
3371 assert_eq!(config.backends[0].name, "echo");
3372 }
3373
3374 #[cfg(feature = "yaml")]
3375 #[test]
3376 fn test_parse_yaml_with_auth() {
3377 let yaml = r#"
3378proxy:
3379 name: auth-proxy
3380 listen:
3381 host: "127.0.0.1"
3382 port: 9090
3383backends:
3384 - name: api
3385 transport: stdio
3386 command: echo
3387auth:
3388 type: bearer
3389 tokens:
3390 - token-1
3391 - token-2
3392"#;
3393 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3394 match &config.auth {
3395 Some(AuthConfig::Bearer { tokens, .. }) => {
3396 assert_eq!(tokens, &["token-1", "token-2"]);
3397 }
3398 other => panic!("expected Bearer auth, got: {other:?}"),
3399 }
3400 }
3401
3402 #[cfg(feature = "yaml")]
3403 #[test]
3404 fn test_parse_yaml_with_middleware() {
3405 let yaml = r#"
3406proxy:
3407 name: mw-proxy
3408 listen:
3409 host: "127.0.0.1"
3410 port: 8080
3411backends:
3412 - name: api
3413 transport: stdio
3414 command: echo
3415 timeout:
3416 seconds: 30
3417 rate_limit:
3418 requests: 100
3419 period_seconds: 1
3420 expose_tools:
3421 - read_file
3422 - list_directory
3423"#;
3424 let config = ProxyConfig::parse_yaml(yaml).unwrap();
3425 assert_eq!(config.backends[0].timeout.as_ref().unwrap().seconds, 30);
3426 assert_eq!(
3427 config.backends[0].rate_limit.as_ref().unwrap().requests,
3428 100
3429 );
3430 assert_eq!(
3431 config.backends[0].expose_tools,
3432 vec!["read_file", "list_directory"]
3433 );
3434 }
3435
3436 #[test]
3437 fn test_from_mcp_json() {
3438 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json");
3439 let project_dir = dir.join("my-project");
3440 std::fs::create_dir_all(&project_dir).unwrap();
3441
3442 let mcp_json_path = project_dir.join(".mcp.json");
3443 std::fs::write(
3444 &mcp_json_path,
3445 r#"{
3446 "mcpServers": {
3447 "github": {
3448 "command": "npx",
3449 "args": ["-y", "@modelcontextprotocol/server-github"]
3450 },
3451 "api": {
3452 "url": "http://localhost:9000"
3453 }
3454 }
3455 }"#,
3456 )
3457 .unwrap();
3458
3459 let config = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap();
3460
3461 assert_eq!(config.proxy.name, "my-project");
3463 assert_eq!(config.proxy.listen.host, "127.0.0.1");
3465 assert_eq!(config.proxy.listen.port, 8080);
3466 assert_eq!(config.proxy.version, "0.1.0");
3467 assert_eq!(config.proxy.separator, "/");
3468 assert!(config.auth.is_none());
3470 assert!(config.composite_tools.is_empty());
3471 assert_eq!(config.backends.len(), 2);
3473 assert_eq!(config.backends[0].name, "api");
3474 assert_eq!(config.backends[1].name, "github");
3475
3476 std::fs::remove_dir_all(&dir).unwrap();
3477 }
3478
3479 #[test]
3480 fn test_from_mcp_json_empty_rejects() {
3481 let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json_empty");
3482 std::fs::create_dir_all(&dir).unwrap();
3483
3484 let mcp_json_path = dir.join(".mcp.json");
3485 std::fs::write(&mcp_json_path, r#"{ "mcpServers": {} }"#).unwrap();
3486
3487 let err = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap_err();
3488 assert!(
3489 err.to_string().contains("at least one backend"),
3490 "unexpected error: {err}"
3491 );
3492
3493 std::fs::remove_dir_all(&dir).unwrap();
3494 }
3495
3496 #[test]
3497 fn test_priority_defaults_to_zero() {
3498 let toml = r#"
3499 [proxy]
3500 name = "test"
3501 [proxy.listen]
3502
3503 [[backends]]
3504 name = "api"
3505 transport = "stdio"
3506 command = "echo"
3507 "#;
3508
3509 let config = ProxyConfig::parse(toml).unwrap();
3510 assert_eq!(config.backends[0].priority, 0);
3511 }
3512
3513 #[test]
3514 fn test_priority_parsed_from_config() {
3515 let toml = r#"
3516 [proxy]
3517 name = "test"
3518 [proxy.listen]
3519
3520 [[backends]]
3521 name = "api"
3522 transport = "stdio"
3523 command = "echo"
3524
3525 [[backends]]
3526 name = "api-backup-1"
3527 transport = "stdio"
3528 command = "echo"
3529 failover_for = "api"
3530 priority = 10
3531
3532 [[backends]]
3533 name = "api-backup-2"
3534 transport = "stdio"
3535 command = "echo"
3536 failover_for = "api"
3537 priority = 5
3538 "#;
3539
3540 let config = ProxyConfig::parse(toml).unwrap();
3541 assert_eq!(config.backends[0].priority, 0);
3542 assert_eq!(config.backends[1].priority, 10);
3543 assert_eq!(config.backends[2].priority, 5);
3544 }
3545}