Skip to main content

mcp_proxy/
config.rs

1//! Proxy configuration types and TOML parsing.
2
3use std::collections::HashMap;
4use std::collections::HashSet;
5use std::path::Path;
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10/// Top-level proxy configuration, typically loaded from a TOML file.
11#[derive(Debug, Deserialize, Serialize)]
12pub struct ProxyConfig {
13    /// Core proxy settings (name, version, listen address).
14    pub proxy: ProxySettings,
15    /// Backend MCP servers to proxy.
16    #[serde(default)]
17    pub backends: Vec<BackendConfig>,
18    /// Inbound authentication configuration.
19    pub auth: Option<AuthConfig>,
20    /// Performance tuning options.
21    #[serde(default)]
22    pub performance: PerformanceConfig,
23    /// Security policies.
24    #[serde(default)]
25    pub security: SecurityConfig,
26    /// Global cache backend configuration.
27    #[serde(default)]
28    pub cache: CacheBackendConfig,
29    /// Logging, metrics, and tracing configuration.
30    #[serde(default)]
31    pub observability: ObservabilityConfig,
32    /// Composite tools that fan out to multiple backend tools.
33    #[serde(default)]
34    pub composite_tools: Vec<CompositeToolConfig>,
35}
36
37/// Fan-out strategy for composite tools.
38#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
39#[serde(rename_all = "lowercase")]
40pub enum CompositeStrategy {
41    /// Execute all tools concurrently using `tokio::JoinSet`.
42    #[default]
43    Parallel,
44}
45
46/// Configuration for a composite tool that fans out to multiple backend tools.
47///
48/// Composite tools appear in `ListTools` responses alongside regular tools.
49/// When called, the proxy dispatches the request to every tool in [`tools`](Self::tools)
50/// concurrently (for `parallel` strategy) and aggregates all results.
51///
52/// # Example
53///
54/// ```toml
55/// [[composite_tools]]
56/// name = "search_all"
57/// description = "Search across all knowledge sources"
58/// tools = ["github/search", "jira/search", "docs/search"]
59/// strategy = "parallel"
60/// ```
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct CompositeToolConfig {
63    /// Name of the composite tool as it appears to MCP clients.
64    pub name: String,
65    /// Human-readable description of the composite tool.
66    pub description: String,
67    /// Fully-qualified backend tool names to fan out to (e.g. `"github/search"`).
68    pub tools: Vec<String>,
69    /// Execution strategy (default: `parallel`).
70    #[serde(default)]
71    pub strategy: CompositeStrategy,
72}
73
74/// Core proxy identity and server settings.
75#[derive(Debug, Deserialize, Serialize)]
76pub struct ProxySettings {
77    /// Proxy name, used in MCP server info.
78    pub name: String,
79    /// Proxy version, used in MCP server info (default: "0.1.0").
80    #[serde(default = "default_version")]
81    pub version: String,
82    /// Namespace separator between backend name and tool/resource name (default: "/").
83    #[serde(default = "default_separator")]
84    pub separator: String,
85    /// HTTP listen address.
86    pub listen: ListenConfig,
87    /// Optional instructions text sent to MCP clients.
88    pub instructions: Option<String>,
89    /// Graceful shutdown timeout in seconds (default: 30)
90    #[serde(default = "default_shutdown_timeout")]
91    pub shutdown_timeout_seconds: u64,
92    /// Enable hot reload: watch config file for new backends
93    #[serde(default)]
94    pub hot_reload: bool,
95    /// Import backends from a `.mcp.json` file. Backends defined in the TOML
96    /// config take precedence over imported ones with the same name.
97    pub import_backends: Option<String>,
98    /// Global rate limit applied to all requests before per-backend dispatch.
99    pub rate_limit: Option<GlobalRateLimitConfig>,
100    /// Enable BM25-based tool discovery and search (default: false).
101    /// Adds `proxy/search_tools`, `proxy/similar_tools`, and
102    /// `proxy/tool_categories` tools for finding tools across backends.
103    #[serde(default)]
104    pub tool_discovery: bool,
105    /// How backend tools are exposed to MCP clients (default: "direct").
106    ///
107    /// - `direct` -- all tools appear in `ListTools` responses (default behavior).
108    /// - `search` -- only `proxy/` meta-tools are listed; backend tools are
109    ///   discoverable via `proxy/search_tools` and invokable via `proxy/call_tool`.
110    ///   Useful when aggregating 100+ tools that would overwhelm LLM context.
111    ///   Implies `tool_discovery = true`.
112    #[serde(default)]
113    pub tool_exposure: ToolExposure,
114}
115
116/// How backend tools are exposed to MCP clients.
117///
118/// Controls whether individual backend tools appear in `ListTools` responses
119/// or are hidden behind discovery meta-tools.
120///
121/// # Examples
122///
123/// ```
124/// use mcp_proxy::config::ToolExposure;
125///
126/// let direct: ToolExposure = serde_json::from_str("\"direct\"").unwrap();
127/// assert_eq!(direct, ToolExposure::Direct);
128///
129/// let search: ToolExposure = serde_json::from_str("\"search\"").unwrap();
130/// assert_eq!(search, ToolExposure::Search);
131/// ```
132#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
133#[serde(rename_all = "lowercase")]
134pub enum ToolExposure {
135    /// All backend tools appear in `ListTools` responses.
136    #[default]
137    Direct,
138    /// Only `proxy/` namespace meta-tools appear. Backend tools are hidden
139    /// from listings but remain invokable via `proxy/call_tool`.
140    Search,
141}
142
143/// Global rate limit configuration applied across all backends.
144#[derive(Debug, Deserialize, Serialize, Clone)]
145pub struct GlobalRateLimitConfig {
146    /// Maximum number of requests allowed per period.
147    pub requests: usize,
148    /// Period length in seconds (default: 1).
149    #[serde(default = "default_rate_period")]
150    pub period_seconds: u64,
151}
152
153/// HTTP server listen address.
154#[derive(Debug, Deserialize, Serialize)]
155pub struct ListenConfig {
156    /// Bind host (default: "127.0.0.1").
157    #[serde(default = "default_host")]
158    pub host: String,
159    /// Bind port (default: 8080).
160    #[serde(default = "default_port")]
161    pub port: u16,
162}
163
164/// Configuration for a single backend MCP server.
165#[derive(Debug, Deserialize, Serialize)]
166pub struct BackendConfig {
167    /// Unique backend name, used as the namespace prefix for its tools/resources.
168    pub name: String,
169    /// Transport protocol to use when connecting to this backend.
170    pub transport: TransportType,
171    /// Command for stdio backends
172    pub command: Option<String>,
173    /// Arguments for stdio backends
174    #[serde(default)]
175    pub args: Vec<String>,
176    /// URL for HTTP backends
177    pub url: Option<String>,
178    /// Environment variables for subprocess backends
179    #[serde(default)]
180    pub env: HashMap<String, String>,
181    /// Per-backend timeout
182    pub timeout: Option<TimeoutConfig>,
183    /// Per-backend circuit breaker
184    pub circuit_breaker: Option<CircuitBreakerConfig>,
185    /// Per-backend rate limit
186    pub rate_limit: Option<RateLimitConfig>,
187    /// Per-backend concurrency limit
188    pub concurrency: Option<ConcurrencyConfig>,
189    /// Per-backend retry policy
190    pub retry: Option<RetryConfig>,
191    /// Per-backend outlier detection (passive health checks)
192    pub outlier_detection: Option<OutlierDetectionConfig>,
193    /// Per-backend request hedging (parallel redundant requests)
194    pub hedging: Option<HedgingConfig>,
195    /// Mirror traffic from another backend (fire-and-forget).
196    /// Set to the name of the source backend to mirror.
197    pub mirror_of: Option<String>,
198    /// Percentage of requests to mirror (1-100, default: 100).
199    #[serde(default = "default_mirror_percent")]
200    pub mirror_percent: u32,
201    /// Per-backend cache policy
202    pub cache: Option<BackendCacheConfig>,
203    /// Static bearer token for authenticating to this backend (HTTP only).
204    /// Supports `${ENV_VAR}` syntax for env var resolution.
205    pub bearer_token: Option<String>,
206    /// Forward the client's inbound auth token to this backend.
207    /// Only works with HTTP backends when the proxy has auth enabled.
208    #[serde(default)]
209    pub forward_auth: bool,
210    /// Tool aliases: rename tools exposed by this backend
211    #[serde(default)]
212    pub aliases: Vec<AliasConfig>,
213    /// Default arguments injected into all tool calls for this backend.
214    /// Merged into tool call arguments (does not overwrite existing keys).
215    #[serde(default)]
216    pub default_args: serde_json::Map<String, serde_json::Value>,
217    /// Per-tool argument injection rules.
218    #[serde(default)]
219    pub inject_args: Vec<InjectArgsConfig>,
220    /// Per-tool parameter overrides: hide, rename, and inject defaults.
221    #[serde(default)]
222    pub param_overrides: Vec<ParamOverrideConfig>,
223    /// Capability filtering: only expose these tools (allowlist)
224    #[serde(default)]
225    pub expose_tools: Vec<String>,
226    /// Capability filtering: hide these tools (denylist)
227    #[serde(default)]
228    pub hide_tools: Vec<String>,
229    /// Capability filtering: only expose these resources (allowlist, by URI)
230    #[serde(default)]
231    pub expose_resources: Vec<String>,
232    /// Capability filtering: hide these resources (denylist, by URI)
233    #[serde(default)]
234    pub hide_resources: Vec<String>,
235    /// Capability filtering: only expose these prompts (allowlist)
236    #[serde(default)]
237    pub expose_prompts: Vec<String>,
238    /// Capability filtering: hide these prompts (denylist)
239    #[serde(default)]
240    pub hide_prompts: Vec<String>,
241    /// Hide tools annotated as destructive (`destructive_hint = true`).
242    #[serde(default)]
243    pub hide_destructive: bool,
244    /// Only expose tools annotated as read-only (`read_only_hint = true`).
245    #[serde(default)]
246    pub read_only_only: bool,
247    /// Failover: name of the primary backend this is a failover for.
248    /// When set, this backend's tools are hidden and requests are only
249    /// routed here when the primary returns an error.
250    pub failover_for: Option<String>,
251    /// Failover priority for ordering multiple failover backends.
252    /// Lower values are preferred (tried first). Default is 0.
253    /// When multiple backends declare `failover_for` the same primary,
254    /// they are tried in ascending priority order until one succeeds.
255    #[serde(default)]
256    pub priority: u32,
257    /// Canary routing: name of the primary backend this is a canary for.
258    /// When set, this backend's tools are hidden and requests targeting
259    /// the primary are probabilistically routed here based on weight.
260    pub canary_of: Option<String>,
261    /// Routing weight for canary deployments (default: 100).
262    /// Higher values receive proportionally more traffic.
263    #[serde(default = "default_weight")]
264    pub weight: u32,
265}
266
267/// Backend transport protocol.
268#[derive(Debug, Deserialize, Serialize)]
269#[serde(rename_all = "lowercase")]
270pub enum TransportType {
271    /// Subprocess communicating via stdin/stdout.
272    Stdio,
273    /// HTTP+SSE remote server.
274    Http,
275    /// WebSocket remote server.
276    Websocket,
277}
278
279/// Per-backend request timeout.
280#[derive(Debug, Deserialize, Serialize)]
281pub struct TimeoutConfig {
282    /// Timeout duration in seconds.
283    pub seconds: u64,
284}
285
286/// Per-backend circuit breaker configuration.
287#[derive(Debug, Deserialize, Serialize)]
288pub struct CircuitBreakerConfig {
289    /// Failure rate threshold (0.0-1.0) to trip open (default: 0.5)
290    #[serde(default = "default_failure_rate")]
291    pub failure_rate_threshold: f64,
292    /// Minimum number of calls before evaluating failure rate (default: 5)
293    #[serde(default = "default_min_calls")]
294    pub minimum_calls: usize,
295    /// Seconds to wait in open state before half-open (default: 30)
296    #[serde(default = "default_wait_duration")]
297    pub wait_duration_seconds: u64,
298    /// Number of permitted calls in half-open state (default: 3)
299    #[serde(default = "default_half_open_calls")]
300    pub permitted_calls_in_half_open: usize,
301}
302
303/// Per-backend rate limiting configuration.
304#[derive(Debug, Deserialize, Serialize)]
305pub struct RateLimitConfig {
306    /// Maximum requests per period
307    pub requests: usize,
308    /// Period in seconds (default: 1)
309    #[serde(default = "default_rate_period")]
310    pub period_seconds: u64,
311}
312
313/// Per-backend concurrency limit configuration.
314#[derive(Debug, Deserialize, Serialize)]
315pub struct ConcurrencyConfig {
316    /// Maximum concurrent requests.
317    pub max_concurrent: usize,
318}
319
320/// Per-backend retry policy with exponential backoff.
321#[derive(Debug, Clone, Deserialize, Serialize)]
322pub struct RetryConfig {
323    /// Maximum number of retry attempts (default: 3)
324    #[serde(default = "default_max_retries")]
325    pub max_retries: u32,
326    /// Initial backoff in milliseconds (default: 100)
327    #[serde(default = "default_initial_backoff_ms")]
328    pub initial_backoff_ms: u64,
329    /// Maximum backoff in milliseconds (default: 5000)
330    #[serde(default = "default_max_backoff_ms")]
331    pub max_backoff_ms: u64,
332    /// Maximum percentage of requests that can be retries (default: none / unlimited).
333    /// When set, prevents retry storms by capping retries as a fraction of total
334    /// request volume. Envoy uses 20% as a default. Evaluated over a 10-second
335    /// rolling window.
336    pub budget_percent: Option<f64>,
337    /// Minimum retries per second allowed regardless of budget (default: 10).
338    /// Ensures low-traffic backends can still retry.
339    #[serde(default = "default_min_retries_per_sec")]
340    pub min_retries_per_sec: u32,
341}
342
343/// Passive health check / outlier detection configuration.
344///
345/// Tracks consecutive errors on live traffic and ejects unhealthy backends.
346#[derive(Debug, Clone, Deserialize, Serialize)]
347pub struct OutlierDetectionConfig {
348    /// Number of consecutive errors before ejecting (default: 5)
349    #[serde(default = "default_consecutive_errors")]
350    pub consecutive_errors: u32,
351    /// Evaluation interval in seconds (default: 10)
352    #[serde(default = "default_interval_seconds")]
353    pub interval_seconds: u64,
354    /// How long to eject in seconds (default: 30)
355    #[serde(default = "default_base_ejection_seconds")]
356    pub base_ejection_seconds: u64,
357    /// Maximum percentage of backends that can be ejected (default: 50)
358    #[serde(default = "default_max_ejection_percent")]
359    pub max_ejection_percent: u32,
360}
361
362/// Per-tool argument injection configuration.
363#[derive(Debug, Clone, Deserialize, Serialize)]
364pub struct InjectArgsConfig {
365    /// Tool name (backend-local, without namespace prefix).
366    pub tool: String,
367    /// Arguments to inject. Merged into the tool call arguments.
368    /// Does not overwrite existing keys unless `overwrite` is true.
369    pub args: serde_json::Map<String, serde_json::Value>,
370    /// Whether injected args should overwrite existing values (default: false).
371    #[serde(default)]
372    pub overwrite: bool,
373}
374
375/// Per-tool parameter override configuration.
376///
377/// Allows hiding parameters from tool schemas (injecting defaults instead),
378/// and renaming parameters to present a more domain-specific interface.
379///
380/// # Configuration
381///
382/// ```toml
383/// [[backends.param_overrides]]
384/// tool = "list_directory"
385/// hide = ["path"]
386/// defaults = { path = "/home/docs" }
387/// rename = { recursive = "deep_search" }
388/// ```
389#[derive(Debug, Clone, Deserialize, Serialize)]
390pub struct ParamOverrideConfig {
391    /// Tool name (backend-local, without namespace prefix).
392    pub tool: String,
393    /// Parameters to hide from the tool's input schema.
394    /// Hidden parameters are removed from the schema and their values
395    /// are injected from `defaults` at call time.
396    #[serde(default)]
397    pub hide: Vec<String>,
398    /// Default values for hidden parameters. These are injected into
399    /// tool call arguments when the parameter is hidden.
400    #[serde(default)]
401    pub defaults: serde_json::Map<String, serde_json::Value>,
402    /// Parameter renames: maps original parameter names to new names.
403    /// The schema exposes the new name; at call time the new name is
404    /// mapped back to the original before forwarding to the backend.
405    #[serde(default)]
406    pub rename: HashMap<String, String>,
407}
408
409/// Request hedging configuration.
410///
411/// Sends parallel redundant requests to reduce tail latency. If the primary
412/// request hasn't completed after `delay_ms`, a hedge request is fired.
413/// The first successful response wins.
414#[derive(Debug, Clone, Deserialize, Serialize)]
415pub struct HedgingConfig {
416    /// Delay in milliseconds before sending a hedge request (default: 200).
417    /// Set to 0 for parallel mode (all requests fire immediately).
418    #[serde(default = "default_hedge_delay_ms")]
419    pub delay_ms: u64,
420    /// Maximum number of additional hedge requests (default: 1)
421    #[serde(default = "default_max_hedges")]
422    pub max_hedges: usize,
423}
424
425/// Inbound authentication configuration.
426#[derive(Debug, Deserialize, Serialize)]
427#[serde(tag = "type", rename_all = "lowercase")]
428pub enum AuthConfig {
429    /// Static bearer token authentication.
430    Bearer {
431        /// Accepted bearer tokens (all tools allowed).
432        #[serde(default)]
433        tokens: Vec<String>,
434        /// Tokens with per-token tool access control.
435        #[serde(default)]
436        scoped_tokens: Vec<BearerTokenConfig>,
437    },
438    /// JWT authentication via JWKS endpoint.
439    Jwt {
440        /// Expected token issuer (`iss` claim).
441        issuer: String,
442        /// Expected token audience (`aud` claim).
443        audience: String,
444        /// URL to fetch the JSON Web Key Set for token verification.
445        jwks_uri: String,
446        /// RBAC role definitions
447        #[serde(default)]
448        roles: Vec<RoleConfig>,
449        /// Map JWT claims to roles
450        role_mapping: Option<RoleMappingConfig>,
451    },
452    /// OAuth 2.1 authentication with auto-discovery and token introspection.
453    ///
454    /// Discovers authorization server endpoints (JWKS URI, introspection endpoint)
455    /// from the issuer URL via RFC 8414 metadata. Supports JWT validation,
456    /// opaque token introspection, or both.
457    OAuth {
458        /// Authorization server issuer URL (e.g. `https://accounts.google.com`).
459        /// Used for RFC 8414 metadata discovery.
460        issuer: String,
461        /// Expected token audience (`aud` claim).
462        audience: String,
463        /// OAuth client ID (required for token introspection).
464        #[serde(default)]
465        client_id: Option<String>,
466        /// OAuth client secret (required for token introspection).
467        /// Supports `${ENV_VAR}` syntax.
468        #[serde(default)]
469        client_secret: Option<String>,
470        /// Token validation strategy.
471        #[serde(default)]
472        token_validation: TokenValidationStrategy,
473        /// Override the auto-discovered JWKS URI.
474        #[serde(default)]
475        jwks_uri: Option<String>,
476        /// Override the auto-discovered introspection endpoint.
477        #[serde(default)]
478        introspection_endpoint: Option<String>,
479        /// Required scopes for access (space-delimited).
480        #[serde(default)]
481        required_scopes: Vec<String>,
482        /// RBAC role definitions.
483        #[serde(default)]
484        roles: Vec<RoleConfig>,
485        /// Map JWT/token claims to roles.
486        role_mapping: Option<RoleMappingConfig>,
487    },
488}
489
490/// Token validation strategy for OAuth 2.1 auth.
491#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
492#[serde(rename_all = "lowercase")]
493pub enum TokenValidationStrategy {
494    /// Validate JWTs locally via JWKS (default). Fast, no network call per request.
495    #[default]
496    Jwt,
497    /// Validate tokens via the authorization server's introspection endpoint (RFC 7662).
498    /// Works with opaque tokens. Requires `client_id` and `client_secret`.
499    Introspection,
500    /// Try JWT validation first; fall back to introspection for non-JWT tokens.
501    /// Requires `client_id` and `client_secret`.
502    Both,
503}
504
505/// Per-token configuration for bearer auth with optional tool scoping.
506///
507/// Allows restricting which tools each bearer token can access, bridging
508/// the gap between all-or-nothing bearer auth and full JWT/RBAC.
509///
510/// # Examples
511///
512/// ```
513/// use mcp_proxy::config::BearerTokenConfig;
514///
515/// let frontend = BearerTokenConfig {
516///     token: "frontend-token".into(),
517///     allow_tools: vec!["files/read_file".into()],
518///     deny_tools: vec![],
519/// };
520///
521/// let admin = BearerTokenConfig {
522///     token: "admin-token".into(),
523///     allow_tools: vec![],
524///     deny_tools: vec![],
525/// };
526/// ```
527#[derive(Debug, Clone, Deserialize, Serialize)]
528pub struct BearerTokenConfig {
529    /// The bearer token value. Supports `${ENV_VAR}` syntax.
530    pub token: String,
531    /// Tools this token can access (namespaced, e.g. "files/read_file").
532    /// Empty means all tools allowed.
533    #[serde(default)]
534    pub allow_tools: Vec<String>,
535    /// Tools this token cannot access.
536    #[serde(default)]
537    pub deny_tools: Vec<String>,
538}
539
540/// RBAC role definition.
541#[derive(Debug, Deserialize, Serialize)]
542pub struct RoleConfig {
543    /// Role name, referenced by `RoleMappingConfig`.
544    pub name: String,
545    /// Tools this role can access (namespaced, e.g. "files/read_file")
546    #[serde(default)]
547    pub allow_tools: Vec<String>,
548    /// Tools this role cannot access
549    #[serde(default)]
550    pub deny_tools: Vec<String>,
551}
552
553/// Maps JWT claim values to RBAC role names.
554#[derive(Debug, Deserialize, Serialize)]
555pub struct RoleMappingConfig {
556    /// JWT claim to read for role resolution (e.g. "scope", "role", "groups")
557    pub claim: String,
558    /// Map claim values to role names
559    pub mapping: HashMap<String, String>,
560}
561
562/// Tool alias: exposes a backend tool under a different name.
563#[derive(Debug, Deserialize, Serialize)]
564pub struct AliasConfig {
565    /// Original tool name (backend-local, without namespace prefix)
566    pub from: String,
567    /// New tool name to expose (will be namespaced as backend/to)
568    pub to: String,
569}
570
571/// Per-backend response cache configuration.
572#[derive(Debug, Deserialize, Serialize)]
573pub struct BackendCacheConfig {
574    /// TTL for cached resource reads in seconds (0 = disabled)
575    #[serde(default)]
576    pub resource_ttl_seconds: u64,
577    /// TTL for cached tool call results in seconds (0 = disabled)
578    #[serde(default)]
579    pub tool_ttl_seconds: u64,
580    /// Maximum number of cached entries per backend (default: 1000)
581    #[serde(default = "default_max_cache_entries")]
582    pub max_entries: u64,
583}
584
585/// Global cache backend configuration.
586///
587/// Controls which storage backend is used for response caching. Per-backend
588/// TTL and max_entries settings remain the same regardless of backend.
589///
590/// # Backends
591///
592/// - `"memory"` (default): In-process cache using moka. Fast, no external deps,
593///   but not shared across proxy instances.
594/// - `"redis"`: External Redis cache. Shared across instances. Requires the
595///   `redis-cache` feature.
596/// - `"sqlite"`: Local SQLite cache. Persistent across restarts. Requires the
597///   `sqlite-cache` feature.
598#[derive(Debug, Deserialize, Serialize, Clone)]
599pub struct CacheBackendConfig {
600    /// Cache backend type: "memory" (default), "redis", or "sqlite".
601    #[serde(default = "default_cache_backend")]
602    pub backend: String,
603    /// Connection URL for external backends (Redis or SQLite path).
604    pub url: Option<String>,
605    /// Key prefix for external cache entries (default: "mcp-proxy:").
606    #[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/// Performance tuning options.
629#[derive(Debug, Default, Deserialize, Serialize)]
630pub struct PerformanceConfig {
631    /// Deduplicate identical concurrent tool calls and resource reads
632    #[serde(default)]
633    pub coalesce_requests: bool,
634}
635
636/// Security policies.
637#[derive(Debug, Default, Deserialize, Serialize)]
638pub struct SecurityConfig {
639    /// Maximum size of tool call arguments in bytes (default: unlimited)
640    pub max_argument_size: Option<usize>,
641}
642
643/// Logging, metrics, and distributed tracing configuration.
644#[derive(Debug, Default, Deserialize, Serialize)]
645pub struct ObservabilityConfig {
646    /// Enable audit logging of all MCP requests (default: false).
647    #[serde(default)]
648    pub audit: bool,
649    /// Log level filter (default: "info").
650    #[serde(default = "default_log_level")]
651    pub log_level: String,
652    /// Emit structured JSON logs (default: false).
653    #[serde(default)]
654    pub json_logs: bool,
655    /// Prometheus metrics configuration.
656    #[serde(default)]
657    pub metrics: MetricsConfig,
658    /// OpenTelemetry distributed tracing configuration.
659    #[serde(default)]
660    pub tracing: TracingConfig,
661    /// Structured access logging configuration.
662    #[serde(default)]
663    pub access_log: AccessLogConfig,
664}
665
666/// Structured access log configuration.
667#[derive(Debug, Default, Deserialize, Serialize)]
668pub struct AccessLogConfig {
669    /// Enable structured access logging (default: false).
670    #[serde(default)]
671    pub enabled: bool,
672}
673
674/// Prometheus metrics configuration.
675#[derive(Debug, Default, Deserialize, Serialize)]
676pub struct MetricsConfig {
677    /// Enable Prometheus metrics at `/admin/metrics` (default: false).
678    #[serde(default)]
679    pub enabled: bool,
680}
681
682/// OpenTelemetry distributed tracing configuration.
683#[derive(Debug, Default, Deserialize, Serialize)]
684pub struct TracingConfig {
685    /// Enable OTLP trace export (default: false).
686    #[serde(default)]
687    pub enabled: bool,
688    /// OTLP endpoint (default: http://localhost:4317)
689    #[serde(default = "default_otlp_endpoint")]
690    pub endpoint: String,
691    /// Service name for traces (default: "mcp-proxy")
692    #[serde(default = "default_service_name")]
693    pub service_name: String,
694}
695
696// Defaults
697
698fn 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/// Resolved filter rules for a backend's capabilities.
803#[derive(Debug, Clone)]
804pub struct BackendFilter {
805    /// Namespace prefix (e.g. "db/") this filter applies to.
806    pub namespace: String,
807    /// Filter for tool names.
808    pub tool_filter: NameFilter,
809    /// Filter for resource URIs.
810    pub resource_filter: NameFilter,
811    /// Filter for prompt names.
812    pub prompt_filter: NameFilter,
813    /// Hide tools with `destructive_hint = true`.
814    pub hide_destructive: bool,
815    /// Only allow tools with `read_only_hint = true`.
816    pub read_only_only: bool,
817}
818
819/// A compiled pattern for name matching -- either a glob or a regex.
820///
821/// Constructed internally by [`NameFilter::allow_list`] and
822/// [`NameFilter::deny_list`].
823#[derive(Debug, Clone)]
824pub enum CompiledPattern {
825    /// A glob pattern (matched via `glob_match`).
826    Glob(String),
827    /// A pre-compiled regex pattern (from `re:` prefix).
828    Regex(regex::Regex),
829}
830
831impl CompiledPattern {
832    /// Compile a pattern string. Patterns prefixed with `re:` are treated as
833    /// regular expressions; all others are treated as glob patterns.
834    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    /// Check if this pattern matches the given name.
845    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/// A name-based allow/deny filter.
854///
855/// Patterns support two syntaxes:
856/// - **Glob** (default): `*` matches any sequence, `?` matches one character.
857/// - **Regex** (`re:` prefix): e.g. `re:^list_.*$` uses the `regex` crate.
858///
859/// Regex patterns are compiled once at config parse time.
860#[derive(Debug, Clone)]
861pub enum NameFilter {
862    /// No filtering -- everything passes.
863    PassAll,
864    /// Only items matching at least one pattern are allowed.
865    AllowList(Vec<CompiledPattern>),
866    /// Items matching any pattern are denied.
867    DenyList(Vec<CompiledPattern>),
868}
869
870impl NameFilter {
871    /// Build an allow-list filter from raw pattern strings.
872    ///
873    /// Patterns prefixed with `re:` are compiled as regular expressions;
874    /// all others are treated as glob patterns.
875    ///
876    /// # Errors
877    ///
878    /// Returns an error if any `re:` pattern contains invalid regex syntax.
879    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    /// Build a deny-list filter from raw pattern strings.
888    ///
889    /// Patterns prefixed with `re:` are compiled as regular expressions;
890    /// all others are treated as glob patterns.
891    ///
892    /// # Errors
893    ///
894    /// Returns an error if any `re:` pattern contains invalid regex syntax.
895    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    /// Check if a capability name is allowed by this filter.
904    ///
905    /// Supports glob patterns (`*`, `?`) and regex patterns (`re:` prefix).
906    /// Exact strings match themselves.
907    ///
908    /// # Examples
909    ///
910    /// ```
911    /// use mcp_proxy::config::NameFilter;
912    ///
913    /// let filter = NameFilter::deny_list(["delete".to_string()]).unwrap();
914    /// assert!(filter.allows("read"));
915    /// assert!(!filter.allows("delete"));
916    ///
917    /// let filter = NameFilter::allow_list(["read".to_string()]).unwrap();
918    /// assert!(filter.allows("read"));
919    /// assert!(!filter.allows("write"));
920    ///
921    /// assert!(NameFilter::PassAll.allows("anything"));
922    ///
923    /// // Glob patterns
924    /// let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
925    /// assert!(filter.allows("read_file"));
926    /// assert!(filter.allows("write_file"));
927    /// assert!(!filter.allows("query"));
928    ///
929    /// // Regex patterns
930    /// let filter = NameFilter::allow_list(["re:^list_.*$".to_string()]).unwrap();
931    /// assert!(filter.allows("list_files"));
932    /// assert!(!filter.allows("get_files"));
933    /// ```
934    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    /// Build a [`BackendFilter`] from this backend's expose/hide lists.
945    /// Returns `None` if no filtering is configured.
946    ///
947    /// Canary and failover backends automatically hide all capabilities so
948    /// their tools don't appear in `ListTools` responses (traffic reaches
949    /// them via routing middleware, not direct tool calls).
950    pub fn build_filter(&self, separator: &str) -> Result<Option<BackendFilter>> {
951        // Canary and failover backends hide all capabilities -- tools are
952        // accessed via routing middleware rewriting the primary namespace.
953        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        // Only create a filter if at least one dimension has filtering
989        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    /// Load and validate a config from a file path.
1011    ///
1012    /// If `import_backends` is set in the config, backends from the referenced
1013    /// `.mcp.json` file are merged (TOML backends take precedence on name conflicts).
1014    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        // Import backends from .mcp.json if configured
1032        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                // Resolve relative to config file directory
1035                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    /// Build a minimal `ProxyConfig` from a `.mcp.json` file.
1058    ///
1059    /// This is a convenience mode for quick local development. The proxy name
1060    /// is derived from the file's parent directory (or the filename itself),
1061    /// and the server listens on `127.0.0.1:8080` with no middleware or auth.
1062    ///
1063    /// # Examples
1064    ///
1065    /// ```no_run
1066    /// use std::path::Path;
1067    /// use mcp_proxy::ProxyConfig;
1068    ///
1069    /// let config = ProxyConfig::from_mcp_json(Path::new(".mcp.json")).unwrap();
1070    /// assert_eq!(config.proxy.listen.host, "127.0.0.1");
1071    /// assert_eq!(config.proxy.listen.port, 8080);
1072    /// ```
1073    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        // Derive a proxy name from the parent directory or filename
1078        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    /// Parse and validate a config from a TOML string.
1116    ///
1117    /// # Examples
1118    ///
1119    /// ```
1120    /// use mcp_proxy::ProxyConfig;
1121    ///
1122    /// let config = ProxyConfig::parse(r#"
1123    ///     [proxy]
1124    ///     name = "my-proxy"
1125    ///     [proxy.listen]
1126    ///
1127    ///     [[backends]]
1128    ///     name = "echo"
1129    ///     transport = "stdio"
1130    ///     command = "echo"
1131    /// "#).unwrap();
1132    ///
1133    /// assert_eq!(config.proxy.name, "my-proxy");
1134    /// assert_eq!(config.backends.len(), 1);
1135    /// ```
1136    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    /// Parse and validate a config from a YAML string.
1143    ///
1144    /// # Examples
1145    ///
1146    /// ```
1147    /// use mcp_proxy::ProxyConfig;
1148    ///
1149    /// let config = ProxyConfig::parse_yaml(r#"
1150    /// proxy:
1151    ///   name: my-proxy
1152    ///   listen:
1153    ///     host: "127.0.0.1"
1154    ///     port: 8080
1155    /// backends:
1156    ///   - name: echo
1157    ///     transport: stdio
1158    ///     command: echo
1159    /// "#).unwrap();
1160    ///
1161    /// assert_eq!(config.proxy.name, "my-proxy");
1162    /// ```
1163    #[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        // Validate cache backend
1176        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        // Validate global rate limit
1213        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        // Validate bearer auth config
1223        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            // Check for duplicate tokens across both lists
1234            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        // Validate OAuth config
1255        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        // Check for duplicate backend names
1271        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        // Validate mirror_of references
1351        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        // Validate failover_for references
1371        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        // Validate composite tools
1390        {
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        // Validate canary_of references
1409        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        // Validate tool_exposure = "search" requires the discovery feature
1431        #[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        // Validate param_overrides
1440        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                // Hidden params that have no default are a warning-level concern,
1457                // but renamed params that conflict with hide are an error.
1458                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                // Check for rename target conflicts (two originals mapping to same name)
1470                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    /// Resolve environment variable references in config values.
1489    /// Replaces `${VAR_NAME}` with the value of the environment variable.
1490    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        // Resolve env vars in auth config
1508        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        // Resolve env vars in OAuth config
1533        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    /// Check for `${VAR}` references where the environment variable is not set.
1543    ///
1544    /// Returns a list of human-readable warning strings. This method does not
1545    /// modify the config or fail -- it only reports potential issues.
1546    ///
1547    /// # Example
1548    ///
1549    /// ```
1550    /// use mcp_proxy::config::ProxyConfig;
1551    ///
1552    /// let toml = r#"
1553    /// [proxy]
1554    /// name = "test"
1555    /// [proxy.listen]
1556    ///
1557    /// [[backends]]
1558    /// name = "svc"
1559    /// transport = "stdio"
1560    /// command = "echo"
1561    /// bearer_token = "${UNSET_VAR}"
1562    /// "#;
1563    ///
1564    /// let config = ProxyConfig::parse(toml).unwrap();
1565    /// let warnings = config.check_env_vars();
1566    /// assert!(!warnings.is_empty());
1567    /// ```
1568    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            // backend.bearer_token
1582            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            // backend.env values
1591            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"); // default
1663        assert_eq!(config.proxy.separator, "/"); // default
1664        assert_eq!(config.proxy.listen.host, "127.0.0.1"); // default
1665        assert_eq!(config.proxy.listen.port, 8080); // default
1666        assert_eq!(config.proxy.shutdown_timeout_seconds, 30); // default
1667        assert!(!config.proxy.hot_reload); // default false
1668        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    // ========================================================================
1876    // Validation errors
1877    // ========================================================================
1878
1879    #[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    // ========================================================================
2068    // Env var resolution
2069    // ========================================================================
2070
2071    #[test]
2072    fn test_resolve_env_vars() {
2073        // SAFETY: test runs single-threaded, no other threads reading this var
2074        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        // SAFETY: same as above
2104        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    // ========================================================================
2362    // Capability filter building
2363    // ========================================================================
2364
2365    #[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        // Regex without anchors matches substrings
2628        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        // SAFETY: test runs single-threaded
3285        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        // SAFETY: same as above
3304        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        // Name derived from parent directory
3462        assert_eq!(config.proxy.name, "my-project");
3463        // Sensible defaults
3464        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        // No auth or middleware
3469        assert!(config.auth.is_none());
3470        assert!(config.composite_tools.is_empty());
3471        // Backends imported
3472        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}