1use std::collections::HashMap;
28use std::time::Duration;
29
30use anyhow::Result;
31
32use crate::Proxy;
33use crate::config::*;
34
35pub struct ProxyBuilder {
40 config: ProxyConfig,
41}
42
43impl ProxyBuilder {
44 pub fn new(name: impl Into<String>) -> Self {
48 Self {
49 config: ProxyConfig {
50 proxy: ProxySettings {
51 name: name.into(),
52 version: "0.1.0".to_string(),
53 separator: "/".to_string(),
54 listen: ListenConfig {
55 host: "127.0.0.1".to_string(),
56 port: 8080,
57 },
58 instructions: None,
59 shutdown_timeout_seconds: 30,
60 hot_reload: false,
61 import_backends: None,
62 rate_limit: None,
63 tool_discovery: false,
64 tool_exposure: crate::config::ToolExposure::default(),
65 },
66 backends: Vec::new(),
67 auth: None,
68 performance: PerformanceConfig::default(),
69 security: SecurityConfig::default(),
70 cache: CacheBackendConfig::default(),
71 observability: ObservabilityConfig::default(),
72 composite_tools: Vec::new(),
73 source_path: None,
74 },
75 }
76 }
77
78 pub fn version(mut self, version: impl Into<String>) -> Self {
80 self.config.proxy.version = version.into();
81 self
82 }
83
84 pub fn separator(mut self, separator: impl Into<String>) -> Self {
86 self.config.proxy.separator = separator.into();
87 self
88 }
89
90 pub fn listen(mut self, host: impl Into<String>, port: u16) -> Self {
92 self.config.proxy.listen = ListenConfig {
93 host: host.into(),
94 port,
95 };
96 self
97 }
98
99 pub fn instructions(mut self, instructions: impl Into<String>) -> Self {
101 self.config.proxy.instructions = Some(instructions.into());
102 self
103 }
104
105 pub fn shutdown_timeout(mut self, timeout: Duration) -> Self {
107 self.config.proxy.shutdown_timeout_seconds = timeout.as_secs();
108 self
109 }
110
111 pub fn hot_reload(mut self, enabled: bool) -> Self {
113 self.config.proxy.hot_reload = enabled;
114 self
115 }
116
117 pub fn global_rate_limit(mut self, requests: usize, period: Duration) -> Self {
119 self.config.proxy.rate_limit = Some(GlobalRateLimitConfig {
120 requests,
121 period_seconds: period.as_secs(),
122 });
123 self
124 }
125
126 pub fn stdio_backend(
128 mut self,
129 name: impl Into<String>,
130 command: impl Into<String>,
131 args: &[&str],
132 ) -> Self {
133 self.config.backends.push(BackendConfig {
134 name: name.into(),
135 transport: TransportType::Stdio,
136 command: Some(command.into()),
137 args: args.iter().map(|s| s.to_string()).collect(),
138 url: None,
139 ..default_backend()
140 });
141 self
142 }
143
144 pub fn stdio_backend_with_env(
146 mut self,
147 name: impl Into<String>,
148 command: impl Into<String>,
149 args: &[&str],
150 env: HashMap<String, String>,
151 ) -> Self {
152 self.config.backends.push(BackendConfig {
153 name: name.into(),
154 transport: TransportType::Stdio,
155 command: Some(command.into()),
156 args: args.iter().map(|s| s.to_string()).collect(),
157 url: None,
158 env,
159 ..default_backend()
160 });
161 self
162 }
163
164 pub fn http_backend(mut self, name: impl Into<String>, url: impl Into<String>) -> Self {
166 self.config.backends.push(BackendConfig {
167 name: name.into(),
168 transport: TransportType::Http,
169 command: None,
170 url: Some(url.into()),
171 ..default_backend()
172 });
173 self
174 }
175
176 pub fn http_backend_with_token(
178 mut self,
179 name: impl Into<String>,
180 url: impl Into<String>,
181 token: impl Into<String>,
182 ) -> Self {
183 self.config.backends.push(BackendConfig {
184 name: name.into(),
185 transport: TransportType::Http,
186 command: None,
187 url: Some(url.into()),
188 bearer_token: Some(token.into()),
189 ..default_backend()
190 });
191 self
192 }
193
194 pub fn configure_backend(mut self, f: impl FnOnce(&mut BackendConfig)) -> Self {
200 let backend = self
201 .config
202 .backends
203 .last_mut()
204 .expect("configure_backend called with no backends");
205 f(backend);
206 self
207 }
208
209 pub fn bearer_auth(mut self, tokens: Vec<String>) -> Self {
215 self.config.auth = Some(AuthConfig::Bearer {
216 tokens,
217 scoped_tokens: vec![],
218 });
219 self
220 }
221
222 pub fn scoped_bearer_auth(mut self, scoped_tokens: Vec<BearerTokenConfig>) -> Self {
227 self.config.auth = Some(AuthConfig::Bearer {
228 tokens: vec![],
229 scoped_tokens,
230 });
231 self
232 }
233
234 pub fn coalesce_requests(mut self, enabled: bool) -> Self {
236 self.config.performance.coalesce_requests = enabled;
237 self
238 }
239
240 pub fn max_argument_size(mut self, max_bytes: usize) -> Self {
242 self.config.security.max_argument_size = Some(max_bytes);
243 self
244 }
245
246 pub fn audit_logging(mut self, enabled: bool) -> Self {
248 self.config.observability.audit = enabled;
249 self
250 }
251
252 pub fn access_logging(mut self, enabled: bool) -> Self {
254 self.config.observability.access_log.enabled = enabled;
255 self
256 }
257
258 pub fn log_level(mut self, level: impl Into<String>) -> Self {
260 self.config.observability.log_level = level.into();
261 self
262 }
263
264 pub fn json_logs(mut self, enabled: bool) -> Self {
266 self.config.observability.json_logs = enabled;
267 self
268 }
269
270 pub fn metrics(mut self, enabled: bool) -> Self {
272 self.config.observability.metrics.enabled = enabled;
273 self
274 }
275
276 pub fn timeout(mut self, seconds: u64) -> Self {
295 let backend = self
296 .config
297 .backends
298 .last_mut()
299 .expect("timeout called with no backends");
300 backend.timeout = Some(TimeoutConfig { seconds });
301 self
302 }
303
304 pub fn rate_limit(mut self, requests: usize, period_seconds: u64) -> Self {
325 let backend = self
326 .config
327 .backends
328 .last_mut()
329 .expect("rate_limit called with no backends");
330 backend.rate_limit = Some(RateLimitConfig {
331 requests,
332 period_seconds,
333 });
334 self
335 }
336
337 pub fn circuit_breaker(mut self, failure_rate: f64) -> Self {
360 let backend = self
361 .config
362 .backends
363 .last_mut()
364 .expect("circuit_breaker called with no backends");
365 backend.circuit_breaker = Some(CircuitBreakerConfig {
366 failure_rate_threshold: failure_rate,
367 minimum_calls: 5,
368 wait_duration_seconds: 30,
369 permitted_calls_in_half_open: 3,
370 });
371 self
372 }
373
374 pub fn expose_tools(mut self, tools: &[&str]) -> Self {
395 let backend = self
396 .config
397 .backends
398 .last_mut()
399 .expect("expose_tools called with no backends");
400 backend.expose_tools = tools.iter().map(|s| s.to_string()).collect();
401 self
402 }
403
404 pub fn hide_tools(mut self, tools: &[&str]) -> Self {
425 let backend = self
426 .config
427 .backends
428 .last_mut()
429 .expect("hide_tools called with no backends");
430 backend.hide_tools = tools.iter().map(|s| s.to_string()).collect();
431 self
432 }
433
434 pub fn retry(mut self, max_retries: u32) -> Self {
457 let backend = self
458 .config
459 .backends
460 .last_mut()
461 .expect("retry called with no backends");
462 backend.retry = Some(RetryConfig {
463 max_retries,
464 initial_backoff_ms: 100,
465 max_backoff_ms: 5000,
466 budget_percent: None,
467 min_retries_per_sec: 10,
468 });
469 self
470 }
471
472 pub fn into_config(self) -> ProxyConfig {
477 self.config
478 }
479
480 pub async fn build(self) -> Result<Proxy> {
483 Proxy::from_config(self.config).await
484 }
485}
486
487fn default_backend() -> BackendConfig {
489 BackendConfig {
490 name: String::new(),
491 transport: TransportType::Stdio,
492 command: None,
493 args: Vec::new(),
494 url: None,
495 env: HashMap::new(),
496 bearer_token: None,
497 forward_auth: false,
498 timeout: None,
499 circuit_breaker: None,
500 rate_limit: None,
501 concurrency: None,
502 retry: None,
503 outlier_detection: None,
504 hedging: None,
505 cache: None,
506 default_args: serde_json::Map::new(),
507 inject_args: Vec::new(),
508 param_overrides: Vec::new(),
509 expose_tools: Vec::new(),
510 hide_tools: Vec::new(),
511 expose_resources: Vec::new(),
512 hide_resources: Vec::new(),
513 expose_prompts: Vec::new(),
514 hide_prompts: Vec::new(),
515 hide_destructive: false,
516 read_only_only: false,
517 failover_for: None,
518 priority: 0,
519 canary_of: None,
520 weight: 100,
521 aliases: Vec::new(),
522 mirror_of: None,
523 mirror_percent: 100,
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 #[test]
532 fn test_builder_minimal() {
533 let config = ProxyBuilder::new("test-proxy").into_config();
534 assert_eq!(config.proxy.name, "test-proxy");
535 assert_eq!(config.proxy.version, "0.1.0");
536 assert_eq!(config.proxy.separator, "/");
537 assert_eq!(config.proxy.listen.host, "127.0.0.1");
538 assert_eq!(config.proxy.listen.port, 8080);
539 assert!(config.backends.is_empty());
540 }
541
542 #[test]
543 fn test_builder_with_backends() {
544 let config = ProxyBuilder::new("test")
545 .stdio_backend("files", "npx", &["-y", "@mcp/server-files"])
546 .http_backend("api", "http://localhost:8080")
547 .into_config();
548
549 assert_eq!(config.backends.len(), 2);
550 assert_eq!(config.backends[0].name, "files");
551 assert!(matches!(config.backends[0].transport, TransportType::Stdio));
552 assert_eq!(config.backends[0].command.as_deref(), Some("npx"));
553 assert_eq!(config.backends[1].name, "api");
554 assert!(matches!(config.backends[1].transport, TransportType::Http));
555 assert_eq!(
556 config.backends[1].url.as_deref(),
557 Some("http://localhost:8080")
558 );
559 }
560
561 #[test]
562 fn test_builder_configure_backend() {
563 let config = ProxyBuilder::new("test")
564 .http_backend("api", "http://localhost:8080")
565 .configure_backend(|b| {
566 b.timeout = Some(TimeoutConfig { seconds: 30 });
567 b.rate_limit = Some(RateLimitConfig {
568 requests: 100,
569 period_seconds: 1,
570 });
571 b.hide_tools = vec!["dangerous_op".to_string()];
572 })
573 .into_config();
574
575 assert!(config.backends[0].timeout.is_some());
576 assert!(config.backends[0].rate_limit.is_some());
577 assert_eq!(config.backends[0].hide_tools, vec!["dangerous_op"]);
578 }
579
580 #[test]
581 fn test_builder_auth_and_observability() {
582 let config = ProxyBuilder::new("test")
583 .bearer_auth(vec!["token1".into(), "token2".into()])
584 .audit_logging(true)
585 .access_logging(true)
586 .metrics(true)
587 .json_logs(true)
588 .log_level("debug")
589 .into_config();
590
591 assert!(config.auth.is_some());
592 assert!(config.observability.audit);
593 assert!(config.observability.access_log.enabled);
594 assert!(config.observability.metrics.enabled);
595 assert!(config.observability.json_logs);
596 assert_eq!(config.observability.log_level, "debug");
597 }
598
599 #[test]
600 fn test_builder_global_rate_limit() {
601 let config = ProxyBuilder::new("test")
602 .global_rate_limit(500, Duration::from_secs(1))
603 .into_config();
604
605 let rl = config.proxy.rate_limit.unwrap();
606 assert_eq!(rl.requests, 500);
607 assert_eq!(rl.period_seconds, 1);
608 }
609
610 #[test]
611 fn test_builder_all_settings() {
612 let config = ProxyBuilder::new("enterprise")
613 .version("2.0.0")
614 .separator("::")
615 .listen("0.0.0.0", 9090)
616 .instructions("Enterprise MCP gateway")
617 .shutdown_timeout(Duration::from_secs(60))
618 .coalesce_requests(true)
619 .max_argument_size(1_048_576)
620 .into_config();
621
622 assert_eq!(config.proxy.name, "enterprise");
623 assert_eq!(config.proxy.version, "2.0.0");
624 assert_eq!(config.proxy.separator, "::");
625 assert_eq!(config.proxy.listen.host, "0.0.0.0");
626 assert_eq!(config.proxy.listen.port, 9090);
627 assert_eq!(
628 config.proxy.instructions.as_deref(),
629 Some("Enterprise MCP gateway")
630 );
631 assert_eq!(config.proxy.shutdown_timeout_seconds, 60);
632 assert!(config.performance.coalesce_requests);
633 assert_eq!(config.security.max_argument_size, Some(1_048_576));
634 }
635
636 #[test]
637 fn test_builder_http_backend_with_token() {
638 let config = ProxyBuilder::new("test")
639 .http_backend_with_token("api", "http://api:8080", "secret")
640 .into_config();
641
642 assert_eq!(config.backends[0].bearer_token.as_deref(), Some("secret"));
643 }
644
645 #[test]
646 fn test_builder_ergonomic_backend_methods() {
647 let config = ProxyBuilder::new("test")
648 .http_backend("api", "http://api:8080")
649 .timeout(30)
650 .rate_limit(100, 1)
651 .circuit_breaker(0.7)
652 .expose_tools(&["read_file", "list_dir"])
653 .retry(5)
654 .stdio_backend("files", "npx", &["-y", "@mcp/server-files"])
655 .hide_tools(&["dangerous_op"])
656 .timeout(60)
657 .into_config();
658
659 let api = &config.backends[0];
661 assert_eq!(api.timeout.as_ref().unwrap().seconds, 30);
662 let rl = api.rate_limit.as_ref().unwrap();
663 assert_eq!(rl.requests, 100);
664 assert_eq!(rl.period_seconds, 1);
665 let cb = api.circuit_breaker.as_ref().unwrap();
666 assert!((cb.failure_rate_threshold - 0.7).abs() < f64::EPSILON);
667 assert_eq!(cb.minimum_calls, 5);
668 assert_eq!(cb.wait_duration_seconds, 30);
669 assert_eq!(cb.permitted_calls_in_half_open, 3);
670 assert_eq!(api.expose_tools, vec!["read_file", "list_dir"]);
671 let retry = api.retry.as_ref().unwrap();
672 assert_eq!(retry.max_retries, 5);
673 assert_eq!(retry.initial_backoff_ms, 100);
674 assert_eq!(retry.max_backoff_ms, 5000);
675 assert!(retry.budget_percent.is_none());
676
677 let files = &config.backends[1];
679 assert_eq!(files.hide_tools, vec!["dangerous_op"]);
680 assert_eq!(files.timeout.as_ref().unwrap().seconds, 60);
681 assert!(files.circuit_breaker.is_none());
682 assert!(files.rate_limit.is_none());
683 }
684
685 #[test]
686 fn test_builder_stdio_backend_with_env() {
687 let mut env = HashMap::new();
688 env.insert("GITHUB_TOKEN".to_string(), "ghp_xxx".to_string());
689
690 let config = ProxyBuilder::new("test")
691 .stdio_backend_with_env("github", "npx", &["-y", "@mcp/github"], env)
692 .into_config();
693
694 assert_eq!(
695 config.backends[0].env.get("GITHUB_TOKEN").unwrap(),
696 "ghp_xxx"
697 );
698 }
699}