Skip to main content

mcp_proxy/
config.rs

1//! Proxy configuration types and parsing.
2//!
3//! All proxy behavior is driven by [`ProxyConfig`], typically loaded from a TOML
4//! file via [`ProxyConfig::load()`]. YAML is also supported when the `yaml` feature
5//! is enabled. The config can also be built programmatically via [`crate::ProxyBuilder`].
6//!
7//! # Config Structure
8//!
9//! ```toml
10//! [proxy]                    # Core settings (name, listen, separator)
11//! [[backends]]               # Backend MCP servers (stdio, http, websocket)
12//! [auth]                     # Authentication (bearer, jwt, oauth)
13//! [performance]              # Request coalescing
14//! [security]                 # Argument size limits, admin token
15//! [cache]                    # Cache backend (memory, redis, sqlite)
16//! [observability]            # Logging, metrics, tracing
17//! [[composite_tools]]        # Fan-out tools
18//! ```
19//!
20//! # Proxy Settings
21//!
22//! ```toml
23//! [proxy]
24//! name = "my-proxy"              # Proxy name in MCP server info
25//! version = "1.0.0"              # Version string (default: "0.1.0")
26//! separator = "/"                # Namespace separator (default: "/")
27//! hot_reload = true              # Watch config file for changes
28//! tool_discovery = true          # Enable BM25 search (adds proxy/search_tools)
29//! tool_exposure = "search"       # "direct" (default) or "search" (meta-tools only)
30//! shutdown_timeout_seconds = 30  # Graceful shutdown timeout
31//! import_backends = ".mcp.json"  # Import backends from Claude/Cursor config
32//!
33//! [proxy.listen]
34//! host = "0.0.0.0"
35//! port = 8080
36//!
37//! [proxy.rate_limit]             # Global rate limit (all backends)
38//! requests = 1000
39//! period_seconds = 1
40//! ```
41//!
42//! # Backend Configuration
43//!
44//! Each backend is an MCP server the proxy routes to. The `name` becomes the
45//! namespace prefix for all tools/resources/prompts from that backend.
46//!
47//! ## Transports
48//!
49//! ```toml
50//! # Subprocess (stdin/stdout)
51//! [[backends]]
52//! name = "files"
53//! transport = "stdio"
54//! command = "npx"
55//! args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
56//! [backends.env]
57//! NODE_ENV = "production"
58//!
59//! # Remote HTTP server
60//! [[backends]]
61//! name = "api"
62//! transport = "http"
63//! url = "http://mcp-server:8080"
64//! bearer_token = "${API_TOKEN}"    # ${VAR} syntax for env vars
65//! headers = { "X-API-Key" = "${API_KEY}", "X-Tenant" = "acme" }  # custom outbound headers
66//! forward_auth = true              # Forward client's auth token
67//!
68//! # WebSocket server
69//! [[backends]]
70//! name = "ws"
71//! transport = "websocket"
72//! url = "wss://mcp.example.com/ws"
73//! ```
74//!
75//! ## Per-Backend Middleware
76//!
77//! All middleware is optional and configured per-backend:
78//!
79//! ```toml
80//! [[backends]]
81//! name = "api"
82//! transport = "http"
83//! url = "http://api:8080"
84//!
85//! # Timeout
86//! [backends.timeout]
87//! seconds = 30
88//!
89//! # Circuit breaker (failure-rate based)
90//! [backends.circuit_breaker]
91//! failure_rate_threshold = 0.5       # Trip at 50% failure rate
92//! minimum_calls = 5
93//! wait_duration_seconds = 30
94//! permitted_calls_in_half_open = 3
95//!
96//! # Rate limit
97//! [backends.rate_limit]
98//! requests = 100
99//! period_seconds = 1
100//!
101//! # Retry with exponential backoff
102//! [backends.retry]
103//! max_retries = 3
104//! initial_backoff_ms = 100
105//! max_backoff_ms = 5000
106//! budget_percent = 20.0              # Max 20% of requests can be retries
107//!
108//! # Request hedging (tail latency)
109//! [backends.hedging]
110//! delay_ms = 200
111//! max_hedges = 1
112//!
113//! # Outlier detection (passive health)
114//! [backends.outlier_detection]
115//! consecutive_errors = 5
116//! interval_seconds = 10
117//! base_ejection_seconds = 30
118//!
119//! # Response caching
120//! [backends.cache]
121//! resource_ttl_seconds = 300
122//! tool_ttl_seconds = 60
123//! max_entries = 1000
124//!
125//! # Concurrency limit
126//! [backends.concurrency]
127//! max_concurrent = 10
128//! ```
129//!
130//! ## Capability Filtering
131//!
132//! Control which tools, resources, and prompts are exposed:
133//!
134//! ```toml
135//! # Allowlist (mutually exclusive with hide_*)
136//! expose_tools = ["read_file", "list_*", "re:^search_.*$"]
137//! # Or denylist
138//! hide_tools = ["delete_*", "re:^admin_"]
139//! # Annotation-based
140//! hide_destructive = true    # Hide tools with destructive_hint
141//! read_only_only = true      # Only expose read_only_hint tools
142//! ```
143//!
144//! ## Traffic Routing
145//!
146//! ```toml
147//! # Failover (priority-ordered chain)
148//! [[backends]]
149//! name = "api-backup"
150//! transport = "http"
151//! url = "http://backup:8080"
152//! failover_for = "api"
153//! priority = 1                   # Lower = tried first
154//!
155//! # Canary routing (weight-based split)
156//! [[backends]]
157//! name = "api-v2"
158//! transport = "http"
159//! url = "http://api-v2:8080"
160//! canary_of = "api"
161//! weight = 10                    # 10% of traffic
162//!
163//! # Traffic mirroring (shadow, fire-and-forget)
164//! [[backends]]
165//! name = "api-mirror"
166//! transport = "http"
167//! url = "http://mirror:8080"
168//! mirror_of = "api"
169//! mirror_percent = 5
170//! ```
171//!
172//! # Authentication
173//!
174//! ```toml
175//! # Bearer tokens (simple)
176//! [auth]
177//! type = "bearer"
178//! tokens = ["${TOKEN}"]
179//!
180//! # With per-token scoping
181//! [[auth.scoped_tokens]]
182//! token = "${READONLY_TOKEN}"
183//! allow_tools = ["api/read_*"]
184//!
185//! # JWT/JWKS
186//! [auth]
187//! type = "jwt"
188//! issuer = "https://auth.example.com"
189//! audience = "mcp-proxy"
190//! jwks_uri = "https://auth.example.com/.well-known/jwks.json"
191//!
192//! # OAuth 2.1 (auto-discovery)
193//! [auth]
194//! type = "oauth"
195//! issuer = "https://accounts.google.com"
196//! audience = "mcp-proxy"
197//! token_validation = "both"      # jwt + introspection fallback
198//! client_id = "my-client"
199//! client_secret = "${OAUTH_SECRET}"
200//! ```
201//!
202//! # Security
203//!
204//! ```toml
205//! [security]
206//! max_argument_size = 1048576    # 1MB limit on tool call arguments
207//! admin_token = "${ADMIN_TOKEN}" # Protect admin API (falls back to proxy auth)
208//! ```
209//!
210//! # Cache Backend
211//!
212//! ```toml
213//! [cache]
214//! backend = "redis"              # "memory" (default), "redis", "sqlite"
215//! url = "redis://localhost:6379"
216//! prefix = "mcp-proxy:"
217//! ```
218//!
219//! # Environment Variables
220//!
221//! Any config value can reference environment variables with `${VAR_NAME}` syntax.
222//! The `--check` flag warns about unset variables. Supported in: `bearer_token`,
223//! `env` values, auth `tokens`, `scoped_tokens[].token`, `client_secret`,
224//! `admin_token`.
225
226use std::collections::HashMap;
227use std::collections::HashSet;
228use std::path::Path;
229
230use anyhow::{Context, Result};
231use serde::{Deserialize, Serialize};
232
233/// Top-level proxy configuration, typically loaded from a TOML file.
234#[derive(Debug, Deserialize, Serialize)]
235pub struct ProxyConfig {
236    /// Core proxy settings (name, version, listen address).
237    pub proxy: ProxySettings,
238    /// Backend MCP servers to proxy.
239    #[serde(default)]
240    pub backends: Vec<BackendConfig>,
241    /// Inbound authentication configuration.
242    pub auth: Option<AuthConfig>,
243    /// Performance tuning options.
244    #[serde(default)]
245    pub performance: PerformanceConfig,
246    /// Security policies.
247    #[serde(default)]
248    pub security: SecurityConfig,
249    /// Global cache backend configuration.
250    #[serde(default)]
251    pub cache: CacheBackendConfig,
252    /// Logging, metrics, and tracing configuration.
253    #[serde(default)]
254    pub observability: ObservabilityConfig,
255    /// Composite tools that fan out to multiple backend tools.
256    #[serde(default)]
257    pub composite_tools: Vec<CompositeToolConfig>,
258    /// Path to the config file (set during load, not serialized).
259    #[serde(skip)]
260    pub source_path: Option<std::path::PathBuf>,
261}
262
263/// Fan-out strategy for composite tools.
264#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
265#[serde(rename_all = "lowercase")]
266pub enum CompositeStrategy {
267    /// Execute all tools concurrently using `tokio::JoinSet`.
268    #[default]
269    Parallel,
270}
271
272/// Configuration for a composite tool that fans out to multiple backend tools.
273///
274/// Composite tools appear in `ListTools` responses alongside regular tools.
275/// When called, the proxy dispatches the request to every tool in [`tools`](Self::tools)
276/// concurrently (for `parallel` strategy) and aggregates all results.
277///
278/// # Example
279///
280/// ```toml
281/// [[composite_tools]]
282/// name = "search_all"
283/// description = "Search across all knowledge sources"
284/// tools = ["github/search", "jira/search", "docs/search"]
285/// strategy = "parallel"
286/// ```
287#[derive(Debug, Clone, Deserialize, Serialize)]
288pub struct CompositeToolConfig {
289    /// Name of the composite tool as it appears to MCP clients.
290    pub name: String,
291    /// Human-readable description of the composite tool.
292    pub description: String,
293    /// Fully-qualified backend tool names to fan out to (e.g. `"github/search"`).
294    pub tools: Vec<String>,
295    /// Execution strategy (default: `parallel`).
296    #[serde(default)]
297    pub strategy: CompositeStrategy,
298}
299
300/// Core proxy identity and server settings.
301#[derive(Debug, Deserialize, Serialize)]
302pub struct ProxySettings {
303    /// Proxy name, used in MCP server info.
304    pub name: String,
305    /// Proxy version, used in MCP server info (default: "0.1.0").
306    #[serde(default = "default_version")]
307    pub version: String,
308    /// Namespace separator between backend name and tool/resource name (default: "/").
309    #[serde(default = "default_separator")]
310    pub separator: String,
311    /// HTTP listen address.
312    pub listen: ListenConfig,
313    /// Optional instructions text sent to MCP clients.
314    pub instructions: Option<String>,
315    /// Graceful shutdown timeout in seconds (default: 30)
316    #[serde(default = "default_shutdown_timeout")]
317    pub shutdown_timeout_seconds: u64,
318    /// Enable hot reload: watch config file for new backends
319    #[serde(default)]
320    pub hot_reload: bool,
321    /// Import backends from a `.mcp.json` file. Backends defined in the TOML
322    /// config take precedence over imported ones with the same name.
323    pub import_backends: Option<String>,
324    /// Global rate limit applied to all requests before per-backend dispatch.
325    pub rate_limit: Option<GlobalRateLimitConfig>,
326    /// Enable BM25-based tool discovery and search (default: false).
327    /// Adds `proxy/search_tools`, `proxy/similar_tools`, and
328    /// `proxy/tool_categories` tools for finding tools across backends.
329    #[serde(default)]
330    pub tool_discovery: bool,
331    /// How backend tools are exposed to MCP clients (default: "direct").
332    ///
333    /// - `direct` -- all tools appear in `ListTools` responses (default behavior).
334    /// - `search` -- only `proxy/` meta-tools are listed; backend tools are
335    ///   discoverable via `proxy/search_tools` and invokable via `proxy/call_tool`.
336    ///   Useful when aggregating 100+ tools that would overwhelm LLM context.
337    ///   Implies `tool_discovery = true`.
338    #[serde(default)]
339    pub tool_exposure: ToolExposure,
340}
341
342/// How backend tools are exposed to MCP clients.
343///
344/// Controls whether individual backend tools appear in `ListTools` responses
345/// or are hidden behind discovery meta-tools.
346///
347/// # Examples
348///
349/// ```
350/// use mcp_proxy::config::ToolExposure;
351///
352/// let direct: ToolExposure = serde_json::from_str("\"direct\"").unwrap();
353/// assert_eq!(direct, ToolExposure::Direct);
354///
355/// let search: ToolExposure = serde_json::from_str("\"search\"").unwrap();
356/// assert_eq!(search, ToolExposure::Search);
357/// ```
358#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
359#[serde(rename_all = "lowercase")]
360pub enum ToolExposure {
361    /// All backend tools appear in `ListTools` responses.
362    #[default]
363    Direct,
364    /// Only `proxy/` namespace meta-tools appear. Backend tools are hidden
365    /// from listings but remain invokable via `proxy/call_tool`.
366    Search,
367}
368
369/// Global rate limit configuration applied across all backends.
370#[derive(Debug, Deserialize, Serialize, Clone)]
371pub struct GlobalRateLimitConfig {
372    /// Maximum number of requests allowed per period.
373    pub requests: usize,
374    /// Period length in seconds (default: 1).
375    #[serde(default = "default_rate_period")]
376    pub period_seconds: u64,
377}
378
379/// HTTP server listen address.
380#[derive(Debug, Deserialize, Serialize)]
381pub struct ListenConfig {
382    /// Bind host (default: "127.0.0.1").
383    #[serde(default = "default_host")]
384    pub host: String,
385    /// Bind port (default: 8080).
386    #[serde(default = "default_port")]
387    pub port: u16,
388}
389
390/// Configuration for a single backend MCP server.
391#[derive(Debug, Deserialize, Serialize)]
392#[serde(deny_unknown_fields)]
393pub struct BackendConfig {
394    /// Unique backend name, used as the namespace prefix for its tools/resources.
395    pub name: String,
396    /// Transport protocol to use when connecting to this backend.
397    pub transport: TransportType,
398    /// Command for stdio backends
399    pub command: Option<String>,
400    /// Arguments for stdio backends
401    #[serde(default)]
402    pub args: Vec<String>,
403    /// URL for HTTP backends
404    pub url: Option<String>,
405    /// Environment variables for subprocess backends
406    #[serde(default)]
407    pub env: HashMap<String, String>,
408    /// Per-backend timeout
409    pub timeout: Option<TimeoutConfig>,
410    /// Per-backend circuit breaker
411    pub circuit_breaker: Option<CircuitBreakerConfig>,
412    /// Per-backend rate limit
413    pub rate_limit: Option<RateLimitConfig>,
414    /// Per-backend concurrency limit
415    pub concurrency: Option<ConcurrencyConfig>,
416    /// Per-backend retry policy
417    pub retry: Option<RetryConfig>,
418    /// Per-backend outlier detection (passive health checks)
419    pub outlier_detection: Option<OutlierDetectionConfig>,
420    /// Per-backend request hedging (parallel redundant requests)
421    pub hedging: Option<HedgingConfig>,
422    /// Mirror traffic from another backend (fire-and-forget).
423    /// Set to the name of the source backend to mirror.
424    pub mirror_of: Option<String>,
425    /// Percentage of requests to mirror (1-100, default: 100).
426    #[serde(default = "default_mirror_percent")]
427    pub mirror_percent: u32,
428    /// Per-backend cache policy
429    pub cache: Option<BackendCacheConfig>,
430    /// Static bearer token for authenticating to this backend (HTTP only).
431    /// Supports `${ENV_VAR}` syntax for env var resolution.
432    pub bearer_token: Option<String>,
433    /// Custom headers sent on every request to this backend (HTTP/WebSocket only).
434    /// Values support `${ENV_VAR}` syntax for env var resolution.
435    /// An explicit `Authorization` header takes precedence over `bearer_token`.
436    #[serde(default)]
437    pub headers: HashMap<String, String>,
438    /// Forward the client's inbound auth token to this backend.
439    /// Only works with HTTP backends when the proxy has auth enabled.
440    #[serde(default)]
441    pub forward_auth: bool,
442    /// Tool aliases: rename tools exposed by this backend
443    #[serde(default)]
444    pub aliases: Vec<AliasConfig>,
445    /// Default arguments injected into all tool calls for this backend.
446    /// Merged into tool call arguments (does not overwrite existing keys).
447    #[serde(default)]
448    pub default_args: serde_json::Map<String, serde_json::Value>,
449    /// Per-tool argument injection rules.
450    #[serde(default)]
451    pub inject_args: Vec<InjectArgsConfig>,
452    /// Per-tool parameter overrides: hide, rename, and inject defaults.
453    #[serde(default)]
454    pub param_overrides: Vec<ParamOverrideConfig>,
455    /// Capability filtering: only expose these tools (allowlist)
456    #[serde(default)]
457    pub expose_tools: Vec<String>,
458    /// Capability filtering: hide these tools (denylist)
459    #[serde(default)]
460    pub hide_tools: Vec<String>,
461    /// Capability filtering: only expose these resources (allowlist, by URI)
462    #[serde(default)]
463    pub expose_resources: Vec<String>,
464    /// Capability filtering: hide these resources (denylist, by URI)
465    #[serde(default)]
466    pub hide_resources: Vec<String>,
467    /// Capability filtering: only expose these prompts (allowlist)
468    #[serde(default)]
469    pub expose_prompts: Vec<String>,
470    /// Capability filtering: hide these prompts (denylist)
471    #[serde(default)]
472    pub hide_prompts: Vec<String>,
473    /// Hide tools annotated as destructive (`destructive_hint = true`).
474    #[serde(default)]
475    pub hide_destructive: bool,
476    /// Only expose tools annotated as read-only (`read_only_hint = true`).
477    #[serde(default)]
478    pub read_only_only: bool,
479    /// Failover: name of the primary backend this is a failover for.
480    /// When set, this backend's tools are hidden and requests are only
481    /// routed here when the primary returns an error.
482    pub failover_for: Option<String>,
483    /// Failover priority for ordering multiple failover backends.
484    /// Lower values are preferred (tried first). Default is 0.
485    /// When multiple backends declare `failover_for` the same primary,
486    /// they are tried in ascending priority order until one succeeds.
487    #[serde(default)]
488    pub priority: u32,
489    /// Canary routing: name of the primary backend this is a canary for.
490    /// When set, this backend's tools are hidden and requests targeting
491    /// the primary are probabilistically routed here based on weight.
492    pub canary_of: Option<String>,
493    /// Routing weight for canary deployments (default: 100).
494    /// Higher values receive proportionally more traffic.
495    #[serde(default = "default_weight")]
496    pub weight: u32,
497}
498
499/// Backend transport protocol.
500#[derive(Debug, Deserialize, Serialize)]
501#[serde(rename_all = "lowercase")]
502pub enum TransportType {
503    /// Subprocess communicating via stdin/stdout.
504    Stdio,
505    /// HTTP+SSE remote server.
506    Http,
507    /// WebSocket remote server.
508    Websocket,
509}
510
511/// Per-backend request timeout.
512#[derive(Debug, Deserialize, Serialize)]
513pub struct TimeoutConfig {
514    /// Timeout duration in seconds.
515    pub seconds: u64,
516}
517
518/// Per-backend circuit breaker configuration.
519#[derive(Debug, Deserialize, Serialize)]
520pub struct CircuitBreakerConfig {
521    /// Failure rate threshold (0.0-1.0) to trip open (default: 0.5)
522    #[serde(default = "default_failure_rate")]
523    pub failure_rate_threshold: f64,
524    /// Minimum number of calls before evaluating failure rate (default: 5)
525    #[serde(default = "default_min_calls")]
526    pub minimum_calls: usize,
527    /// Seconds to wait in open state before half-open (default: 30)
528    #[serde(default = "default_wait_duration")]
529    pub wait_duration_seconds: u64,
530    /// Number of permitted calls in half-open state (default: 3)
531    #[serde(default = "default_half_open_calls")]
532    pub permitted_calls_in_half_open: usize,
533}
534
535/// Per-backend rate limiting configuration.
536#[derive(Debug, Deserialize, Serialize)]
537pub struct RateLimitConfig {
538    /// Maximum requests per period
539    pub requests: usize,
540    /// Period in seconds (default: 1)
541    #[serde(default = "default_rate_period")]
542    pub period_seconds: u64,
543}
544
545/// Per-backend concurrency limit configuration.
546#[derive(Debug, Deserialize, Serialize)]
547pub struct ConcurrencyConfig {
548    /// Maximum concurrent requests.
549    pub max_concurrent: usize,
550}
551
552/// Per-backend retry policy with exponential backoff.
553#[derive(Debug, Clone, Deserialize, Serialize)]
554pub struct RetryConfig {
555    /// Maximum number of retry attempts (default: 3)
556    #[serde(default = "default_max_retries")]
557    pub max_retries: u32,
558    /// Initial backoff in milliseconds (default: 100)
559    #[serde(default = "default_initial_backoff_ms")]
560    pub initial_backoff_ms: u64,
561    /// Maximum backoff in milliseconds (default: 5000)
562    #[serde(default = "default_max_backoff_ms")]
563    pub max_backoff_ms: u64,
564    /// Maximum percentage of requests that can be retries (default: none / unlimited).
565    /// When set, prevents retry storms by capping retries as a fraction of total
566    /// request volume. Envoy uses 20% as a default. Evaluated over a 10-second
567    /// rolling window.
568    pub budget_percent: Option<f64>,
569    /// Minimum retries per second allowed regardless of budget (default: 10).
570    /// Ensures low-traffic backends can still retry.
571    #[serde(default = "default_min_retries_per_sec")]
572    pub min_retries_per_sec: u32,
573}
574
575/// Passive health check / outlier detection configuration.
576///
577/// Tracks consecutive errors on live traffic and ejects unhealthy backends.
578#[derive(Debug, Clone, Deserialize, Serialize)]
579pub struct OutlierDetectionConfig {
580    /// Number of consecutive errors before ejecting (default: 5)
581    #[serde(default = "default_consecutive_errors")]
582    pub consecutive_errors: u32,
583    /// Evaluation interval in seconds (default: 10)
584    #[serde(default = "default_interval_seconds")]
585    pub interval_seconds: u64,
586    /// How long to eject in seconds (default: 30)
587    #[serde(default = "default_base_ejection_seconds")]
588    pub base_ejection_seconds: u64,
589    /// Maximum percentage of backends that can be ejected (default: 50)
590    #[serde(default = "default_max_ejection_percent")]
591    pub max_ejection_percent: u32,
592}
593
594/// Per-tool argument injection configuration.
595#[derive(Debug, Clone, Deserialize, Serialize)]
596pub struct InjectArgsConfig {
597    /// Tool name (backend-local, without namespace prefix).
598    pub tool: String,
599    /// Arguments to inject. Merged into the tool call arguments.
600    /// Does not overwrite existing keys unless `overwrite` is true.
601    pub args: serde_json::Map<String, serde_json::Value>,
602    /// Whether injected args should overwrite existing values (default: false).
603    #[serde(default)]
604    pub overwrite: bool,
605}
606
607/// Per-tool parameter override configuration.
608///
609/// Allows hiding parameters from tool schemas (injecting defaults instead),
610/// and renaming parameters to present a more domain-specific interface.
611///
612/// # Configuration
613///
614/// ```toml
615/// [[backends.param_overrides]]
616/// tool = "list_directory"
617/// hide = ["path"]
618/// defaults = { path = "/home/docs" }
619/// rename = { recursive = "deep_search" }
620/// ```
621#[derive(Debug, Clone, Deserialize, Serialize)]
622pub struct ParamOverrideConfig {
623    /// Tool name (backend-local, without namespace prefix).
624    pub tool: String,
625    /// Parameters to hide from the tool's input schema.
626    /// Hidden parameters are removed from the schema and their values
627    /// are injected from `defaults` at call time.
628    #[serde(default)]
629    pub hide: Vec<String>,
630    /// Default values for hidden parameters. These are injected into
631    /// tool call arguments when the parameter is hidden.
632    #[serde(default)]
633    pub defaults: serde_json::Map<String, serde_json::Value>,
634    /// Parameter renames: maps original parameter names to new names.
635    /// The schema exposes the new name; at call time the new name is
636    /// mapped back to the original before forwarding to the backend.
637    #[serde(default)]
638    pub rename: HashMap<String, String>,
639}
640
641/// Request hedging configuration.
642///
643/// Sends parallel redundant requests to reduce tail latency. If the primary
644/// request hasn't completed after `delay_ms`, a hedge request is fired.
645/// The first successful response wins.
646#[derive(Debug, Clone, Deserialize, Serialize)]
647pub struct HedgingConfig {
648    /// Delay in milliseconds before sending a hedge request (default: 200).
649    /// Set to 0 for parallel mode (all requests fire immediately).
650    #[serde(default = "default_hedge_delay_ms")]
651    pub delay_ms: u64,
652    /// Maximum number of additional hedge requests (default: 1)
653    #[serde(default = "default_max_hedges")]
654    pub max_hedges: usize,
655}
656
657/// Inbound authentication configuration.
658#[derive(Debug, Deserialize, Serialize)]
659#[serde(tag = "type", rename_all = "lowercase")]
660pub enum AuthConfig {
661    /// Static bearer token authentication.
662    Bearer {
663        /// Accepted bearer tokens (all tools allowed).
664        #[serde(default)]
665        tokens: Vec<String>,
666        /// Tokens with per-token tool access control.
667        #[serde(default)]
668        scoped_tokens: Vec<BearerTokenConfig>,
669    },
670    /// JWT authentication via JWKS endpoint.
671    Jwt {
672        /// Expected token issuer (`iss` claim).
673        issuer: String,
674        /// Expected token audience (`aud` claim).
675        audience: String,
676        /// URL to fetch the JSON Web Key Set for token verification.
677        jwks_uri: String,
678        /// RBAC role definitions
679        #[serde(default)]
680        roles: Vec<RoleConfig>,
681        /// Map JWT claims to roles
682        role_mapping: Option<RoleMappingConfig>,
683    },
684    /// OAuth 2.1 authentication with auto-discovery and token introspection.
685    ///
686    /// Discovers authorization server endpoints (JWKS URI, introspection endpoint)
687    /// from the issuer URL via RFC 8414 metadata. Supports JWT validation,
688    /// opaque token introspection, or both.
689    OAuth {
690        /// Authorization server issuer URL (e.g. `https://accounts.google.com`).
691        /// Used for RFC 8414 metadata discovery.
692        issuer: String,
693        /// Expected token audience (`aud` claim).
694        audience: String,
695        /// OAuth client ID (required for token introspection).
696        #[serde(default)]
697        client_id: Option<String>,
698        /// OAuth client secret (required for token introspection).
699        /// Supports `${ENV_VAR}` syntax.
700        #[serde(default)]
701        client_secret: Option<String>,
702        /// Token validation strategy.
703        #[serde(default)]
704        token_validation: TokenValidationStrategy,
705        /// Override the auto-discovered JWKS URI.
706        #[serde(default)]
707        jwks_uri: Option<String>,
708        /// Override the auto-discovered introspection endpoint.
709        #[serde(default)]
710        introspection_endpoint: Option<String>,
711        /// Scopes a token must carry to access the proxy.
712        ///
713        /// Every listed scope must be present in the token (AND semantics);
714        /// requests whose token is missing any of them are rejected for all
715        /// operations. Empty (the default) means no scope gate. Enforced at the
716        /// MCP middleware level via the OAuth scope-enforcement layer.
717        #[serde(default)]
718        required_scopes: Vec<String>,
719        /// RBAC role definitions.
720        #[serde(default)]
721        roles: Vec<RoleConfig>,
722        /// Map JWT/token claims to roles.
723        role_mapping: Option<RoleMappingConfig>,
724    },
725}
726
727/// Token validation strategy for OAuth 2.1 auth.
728#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
729#[serde(rename_all = "lowercase")]
730pub enum TokenValidationStrategy {
731    /// Validate JWTs locally via JWKS (default). Fast, no network call per request.
732    #[default]
733    Jwt,
734    /// Validate tokens via the authorization server's introspection endpoint (RFC 7662).
735    /// Works with opaque tokens. Requires `client_id` and `client_secret`.
736    Introspection,
737    /// Try JWT validation first; fall back to introspection for non-JWT tokens.
738    /// Requires `client_id` and `client_secret`.
739    Both,
740}
741
742/// Per-token configuration for bearer auth with optional tool scoping.
743///
744/// Allows restricting which tools each bearer token can access, bridging
745/// the gap between all-or-nothing bearer auth and full JWT/RBAC.
746///
747/// # Examples
748///
749/// ```
750/// use mcp_proxy::config::BearerTokenConfig;
751///
752/// let frontend = BearerTokenConfig {
753///     token: "frontend-token".into(),
754///     allow_tools: vec!["files/read_file".into()],
755///     deny_tools: vec![],
756/// };
757///
758/// let admin = BearerTokenConfig {
759///     token: "admin-token".into(),
760///     allow_tools: vec![],
761///     deny_tools: vec![],
762/// };
763/// ```
764#[derive(Debug, Clone, Deserialize, Serialize)]
765pub struct BearerTokenConfig {
766    /// The bearer token value. Supports `${ENV_VAR}` syntax.
767    pub token: String,
768    /// Tools this token can access (namespaced, e.g. "files/read_file").
769    /// Empty means all tools allowed.
770    #[serde(default)]
771    pub allow_tools: Vec<String>,
772    /// Tools this token cannot access.
773    #[serde(default)]
774    pub deny_tools: Vec<String>,
775}
776
777/// RBAC role definition.
778#[derive(Debug, Deserialize, Serialize)]
779pub struct RoleConfig {
780    /// Role name, referenced by `RoleMappingConfig`.
781    pub name: String,
782    /// Tools this role can access (namespaced, e.g. "files/read_file")
783    #[serde(default)]
784    pub allow_tools: Vec<String>,
785    /// Tools this role cannot access
786    #[serde(default)]
787    pub deny_tools: Vec<String>,
788}
789
790/// Maps JWT claim values to RBAC role names.
791#[derive(Debug, Deserialize, Serialize)]
792pub struct RoleMappingConfig {
793    /// JWT claim to read for role resolution (e.g. "scope", "role", "groups")
794    pub claim: String,
795    /// Map claim values to role names
796    pub mapping: HashMap<String, String>,
797    /// Default-deny policy for authenticated principals whose claim value is
798    /// not present in `mapping`.
799    ///
800    /// When `false` (the default, for backwards compatibility), a request that
801    /// carries valid token claims but whose mapped scope is unrecognized passes
802    /// through with no RBAC restriction. When `true`, such a request is denied.
803    ///
804    /// Recommended `true` for gateway deployments: an authenticated principal
805    /// carrying an unrecognized scope should not get unrestricted access. This
806    /// only governs requests that already carry token claims; requests with no
807    /// claims at all (no JWT/RBAC configured) always pass through.
808    #[serde(default)]
809    pub default_deny: bool,
810}
811
812/// Tool alias: exposes a backend tool under a different name.
813#[derive(Debug, Deserialize, Serialize)]
814pub struct AliasConfig {
815    /// Original tool name (backend-local, without namespace prefix)
816    pub from: String,
817    /// New tool name to expose (will be namespaced as backend/to)
818    pub to: String,
819}
820
821/// Per-backend response cache configuration.
822#[derive(Debug, Deserialize, Serialize)]
823pub struct BackendCacheConfig {
824    /// TTL for cached resource reads in seconds (0 = disabled)
825    #[serde(default)]
826    pub resource_ttl_seconds: u64,
827    /// TTL for cached tool call results in seconds (0 = disabled)
828    #[serde(default)]
829    pub tool_ttl_seconds: u64,
830    /// Maximum number of cached entries per backend (default: 1000)
831    #[serde(default = "default_max_cache_entries")]
832    pub max_entries: u64,
833}
834
835/// Global cache backend configuration.
836///
837/// Controls which storage backend is used for response caching. Per-backend
838/// TTL and max_entries settings remain the same regardless of backend.
839///
840/// # Backends
841///
842/// - `"memory"` (default): In-process cache using moka. Fast, no external deps,
843///   but not shared across proxy instances.
844/// - `"redis"`: External Redis cache. Shared across instances. Requires the
845///   `redis-cache` feature.
846/// - `"sqlite"`: Local SQLite cache. Persistent across restarts. Requires the
847///   `sqlite-cache` feature.
848#[derive(Debug, Deserialize, Serialize, Clone)]
849pub struct CacheBackendConfig {
850    /// Cache backend type: "memory" (default), "redis", or "sqlite".
851    #[serde(default = "default_cache_backend")]
852    pub backend: String,
853    /// Connection URL for external backends (Redis or SQLite path).
854    pub url: Option<String>,
855    /// Key prefix for external cache entries (default: "mcp-proxy:").
856    #[serde(default = "default_cache_prefix")]
857    pub prefix: String,
858}
859
860impl Default for CacheBackendConfig {
861    fn default() -> Self {
862        Self {
863            backend: default_cache_backend(),
864            url: None,
865            prefix: default_cache_prefix(),
866        }
867    }
868}
869
870fn default_cache_backend() -> String {
871    "memory".to_string()
872}
873
874fn default_cache_prefix() -> String {
875    "mcp-proxy:".to_string()
876}
877
878/// Performance tuning options.
879#[derive(Debug, Default, Deserialize, Serialize)]
880pub struct PerformanceConfig {
881    /// Deduplicate identical concurrent tool calls and resource reads
882    #[serde(default)]
883    pub coalesce_requests: bool,
884}
885
886/// Security policies.
887#[derive(Debug, Default, Deserialize, Serialize)]
888pub struct SecurityConfig {
889    /// Maximum size of tool call arguments in bytes (default: unlimited)
890    pub max_argument_size: Option<usize>,
891    /// Bearer token for admin API access. If set, all admin endpoints require
892    /// `Authorization: Bearer <token>`. If not set, falls back to the proxy's
893    /// bearer auth tokens (bearer auth only). When `auth.type` is `jwt` or
894    /// `oauth` this token is **required** -- those auth types have no static
895    /// fallback for the admin plane, so config validation rejects a missing
896    /// `admin_token`. With no auth configured at all, the admin API is open
897    /// (suitable for local/dev use). Supports `${ENV_VAR}` syntax.
898    pub admin_token: Option<String>,
899}
900
901/// Logging, metrics, and distributed tracing configuration.
902#[derive(Debug, Default, Deserialize, Serialize)]
903pub struct ObservabilityConfig {
904    /// Enable audit logging of all MCP requests (default: false).
905    #[serde(default)]
906    pub audit: bool,
907    /// Log level filter (default: "info").
908    #[serde(default = "default_log_level")]
909    pub log_level: String,
910    /// Emit structured JSON logs (default: false).
911    #[serde(default)]
912    pub json_logs: bool,
913    /// Prometheus metrics configuration.
914    #[serde(default)]
915    pub metrics: MetricsConfig,
916    /// OpenTelemetry distributed tracing configuration.
917    #[serde(default)]
918    pub tracing: TracingConfig,
919    /// Structured access logging configuration.
920    #[serde(default)]
921    pub access_log: AccessLogConfig,
922}
923
924/// Structured access log configuration.
925#[derive(Debug, Default, Deserialize, Serialize)]
926pub struct AccessLogConfig {
927    /// Enable structured access logging (default: false).
928    #[serde(default)]
929    pub enabled: bool,
930}
931
932/// Prometheus metrics configuration.
933#[derive(Debug, Default, Deserialize, Serialize)]
934pub struct MetricsConfig {
935    /// Enable Prometheus metrics at `/admin/metrics` (default: false).
936    #[serde(default)]
937    pub enabled: bool,
938}
939
940/// OpenTelemetry distributed tracing configuration.
941#[derive(Debug, Default, Deserialize, Serialize)]
942pub struct TracingConfig {
943    /// Enable OTLP trace export (default: false).
944    #[serde(default)]
945    pub enabled: bool,
946    /// OTLP endpoint (default: http://localhost:4317)
947    #[serde(default = "default_otlp_endpoint")]
948    pub endpoint: String,
949    /// Service name for traces (default: "mcp-proxy")
950    #[serde(default = "default_service_name")]
951    pub service_name: String,
952}
953
954// Defaults
955
956fn default_version() -> String {
957    "0.1.0".to_string()
958}
959
960fn default_separator() -> String {
961    "/".to_string()
962}
963
964fn default_host() -> String {
965    "127.0.0.1".to_string()
966}
967
968fn default_port() -> u16 {
969    8080
970}
971
972fn default_log_level() -> String {
973    "info".to_string()
974}
975
976fn default_failure_rate() -> f64 {
977    0.5
978}
979
980fn default_min_calls() -> usize {
981    5
982}
983
984fn default_wait_duration() -> u64 {
985    30
986}
987
988fn default_half_open_calls() -> usize {
989    3
990}
991
992fn default_rate_period() -> u64 {
993    1
994}
995
996fn default_max_retries() -> u32 {
997    3
998}
999
1000fn default_initial_backoff_ms() -> u64 {
1001    100
1002}
1003
1004fn default_max_backoff_ms() -> u64 {
1005    5000
1006}
1007
1008fn default_min_retries_per_sec() -> u32 {
1009    10
1010}
1011
1012fn default_consecutive_errors() -> u32 {
1013    5
1014}
1015
1016fn default_interval_seconds() -> u64 {
1017    10
1018}
1019
1020fn default_base_ejection_seconds() -> u64 {
1021    30
1022}
1023
1024fn default_max_ejection_percent() -> u32 {
1025    50
1026}
1027
1028fn default_hedge_delay_ms() -> u64 {
1029    200
1030}
1031
1032fn default_max_hedges() -> usize {
1033    1
1034}
1035
1036fn default_mirror_percent() -> u32 {
1037    100
1038}
1039
1040fn default_weight() -> u32 {
1041    100
1042}
1043
1044fn default_max_cache_entries() -> u64 {
1045    1000
1046}
1047
1048fn default_shutdown_timeout() -> u64 {
1049    30
1050}
1051
1052fn default_otlp_endpoint() -> String {
1053    "http://localhost:4317".to_string()
1054}
1055
1056fn default_service_name() -> String {
1057    "mcp-proxy".to_string()
1058}
1059
1060/// Resolved filter rules for a backend's capabilities.
1061#[derive(Debug, Clone)]
1062pub struct BackendFilter {
1063    /// Namespace prefix (e.g. "db/") this filter applies to.
1064    pub namespace: String,
1065    /// Filter for tool names.
1066    pub tool_filter: NameFilter,
1067    /// Filter for resource URIs.
1068    pub resource_filter: NameFilter,
1069    /// Filter for prompt names.
1070    pub prompt_filter: NameFilter,
1071    /// Hide tools with `destructive_hint = true`.
1072    pub hide_destructive: bool,
1073    /// Only allow tools with `read_only_hint = true`.
1074    pub read_only_only: bool,
1075}
1076
1077/// A compiled pattern for name matching -- either a glob or a regex.
1078///
1079/// Constructed internally by [`NameFilter::allow_list`] and
1080/// [`NameFilter::deny_list`].
1081#[derive(Debug, Clone)]
1082pub enum CompiledPattern {
1083    /// A glob pattern (matched via `glob_match`).
1084    Glob(String),
1085    /// A pre-compiled regex pattern (from `re:` prefix).
1086    Regex(regex::Regex),
1087}
1088
1089impl CompiledPattern {
1090    /// Compile a pattern string. Patterns prefixed with `re:` are treated as
1091    /// regular expressions; all others are treated as glob patterns.
1092    fn compile(pattern: &str) -> Result<Self> {
1093        if let Some(re_pat) = pattern.strip_prefix("re:") {
1094            let re = regex::Regex::new(re_pat)
1095                .with_context(|| format!("invalid regex in filter pattern: {pattern}"))?;
1096            Ok(Self::Regex(re))
1097        } else {
1098            Ok(Self::Glob(pattern.to_string()))
1099        }
1100    }
1101
1102    /// Check if this pattern matches the given name.
1103    fn matches(&self, name: &str) -> bool {
1104        match self {
1105            Self::Glob(pat) => glob_match::glob_match(pat, name),
1106            Self::Regex(re) => re.is_match(name),
1107        }
1108    }
1109}
1110
1111/// A name-based allow/deny filter.
1112///
1113/// Patterns support two syntaxes:
1114/// - **Glob** (default): `*` matches any sequence, `?` matches one character.
1115/// - **Regex** (`re:` prefix): e.g. `re:^list_.*$` uses the `regex` crate.
1116///
1117/// Regex patterns are compiled once at config parse time.
1118#[derive(Debug, Clone)]
1119pub enum NameFilter {
1120    /// No filtering -- everything passes.
1121    PassAll,
1122    /// Only items matching at least one pattern are allowed.
1123    AllowList(Vec<CompiledPattern>),
1124    /// Items matching any pattern are denied.
1125    DenyList(Vec<CompiledPattern>),
1126}
1127
1128impl NameFilter {
1129    /// Build an allow-list filter from raw pattern strings.
1130    ///
1131    /// Patterns prefixed with `re:` are compiled as regular expressions;
1132    /// all others are treated as glob patterns.
1133    ///
1134    /// # Errors
1135    ///
1136    /// Returns an error if any `re:` pattern contains invalid regex syntax.
1137    pub fn allow_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1138        let compiled: Result<Vec<_>> = patterns
1139            .into_iter()
1140            .map(|p| CompiledPattern::compile(&p))
1141            .collect();
1142        Ok(Self::AllowList(compiled?))
1143    }
1144
1145    /// Build a deny-list filter from raw pattern strings.
1146    ///
1147    /// Patterns prefixed with `re:` are compiled as regular expressions;
1148    /// all others are treated as glob patterns.
1149    ///
1150    /// # Errors
1151    ///
1152    /// Returns an error if any `re:` pattern contains invalid regex syntax.
1153    pub fn deny_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1154        let compiled: Result<Vec<_>> = patterns
1155            .into_iter()
1156            .map(|p| CompiledPattern::compile(&p))
1157            .collect();
1158        Ok(Self::DenyList(compiled?))
1159    }
1160
1161    /// Check if a capability name is allowed by this filter.
1162    ///
1163    /// Supports glob patterns (`*`, `?`) and regex patterns (`re:` prefix).
1164    /// Exact strings match themselves.
1165    ///
1166    /// # Examples
1167    ///
1168    /// ```
1169    /// use mcp_proxy::config::NameFilter;
1170    ///
1171    /// let filter = NameFilter::deny_list(["delete".to_string()]).unwrap();
1172    /// assert!(filter.allows("read"));
1173    /// assert!(!filter.allows("delete"));
1174    ///
1175    /// let filter = NameFilter::allow_list(["read".to_string()]).unwrap();
1176    /// assert!(filter.allows("read"));
1177    /// assert!(!filter.allows("write"));
1178    ///
1179    /// assert!(NameFilter::PassAll.allows("anything"));
1180    ///
1181    /// // Glob patterns
1182    /// let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
1183    /// assert!(filter.allows("read_file"));
1184    /// assert!(filter.allows("write_file"));
1185    /// assert!(!filter.allows("query"));
1186    ///
1187    /// // Regex patterns
1188    /// let filter = NameFilter::allow_list(["re:^list_.*$".to_string()]).unwrap();
1189    /// assert!(filter.allows("list_files"));
1190    /// assert!(!filter.allows("get_files"));
1191    /// ```
1192    pub fn allows(&self, name: &str) -> bool {
1193        match self {
1194            Self::PassAll => true,
1195            Self::AllowList(patterns) => patterns.iter().any(|p| p.matches(name)),
1196            Self::DenyList(patterns) => !patterns.iter().any(|p| p.matches(name)),
1197        }
1198    }
1199}
1200
1201impl BackendConfig {
1202    /// Build a [`BackendFilter`] from this backend's expose/hide lists.
1203    /// Returns `None` if no filtering is configured.
1204    ///
1205    /// Canary and failover backends automatically hide all capabilities so
1206    /// their tools don't appear in `ListTools` responses (traffic reaches
1207    /// them via routing middleware, not direct tool calls).
1208    pub fn build_filter(&self, separator: &str) -> Result<Option<BackendFilter>> {
1209        // Canary and failover backends hide all capabilities -- tools are
1210        // accessed via routing middleware rewriting the primary namespace.
1211        if self.canary_of.is_some() || self.failover_for.is_some() {
1212            return Ok(Some(BackendFilter {
1213                namespace: format!("{}{}", self.name, separator),
1214                tool_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1215                resource_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1216                prompt_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1217                hide_destructive: false,
1218                read_only_only: false,
1219            }));
1220        }
1221
1222        let tool_filter = if !self.expose_tools.is_empty() {
1223            NameFilter::allow_list(self.expose_tools.iter().cloned())?
1224        } else if !self.hide_tools.is_empty() {
1225            NameFilter::deny_list(self.hide_tools.iter().cloned())?
1226        } else {
1227            NameFilter::PassAll
1228        };
1229
1230        let resource_filter = if !self.expose_resources.is_empty() {
1231            NameFilter::allow_list(self.expose_resources.iter().cloned())?
1232        } else if !self.hide_resources.is_empty() {
1233            NameFilter::deny_list(self.hide_resources.iter().cloned())?
1234        } else {
1235            NameFilter::PassAll
1236        };
1237
1238        let prompt_filter = if !self.expose_prompts.is_empty() {
1239            NameFilter::allow_list(self.expose_prompts.iter().cloned())?
1240        } else if !self.hide_prompts.is_empty() {
1241            NameFilter::deny_list(self.hide_prompts.iter().cloned())?
1242        } else {
1243            NameFilter::PassAll
1244        };
1245
1246        // Only create a filter if at least one dimension has filtering
1247        if matches!(tool_filter, NameFilter::PassAll)
1248            && matches!(resource_filter, NameFilter::PassAll)
1249            && matches!(prompt_filter, NameFilter::PassAll)
1250            && !self.hide_destructive
1251            && !self.read_only_only
1252        {
1253            return Ok(None);
1254        }
1255
1256        Ok(Some(BackendFilter {
1257            namespace: format!("{}{}", self.name, separator),
1258            tool_filter,
1259            resource_filter,
1260            prompt_filter,
1261            hide_destructive: self.hide_destructive,
1262            read_only_only: self.read_only_only,
1263        }))
1264    }
1265}
1266
1267impl ProxyConfig {
1268    /// Load and validate a config from a file path.
1269    ///
1270    /// If `import_backends` is set in the config, backends from the referenced
1271    /// `.mcp.json` file are merged (TOML backends take precedence on name conflicts).
1272    pub fn load(path: &Path) -> Result<Self> {
1273        let content =
1274            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1275
1276        let mut config: Self = match path.extension().and_then(|e| e.to_str()) {
1277            #[cfg(feature = "yaml")]
1278            Some("yaml" | "yml") => yaml_serde::from_str(&content)
1279                .with_context(|| format!("parsing YAML {}", path.display()))?,
1280            #[cfg(not(feature = "yaml"))]
1281            Some("yaml" | "yml") => {
1282                anyhow::bail!(
1283                    "YAML config requires the 'yaml' feature. Rebuild with: cargo install mcp-proxy --features yaml"
1284                );
1285            }
1286            _ => toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?,
1287        };
1288
1289        // Import backends from .mcp.json if configured
1290        if let Some(ref mcp_json_path) = config.proxy.import_backends {
1291            let mcp_path = if std::path::Path::new(mcp_json_path).is_relative() {
1292                // Resolve relative to config file directory
1293                path.parent().unwrap_or(Path::new(".")).join(mcp_json_path)
1294            } else {
1295                std::path::PathBuf::from(mcp_json_path)
1296            };
1297
1298            let mcp_json = crate::mcp_json::McpJsonConfig::load(&mcp_path)
1299                .with_context(|| format!("importing backends from {}", mcp_path.display()))?;
1300
1301            let existing_names: HashSet<String> =
1302                config.backends.iter().map(|b| b.name.clone()).collect();
1303
1304            for backend in mcp_json.into_backends()? {
1305                if !existing_names.contains(&backend.name) {
1306                    config.backends.push(backend);
1307                }
1308            }
1309        }
1310
1311        config.source_path = Some(path.to_path_buf());
1312        config.validate()?;
1313        Ok(config)
1314    }
1315
1316    /// Build a minimal `ProxyConfig` from a `.mcp.json` file.
1317    ///
1318    /// This is a convenience mode for quick local development. The proxy name
1319    /// is derived from the file's parent directory (or the filename itself),
1320    /// and the server listens on `127.0.0.1:8080` with no middleware or auth.
1321    ///
1322    /// # Examples
1323    ///
1324    /// ```no_run
1325    /// use std::path::Path;
1326    /// use mcp_proxy::ProxyConfig;
1327    ///
1328    /// let config = ProxyConfig::from_mcp_json(Path::new(".mcp.json")).unwrap();
1329    /// assert_eq!(config.proxy.listen.host, "127.0.0.1");
1330    /// assert_eq!(config.proxy.listen.port, 8080);
1331    /// ```
1332    pub fn from_mcp_json(path: &Path) -> Result<Self> {
1333        let mcp_json = crate::mcp_json::McpJsonConfig::load(path)?;
1334        let backends = mcp_json.into_backends()?;
1335
1336        // Derive a proxy name from the parent directory or filename
1337        let name = path
1338            .parent()
1339            .and_then(|p| p.file_name())
1340            .or_else(|| path.file_stem())
1341            .map(|s| s.to_string_lossy().into_owned())
1342            .unwrap_or_else(|| "mcp-proxy".to_string());
1343
1344        let config = Self {
1345            proxy: ProxySettings {
1346                name,
1347                version: default_version(),
1348                separator: default_separator(),
1349                listen: ListenConfig {
1350                    host: default_host(),
1351                    port: default_port(),
1352                },
1353                instructions: None,
1354                shutdown_timeout_seconds: default_shutdown_timeout(),
1355                hot_reload: false,
1356                import_backends: None,
1357                rate_limit: None,
1358                tool_discovery: false,
1359                tool_exposure: ToolExposure::default(),
1360            },
1361            backends,
1362            auth: None,
1363            performance: PerformanceConfig::default(),
1364            security: SecurityConfig::default(),
1365            cache: CacheBackendConfig::default(),
1366            observability: ObservabilityConfig::default(),
1367            composite_tools: Vec::new(),
1368            source_path: Some(path.to_path_buf()),
1369        };
1370
1371        config.validate()?;
1372        Ok(config)
1373    }
1374
1375    /// Parse and validate a config from a TOML string.
1376    ///
1377    /// # Examples
1378    ///
1379    /// ```
1380    /// use mcp_proxy::ProxyConfig;
1381    ///
1382    /// let config = ProxyConfig::parse(r#"
1383    ///     [proxy]
1384    ///     name = "my-proxy"
1385    ///     [proxy.listen]
1386    ///
1387    ///     [[backends]]
1388    ///     name = "echo"
1389    ///     transport = "stdio"
1390    ///     command = "echo"
1391    /// "#).unwrap();
1392    ///
1393    /// assert_eq!(config.proxy.name, "my-proxy");
1394    /// assert_eq!(config.backends.len(), 1);
1395    /// ```
1396    pub fn parse(toml: &str) -> Result<Self> {
1397        let config: Self = toml::from_str(toml).context("parsing config")?;
1398        config.validate()?;
1399        Ok(config)
1400    }
1401
1402    /// Parse and validate a config from a YAML string.
1403    ///
1404    /// # Examples
1405    ///
1406    /// ```
1407    /// use mcp_proxy::ProxyConfig;
1408    ///
1409    /// let config = ProxyConfig::parse_yaml(r#"
1410    /// proxy:
1411    ///   name: my-proxy
1412    ///   listen:
1413    ///     host: "127.0.0.1"
1414    ///     port: 8080
1415    /// backends:
1416    ///   - name: echo
1417    ///     transport: stdio
1418    ///     command: echo
1419    /// "#).unwrap();
1420    ///
1421    /// assert_eq!(config.proxy.name, "my-proxy");
1422    /// ```
1423    #[cfg(feature = "yaml")]
1424    pub fn parse_yaml(yaml: &str) -> Result<Self> {
1425        let config: Self = yaml_serde::from_str(yaml).context("parsing YAML config")?;
1426        config.validate()?;
1427        Ok(config)
1428    }
1429
1430    fn validate(&self) -> Result<()> {
1431        if self.backends.is_empty() {
1432            anyhow::bail!("at least one backend is required");
1433        }
1434
1435        // Validate cache backend
1436        match self.cache.backend.as_str() {
1437            "memory" => {}
1438            "redis" => {
1439                if self.cache.url.is_none() {
1440                    anyhow::bail!(
1441                        "cache.url is required when cache.backend = \"{}\"",
1442                        self.cache.backend
1443                    );
1444                }
1445                #[cfg(not(feature = "redis-cache"))]
1446                anyhow::bail!(
1447                    "cache.backend = \"redis\" requires the 'redis-cache' feature. \
1448                     Rebuild with: cargo install mcp-proxy --features redis-cache"
1449                );
1450            }
1451            "sqlite" => {
1452                if self.cache.url.is_none() {
1453                    anyhow::bail!(
1454                        "cache.url is required when cache.backend = \"{}\"",
1455                        self.cache.backend
1456                    );
1457                }
1458                #[cfg(not(feature = "sqlite-cache"))]
1459                anyhow::bail!(
1460                    "cache.backend = \"sqlite\" requires the 'sqlite-cache' feature. \
1461                     Rebuild with: cargo install mcp-proxy --features sqlite-cache"
1462                );
1463            }
1464            other => {
1465                anyhow::bail!(
1466                    "unknown cache backend \"{}\", expected \"memory\", \"redis\", or \"sqlite\"",
1467                    other
1468                );
1469            }
1470        }
1471
1472        // Validate global rate limit
1473        if let Some(rl) = &self.proxy.rate_limit {
1474            if rl.requests == 0 {
1475                anyhow::bail!("proxy.rate_limit.requests must be > 0");
1476            }
1477            if rl.period_seconds == 0 {
1478                anyhow::bail!("proxy.rate_limit.period_seconds must be > 0");
1479            }
1480        }
1481
1482        // Validate bearer auth config
1483        if let Some(AuthConfig::Bearer {
1484            tokens,
1485            scoped_tokens,
1486        }) = &self.auth
1487        {
1488            if tokens.is_empty() && scoped_tokens.is_empty() {
1489                anyhow::bail!(
1490                    "bearer auth requires at least one token in 'tokens' or 'scoped_tokens'"
1491                );
1492            }
1493            // Check for duplicate tokens across both lists
1494            let mut seen_tokens = HashSet::new();
1495            for t in tokens {
1496                if !seen_tokens.insert(t.as_str()) {
1497                    anyhow::bail!("duplicate bearer token in 'tokens'");
1498                }
1499            }
1500            for st in scoped_tokens {
1501                if !seen_tokens.insert(st.token.as_str()) {
1502                    anyhow::bail!(
1503                        "duplicate bearer token (appears in both 'tokens' and 'scoped_tokens' or duplicated within 'scoped_tokens')"
1504                    );
1505                }
1506                if !st.allow_tools.is_empty() && !st.deny_tools.is_empty() {
1507                    anyhow::bail!(
1508                        "scoped_tokens: cannot specify both allow_tools and deny_tools for the same token"
1509                    );
1510                }
1511            }
1512        }
1513
1514        // Validate OAuth config
1515        if let Some(AuthConfig::OAuth {
1516            token_validation,
1517            client_id,
1518            client_secret,
1519            ..
1520        }) = &self.auth
1521            && matches!(
1522                token_validation,
1523                TokenValidationStrategy::Introspection | TokenValidationStrategy::Both
1524            )
1525            && (client_id.is_none() || client_secret.is_none())
1526        {
1527            anyhow::bail!("OAuth introspection requires both 'client_id' and 'client_secret'");
1528        }
1529
1530        // Admin API protection: JWT/OAuth auth has no static-token fallback for
1531        // the admin plane (resolve_admin_tokens only derives tokens from bearer
1532        // auth). Without an explicit admin_token the admin endpoints -- which can
1533        // add backends, rewrite the running config, and terminate sessions --
1534        // would be left unauthenticated. Require admin_token to be set in that case.
1535        if matches!(
1536            &self.auth,
1537            Some(AuthConfig::Jwt { .. }) | Some(AuthConfig::OAuth { .. })
1538        ) && self.security.admin_token.is_none()
1539        {
1540            anyhow::bail!(
1541                "security.admin_token is required when auth.type is 'jwt' or 'oauth': \
1542                 the admin API has no token fallback for these auth types and would be \
1543                 left unauthenticated. Set security.admin_token (supports ${{ENV_VAR}})."
1544            );
1545        }
1546
1547        // Check for duplicate backend names
1548        let mut seen_names = HashSet::new();
1549        for backend in &self.backends {
1550            if !seen_names.insert(&backend.name) {
1551                anyhow::bail!("duplicate backend name '{}'", backend.name);
1552            }
1553        }
1554
1555        for backend in &self.backends {
1556            if !backend.headers.is_empty() && matches!(backend.transport, TransportType::Stdio) {
1557                anyhow::bail!(
1558                    "backend '{}': headers require HTTP or WebSocket transport",
1559                    backend.name
1560                );
1561            }
1562            let mut header_names = HashSet::new();
1563            for (name, value) in &backend.headers {
1564                axum::http::HeaderName::try_from(name.as_str())
1565                    .with_context(|| format!("backend '{}': invalid header name", backend.name))?;
1566                if !header_names.insert(name.to_ascii_lowercase()) {
1567                    anyhow::bail!(
1568                        "backend '{}': duplicate header name '{}'",
1569                        backend.name,
1570                        name
1571                    );
1572                }
1573                axum::http::HeaderValue::try_from(value.as_str()).map_err(|_| {
1574                    anyhow::anyhow!(
1575                        "backend '{}': invalid value for header '{}'",
1576                        backend.name,
1577                        name
1578                    )
1579                })?;
1580            }
1581            match backend.transport {
1582                TransportType::Stdio => {
1583                    if backend.command.is_none() {
1584                        anyhow::bail!(
1585                            "backend '{}': stdio transport requires 'command'",
1586                            backend.name
1587                        );
1588                    }
1589                }
1590                TransportType::Http => {
1591                    if backend.url.is_none() {
1592                        anyhow::bail!("backend '{}': http transport requires 'url'", backend.name);
1593                    }
1594                }
1595                TransportType::Websocket => {
1596                    if backend.url.is_none() {
1597                        anyhow::bail!(
1598                            "backend '{}': websocket transport requires 'url'",
1599                            backend.name
1600                        );
1601                    }
1602                }
1603            }
1604
1605            if let Some(cb) = &backend.circuit_breaker
1606                && (!cb.failure_rate_threshold.is_finite()
1607                    || cb.failure_rate_threshold <= 0.0
1608                    || cb.failure_rate_threshold > 1.0)
1609            {
1610                anyhow::bail!(
1611                    "backend '{}': circuit_breaker.failure_rate_threshold must be in (0.0, 1.0]",
1612                    backend.name
1613                );
1614            }
1615
1616            if let Some(cb) = &backend.circuit_breaker
1617                && cb.minimum_calls == 0
1618            {
1619                anyhow::bail!(
1620                    "backend '{}': circuit_breaker.minimum_calls must be > 0",
1621                    backend.name
1622                );
1623            }
1624
1625            if let Some(rl) = &backend.rate_limit
1626                && rl.period_seconds == 0
1627            {
1628                anyhow::bail!(
1629                    "backend '{}': rate_limit.period_seconds must be > 0",
1630                    backend.name
1631                );
1632            }
1633
1634            if let Some(rl) = &backend.rate_limit
1635                && rl.requests == 0
1636            {
1637                anyhow::bail!(
1638                    "backend '{}': rate_limit.requests must be > 0",
1639                    backend.name
1640                );
1641            }
1642
1643            if let Some(cc) = &backend.concurrency
1644                && cc.max_concurrent == 0
1645            {
1646                anyhow::bail!(
1647                    "backend '{}': concurrency.max_concurrent must be > 0",
1648                    backend.name
1649                );
1650            }
1651
1652            if !backend.expose_tools.is_empty() && !backend.hide_tools.is_empty() {
1653                anyhow::bail!(
1654                    "backend '{}': cannot specify both expose_tools and hide_tools",
1655                    backend.name
1656                );
1657            }
1658            if !backend.expose_resources.is_empty() && !backend.hide_resources.is_empty() {
1659                anyhow::bail!(
1660                    "backend '{}': cannot specify both expose_resources and hide_resources",
1661                    backend.name
1662                );
1663            }
1664            if !backend.expose_prompts.is_empty() && !backend.hide_prompts.is_empty() {
1665                anyhow::bail!(
1666                    "backend '{}': cannot specify both expose_prompts and hide_prompts",
1667                    backend.name
1668                );
1669            }
1670        }
1671
1672        // Validate mirror_of references
1673        let backend_names: HashSet<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
1674        for backend in &self.backends {
1675            if let Some(ref source) = backend.mirror_of {
1676                if !backend_names.contains(source.as_str()) {
1677                    anyhow::bail!(
1678                        "backend '{}': mirror_of references unknown backend '{}'",
1679                        backend.name,
1680                        source
1681                    );
1682                }
1683                if source == &backend.name {
1684                    anyhow::bail!(
1685                        "backend '{}': mirror_of cannot reference itself",
1686                        backend.name
1687                    );
1688                }
1689                if backend.mirror_percent > 100 {
1690                    anyhow::bail!(
1691                        "backend '{}': mirror_percent must be 0-100, got {}",
1692                        backend.name,
1693                        backend.mirror_percent
1694                    );
1695                }
1696            }
1697        }
1698
1699        // Validate failover_for references
1700        for backend in &self.backends {
1701            if let Some(ref primary) = backend.failover_for {
1702                if !backend_names.contains(primary.as_str()) {
1703                    anyhow::bail!(
1704                        "backend '{}': failover_for references unknown backend '{}'",
1705                        backend.name,
1706                        primary
1707                    );
1708                }
1709                if primary == &backend.name {
1710                    anyhow::bail!(
1711                        "backend '{}': failover_for cannot reference itself",
1712                        backend.name
1713                    );
1714                }
1715            }
1716        }
1717
1718        // Validate composite tools
1719        {
1720            let mut composite_names = HashSet::new();
1721            for ct in &self.composite_tools {
1722                if ct.name.is_empty() {
1723                    anyhow::bail!("composite_tools: name must not be empty");
1724                }
1725                if ct.tools.is_empty() {
1726                    anyhow::bail!(
1727                        "composite_tools '{}': must reference at least one tool",
1728                        ct.name
1729                    );
1730                }
1731                if !composite_names.insert(&ct.name) {
1732                    anyhow::bail!("duplicate composite_tools name '{}'", ct.name);
1733                }
1734            }
1735        }
1736
1737        // Validate canary_of references
1738        for backend in &self.backends {
1739            if let Some(ref primary) = backend.canary_of {
1740                if !backend_names.contains(primary.as_str()) {
1741                    anyhow::bail!(
1742                        "backend '{}': canary_of references unknown backend '{}'",
1743                        backend.name,
1744                        primary
1745                    );
1746                }
1747                if primary == &backend.name {
1748                    anyhow::bail!(
1749                        "backend '{}': canary_of cannot reference itself",
1750                        backend.name
1751                    );
1752                }
1753                if backend.weight == 0 || backend.weight > 100 {
1754                    anyhow::bail!(
1755                        "backend '{}': weight must be 1-100, got {}",
1756                        backend.name,
1757                        backend.weight
1758                    );
1759                }
1760            }
1761        }
1762
1763        // Validate websocket transport requires the websocket feature, so
1764        // --check predicts the startup failure instead of passing configs
1765        // the binary will refuse to run (#229). Startup keeps its own bail
1766        // as a second line of defense.
1767        #[cfg(not(feature = "websocket"))]
1768        for backend in &self.backends {
1769            if matches!(backend.transport, TransportType::Websocket) {
1770                anyhow::bail!(
1771                    "backend '{}': transport = \"websocket\" requires the 'websocket' feature. \
1772                     Rebuild with: cargo install mcp-proxy --features websocket",
1773                    backend.name
1774                );
1775            }
1776        }
1777
1778        // Validate tool_exposure = "search" requires the discovery feature
1779        #[cfg(not(feature = "discovery"))]
1780        if self.proxy.tool_exposure == ToolExposure::Search {
1781            anyhow::bail!(
1782                "tool_exposure = \"search\" requires the 'discovery' feature. \
1783                 Rebuild with: cargo install mcp-proxy --features discovery"
1784            );
1785        }
1786
1787        // Validate param_overrides
1788        for backend in &self.backends {
1789            let mut seen_tools = HashSet::new();
1790            for po in &backend.param_overrides {
1791                if po.tool.is_empty() {
1792                    anyhow::bail!(
1793                        "backend '{}': param_overrides.tool must not be empty",
1794                        backend.name
1795                    );
1796                }
1797                if !seen_tools.insert(&po.tool) {
1798                    anyhow::bail!(
1799                        "backend '{}': duplicate param_overrides for tool '{}'",
1800                        backend.name,
1801                        po.tool
1802                    );
1803                }
1804                // Hidden params that have no default are a warning-level concern,
1805                // but renamed params that conflict with hide are an error.
1806                for hidden in &po.hide {
1807                    if po.rename.contains_key(hidden) {
1808                        anyhow::bail!(
1809                            "backend '{}': param_overrides for tool '{}': \
1810                             parameter '{}' cannot be both hidden and renamed",
1811                            backend.name,
1812                            po.tool,
1813                            hidden
1814                        );
1815                    }
1816                }
1817                // Check for rename target conflicts (two originals mapping to same name)
1818                let mut rename_targets = HashSet::new();
1819                for target in po.rename.values() {
1820                    if !rename_targets.insert(target) {
1821                        anyhow::bail!(
1822                            "backend '{}': param_overrides for tool '{}': \
1823                             duplicate rename target '{}'",
1824                            backend.name,
1825                            po.tool,
1826                            target
1827                        );
1828                    }
1829                }
1830            }
1831        }
1832
1833        Ok(())
1834    }
1835
1836    /// Resolve environment variable references in config values.
1837    /// Replaces `${VAR_NAME}` with the value of the environment variable.
1838    pub fn resolve_env_vars(&mut self) {
1839        for backend in &mut self.backends {
1840            for value in backend.env.values_mut() {
1841                if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1842                    && let Ok(env_val) = std::env::var(var_name)
1843                {
1844                    *value = env_val;
1845                }
1846            }
1847            if let Some(ref mut token) = backend.bearer_token
1848                && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1849                && let Ok(env_val) = std::env::var(var_name)
1850            {
1851                *token = env_val;
1852            }
1853            for value in backend.headers.values_mut() {
1854                if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1855                    && let Ok(env_val) = std::env::var(var_name)
1856                {
1857                    *value = env_val;
1858                }
1859            }
1860        }
1861
1862        // Resolve env vars in auth config
1863        if let Some(AuthConfig::Bearer {
1864            tokens,
1865            scoped_tokens,
1866        }) = &mut self.auth
1867        {
1868            for token in tokens.iter_mut() {
1869                if let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1870                    && let Ok(env_val) = std::env::var(var_name)
1871                {
1872                    *token = env_val;
1873                }
1874            }
1875            for st in scoped_tokens.iter_mut() {
1876                if let Some(var_name) = st
1877                    .token
1878                    .strip_prefix("${")
1879                    .and_then(|s| s.strip_suffix('}'))
1880                    && let Ok(env_val) = std::env::var(var_name)
1881                {
1882                    st.token = env_val;
1883                }
1884            }
1885        }
1886
1887        // Resolve env vars in admin_token
1888        if let Some(ref mut token) = self.security.admin_token
1889            && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1890            && let Ok(env_val) = std::env::var(var_name)
1891        {
1892            *token = env_val;
1893        }
1894
1895        // Resolve env vars in OAuth config
1896        if let Some(AuthConfig::OAuth { client_secret, .. }) = &mut self.auth
1897            && let Some(secret) = client_secret
1898            && let Some(var_name) = secret.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1899            && let Ok(env_val) = std::env::var(var_name)
1900        {
1901            *secret = env_val;
1902        }
1903    }
1904
1905    /// Check for `${VAR}` references where the environment variable is not set.
1906    ///
1907    /// Returns a list of human-readable warning strings. This method does not
1908    /// modify the config or fail -- it only reports potential issues.
1909    ///
1910    /// # Example
1911    ///
1912    /// ```
1913    /// use mcp_proxy::config::ProxyConfig;
1914    ///
1915    /// let toml = r#"
1916    /// [proxy]
1917    /// name = "test"
1918    /// [proxy.listen]
1919    ///
1920    /// [[backends]]
1921    /// name = "svc"
1922    /// transport = "stdio"
1923    /// command = "echo"
1924    /// bearer_token = "${UNSET_VAR}"
1925    /// "#;
1926    ///
1927    /// let config = ProxyConfig::parse(toml).unwrap();
1928    /// let warnings = config.check_env_vars();
1929    /// assert!(!warnings.is_empty());
1930    /// ```
1931    pub fn check_env_vars(&self) -> Vec<String> {
1932        fn is_unset_env_ref(value: &str) -> Option<&str> {
1933            let var_name = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))?;
1934            if std::env::var(var_name).is_err() {
1935                Some(var_name)
1936            } else {
1937                None
1938            }
1939        }
1940
1941        let mut warnings = Vec::new();
1942
1943        for backend in &self.backends {
1944            // backend.bearer_token
1945            if let Some(ref token) = backend.bearer_token
1946                && let Some(var) = is_unset_env_ref(token)
1947            {
1948                warnings.push(format!(
1949                    "backend '{}': bearer_token references unset env var '{}'",
1950                    backend.name, var
1951                ));
1952            }
1953            // backend.headers values
1954            for (key, value) in &backend.headers {
1955                if let Some(var) = is_unset_env_ref(value) {
1956                    warnings.push(format!(
1957                        "backend '{}': headers.{} references unset env var '{}'",
1958                        backend.name, key, var
1959                    ));
1960                }
1961            }
1962            // backend.env values
1963            for (key, value) in &backend.env {
1964                if let Some(var) = is_unset_env_ref(value) {
1965                    warnings.push(format!(
1966                        "backend '{}': env.{} references unset env var '{}'",
1967                        backend.name, key, var
1968                    ));
1969                }
1970            }
1971        }
1972
1973        match &self.auth {
1974            Some(AuthConfig::Bearer {
1975                tokens,
1976                scoped_tokens,
1977            }) => {
1978                for (i, token) in tokens.iter().enumerate() {
1979                    if let Some(var) = is_unset_env_ref(token) {
1980                        warnings.push(format!(
1981                            "auth.bearer: tokens[{}] references unset env var '{}'",
1982                            i, var
1983                        ));
1984                    }
1985                }
1986                for (i, st) in scoped_tokens.iter().enumerate() {
1987                    if let Some(var) = is_unset_env_ref(&st.token) {
1988                        warnings.push(format!(
1989                            "auth.bearer: scoped_tokens[{}] references unset env var '{}'",
1990                            i, var
1991                        ));
1992                    }
1993                }
1994            }
1995            Some(AuthConfig::OAuth {
1996                client_secret: Some(secret),
1997                ..
1998            }) => {
1999                if let Some(var) = is_unset_env_ref(secret) {
2000                    warnings.push(format!(
2001                        "auth.oauth: client_secret references unset env var '{}'",
2002                        var
2003                    ));
2004                }
2005            }
2006            _ => {}
2007        }
2008
2009        warnings
2010    }
2011}
2012
2013#[cfg(test)]
2014mod tests {
2015    use super::*;
2016
2017    fn minimal_config() -> &'static str {
2018        r#"
2019        [proxy]
2020        name = "test"
2021        [proxy.listen]
2022
2023        [[backends]]
2024        name = "echo"
2025        transport = "stdio"
2026        command = "echo"
2027        "#
2028    }
2029
2030    #[test]
2031    fn reject_invalid_resilience_builder_config() {
2032        let base = "[proxy]\nname = 'test'\n[proxy.listen]\n[[backends]]\nname = 'api'\ntransport = 'http'\nurl = 'http://localhost:1'\n";
2033        for extra in [
2034            "[backends.circuit_breaker]\nminimum_calls = 0",
2035            "[backends.circuit_breaker]\nfailure_rate_threshold = nan",
2036            "[backends.rate_limit]\nrequests = 1\nperiod_seconds = 0",
2037        ] {
2038            assert!(ProxyConfig::parse(&format!("{base}{extra}")).is_err());
2039        }
2040    }
2041
2042    #[test]
2043    fn test_parse_minimal_config() {
2044        let config = ProxyConfig::parse(minimal_config()).unwrap();
2045        assert_eq!(config.proxy.name, "test");
2046        assert_eq!(config.proxy.version, "0.1.0"); // default
2047        assert_eq!(config.proxy.separator, "/"); // default
2048        assert_eq!(config.proxy.listen.host, "127.0.0.1"); // default
2049        assert_eq!(config.proxy.listen.port, 8080); // default
2050        assert_eq!(config.proxy.shutdown_timeout_seconds, 30); // default
2051        assert!(!config.proxy.hot_reload); // default false
2052        assert_eq!(config.backends.len(), 1);
2053        assert_eq!(config.backends[0].name, "echo");
2054        assert!(config.auth.is_none());
2055        assert!(!config.observability.audit);
2056        assert!(!config.observability.metrics.enabled);
2057    }
2058
2059    #[test]
2060    fn test_parse_full_config() {
2061        let toml = r#"
2062        [proxy]
2063        name = "full-gw"
2064        version = "2.0.0"
2065        separator = "."
2066        shutdown_timeout_seconds = 60
2067        hot_reload = true
2068        instructions = "A test proxy"
2069        [proxy.listen]
2070        host = "0.0.0.0"
2071        port = 9090
2072
2073        [[backends]]
2074        name = "files"
2075        transport = "stdio"
2076        command = "file-server"
2077        args = ["--root", "/tmp"]
2078        expose_tools = ["read_file"]
2079
2080        [backends.env]
2081        LOG_LEVEL = "debug"
2082
2083        [backends.timeout]
2084        seconds = 30
2085
2086        [backends.concurrency]
2087        max_concurrent = 5
2088
2089        [backends.rate_limit]
2090        requests = 100
2091        period_seconds = 10
2092
2093        [backends.circuit_breaker]
2094        failure_rate_threshold = 0.5
2095        minimum_calls = 10
2096        wait_duration_seconds = 60
2097        permitted_calls_in_half_open = 2
2098
2099        [backends.cache]
2100        resource_ttl_seconds = 300
2101        tool_ttl_seconds = 60
2102        max_entries = 500
2103
2104        [[backends.aliases]]
2105        from = "read_file"
2106        to = "read"
2107
2108        [[backends]]
2109        name = "remote"
2110        transport = "http"
2111        url = "http://localhost:3000"
2112
2113        [observability]
2114        audit = true
2115        log_level = "debug"
2116        json_logs = true
2117
2118        [observability.metrics]
2119        enabled = true
2120
2121        [observability.tracing]
2122        enabled = true
2123        endpoint = "http://jaeger:4317"
2124        service_name = "test-gw"
2125
2126        [performance]
2127        coalesce_requests = true
2128
2129        [security]
2130        max_argument_size = 1048576
2131        "#;
2132
2133        let config = ProxyConfig::parse(toml).unwrap();
2134        assert_eq!(config.proxy.name, "full-gw");
2135        assert_eq!(config.proxy.version, "2.0.0");
2136        assert_eq!(config.proxy.separator, ".");
2137        assert_eq!(config.proxy.shutdown_timeout_seconds, 60);
2138        assert!(config.proxy.hot_reload);
2139        assert_eq!(config.proxy.instructions.as_deref(), Some("A test proxy"));
2140        assert_eq!(config.proxy.listen.host, "0.0.0.0");
2141        assert_eq!(config.proxy.listen.port, 9090);
2142
2143        assert_eq!(config.backends.len(), 2);
2144
2145        let files = &config.backends[0];
2146        assert_eq!(files.command.as_deref(), Some("file-server"));
2147        assert_eq!(files.args, vec!["--root", "/tmp"]);
2148        assert_eq!(files.expose_tools, vec!["read_file"]);
2149        assert_eq!(files.env.get("LOG_LEVEL").unwrap(), "debug");
2150        assert_eq!(files.timeout.as_ref().unwrap().seconds, 30);
2151        assert_eq!(files.concurrency.as_ref().unwrap().max_concurrent, 5);
2152        assert_eq!(files.rate_limit.as_ref().unwrap().requests, 100);
2153        assert_eq!(files.cache.as_ref().unwrap().resource_ttl_seconds, 300);
2154        assert_eq!(files.cache.as_ref().unwrap().tool_ttl_seconds, 60);
2155        assert_eq!(files.cache.as_ref().unwrap().max_entries, 500);
2156        assert_eq!(files.aliases.len(), 1);
2157        assert_eq!(files.aliases[0].from, "read_file");
2158        assert_eq!(files.aliases[0].to, "read");
2159
2160        let cb = files.circuit_breaker.as_ref().unwrap();
2161        assert_eq!(cb.failure_rate_threshold, 0.5);
2162        assert_eq!(cb.minimum_calls, 10);
2163        assert_eq!(cb.wait_duration_seconds, 60);
2164        assert_eq!(cb.permitted_calls_in_half_open, 2);
2165
2166        let remote = &config.backends[1];
2167        assert_eq!(remote.url.as_deref(), Some("http://localhost:3000"));
2168
2169        assert!(config.observability.audit);
2170        assert_eq!(config.observability.log_level, "debug");
2171        assert!(config.observability.json_logs);
2172        assert!(config.observability.metrics.enabled);
2173        assert!(config.observability.tracing.enabled);
2174        assert_eq!(config.observability.tracing.endpoint, "http://jaeger:4317");
2175
2176        assert!(config.performance.coalesce_requests);
2177        assert_eq!(config.security.max_argument_size, Some(1048576));
2178    }
2179
2180    #[test]
2181    fn test_parse_bearer_auth() {
2182        let toml = r#"
2183        [proxy]
2184        name = "auth-gw"
2185        [proxy.listen]
2186
2187        [[backends]]
2188        name = "echo"
2189        transport = "stdio"
2190        command = "echo"
2191
2192        [auth]
2193        type = "bearer"
2194        tokens = ["token-1", "token-2"]
2195        "#;
2196
2197        let config = ProxyConfig::parse(toml).unwrap();
2198        match &config.auth {
2199            Some(AuthConfig::Bearer { tokens, .. }) => {
2200                assert_eq!(tokens, &["token-1", "token-2"]);
2201            }
2202            other => panic!("expected Bearer auth, got: {:?}", other),
2203        }
2204    }
2205
2206    #[test]
2207    fn test_parse_jwt_auth_with_rbac() {
2208        let toml = r#"
2209        [proxy]
2210        name = "jwt-gw"
2211        [proxy.listen]
2212
2213        [[backends]]
2214        name = "echo"
2215        transport = "stdio"
2216        command = "echo"
2217
2218        [auth]
2219        type = "jwt"
2220        issuer = "https://auth.example.com"
2221        audience = "mcp-proxy"
2222        jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2223
2224        [[auth.roles]]
2225        name = "reader"
2226        allow_tools = ["echo/read"]
2227
2228        [[auth.roles]]
2229        name = "admin"
2230
2231        [auth.role_mapping]
2232        claim = "scope"
2233        mapping = { "mcp:read" = "reader", "mcp:admin" = "admin" }
2234
2235        [security]
2236        admin_token = "admin-secret"
2237        "#;
2238
2239        let config = ProxyConfig::parse(toml).unwrap();
2240        match &config.auth {
2241            Some(AuthConfig::Jwt {
2242                issuer,
2243                audience,
2244                jwks_uri,
2245                roles,
2246                role_mapping,
2247            }) => {
2248                assert_eq!(issuer, "https://auth.example.com");
2249                assert_eq!(audience, "mcp-proxy");
2250                assert_eq!(jwks_uri, "https://auth.example.com/.well-known/jwks.json");
2251                assert_eq!(roles.len(), 2);
2252                assert_eq!(roles[0].name, "reader");
2253                assert_eq!(roles[0].allow_tools, vec!["echo/read"]);
2254                let mapping = role_mapping.as_ref().unwrap();
2255                assert_eq!(mapping.claim, "scope");
2256                assert_eq!(mapping.mapping.get("mcp:read").unwrap(), "reader");
2257            }
2258            other => panic!("expected Jwt auth, got: {:?}", other),
2259        }
2260    }
2261
2262    // ========================================================================
2263    // Validation errors
2264    // ========================================================================
2265
2266    #[test]
2267    fn test_reject_no_backends() {
2268        let toml = r#"
2269        [proxy]
2270        name = "empty"
2271        [proxy.listen]
2272        "#;
2273
2274        let err = ProxyConfig::parse(toml).unwrap_err();
2275        assert!(
2276            format!("{err}").contains("at least one backend"),
2277            "unexpected error: {err}"
2278        );
2279    }
2280
2281    #[test]
2282    fn test_reject_stdio_without_command() {
2283        let toml = r#"
2284        [proxy]
2285        name = "bad"
2286        [proxy.listen]
2287
2288        [[backends]]
2289        name = "broken"
2290        transport = "stdio"
2291        "#;
2292
2293        let err = ProxyConfig::parse(toml).unwrap_err();
2294        assert!(
2295            format!("{err}").contains("stdio transport requires 'command'"),
2296            "unexpected error: {err}"
2297        );
2298    }
2299
2300    #[test]
2301    fn test_reject_http_without_url() {
2302        let toml = r#"
2303        [proxy]
2304        name = "bad"
2305        [proxy.listen]
2306
2307        [[backends]]
2308        name = "broken"
2309        transport = "http"
2310        "#;
2311
2312        let err = ProxyConfig::parse(toml).unwrap_err();
2313        assert!(
2314            format!("{err}").contains("http transport requires 'url'"),
2315            "unexpected error: {err}"
2316        );
2317    }
2318
2319    #[test]
2320    fn test_reject_invalid_circuit_breaker_threshold() {
2321        let toml = r#"
2322        [proxy]
2323        name = "bad"
2324        [proxy.listen]
2325
2326        [[backends]]
2327        name = "svc"
2328        transport = "stdio"
2329        command = "echo"
2330
2331        [backends.circuit_breaker]
2332        failure_rate_threshold = 1.5
2333        "#;
2334
2335        let err = ProxyConfig::parse(toml).unwrap_err();
2336        assert!(
2337            format!("{err}").contains("failure_rate_threshold must be in (0.0, 1.0]"),
2338            "unexpected error: {err}"
2339        );
2340    }
2341
2342    #[test]
2343    fn test_reject_zero_rate_limit() {
2344        let toml = r#"
2345        [proxy]
2346        name = "bad"
2347        [proxy.listen]
2348
2349        [[backends]]
2350        name = "svc"
2351        transport = "stdio"
2352        command = "echo"
2353
2354        [backends.rate_limit]
2355        requests = 0
2356        "#;
2357
2358        let err = ProxyConfig::parse(toml).unwrap_err();
2359        assert!(
2360            format!("{err}").contains("rate_limit.requests must be > 0"),
2361            "unexpected error: {err}"
2362        );
2363    }
2364
2365    #[test]
2366    fn test_reject_zero_concurrency() {
2367        let toml = r#"
2368        [proxy]
2369        name = "bad"
2370        [proxy.listen]
2371
2372        [[backends]]
2373        name = "svc"
2374        transport = "stdio"
2375        command = "echo"
2376
2377        [backends.concurrency]
2378        max_concurrent = 0
2379        "#;
2380
2381        let err = ProxyConfig::parse(toml).unwrap_err();
2382        assert!(
2383            format!("{err}").contains("concurrency.max_concurrent must be > 0"),
2384            "unexpected error: {err}"
2385        );
2386    }
2387
2388    #[test]
2389    fn test_reject_expose_and_hide_tools() {
2390        let toml = r#"
2391        [proxy]
2392        name = "bad"
2393        [proxy.listen]
2394
2395        [[backends]]
2396        name = "svc"
2397        transport = "stdio"
2398        command = "echo"
2399        expose_tools = ["read"]
2400        hide_tools = ["write"]
2401        "#;
2402
2403        let err = ProxyConfig::parse(toml).unwrap_err();
2404        assert!(
2405            format!("{err}").contains("cannot specify both expose_tools and hide_tools"),
2406            "unexpected error: {err}"
2407        );
2408    }
2409
2410    #[test]
2411    fn test_reject_expose_and_hide_resources() {
2412        let toml = r#"
2413        [proxy]
2414        name = "bad"
2415        [proxy.listen]
2416
2417        [[backends]]
2418        name = "svc"
2419        transport = "stdio"
2420        command = "echo"
2421        expose_resources = ["file:///a"]
2422        hide_resources = ["file:///b"]
2423        "#;
2424
2425        let err = ProxyConfig::parse(toml).unwrap_err();
2426        assert!(
2427            format!("{err}").contains("cannot specify both expose_resources and hide_resources"),
2428            "unexpected error: {err}"
2429        );
2430    }
2431
2432    #[test]
2433    fn test_reject_expose_and_hide_prompts() {
2434        let toml = r#"
2435        [proxy]
2436        name = "bad"
2437        [proxy.listen]
2438
2439        [[backends]]
2440        name = "svc"
2441        transport = "stdio"
2442        command = "echo"
2443        expose_prompts = ["help"]
2444        hide_prompts = ["admin"]
2445        "#;
2446
2447        let err = ProxyConfig::parse(toml).unwrap_err();
2448        assert!(
2449            format!("{err}").contains("cannot specify both expose_prompts and hide_prompts"),
2450            "unexpected error: {err}"
2451        );
2452    }
2453
2454    // ========================================================================
2455    // Env var resolution
2456    // ========================================================================
2457
2458    #[test]
2459    fn test_resolve_env_vars() {
2460        // SAFETY: test runs single-threaded, no other threads reading this var
2461        unsafe { std::env::set_var("MCP_GW_TEST_TOKEN", "secret-123") };
2462
2463        let toml = r#"
2464        [proxy]
2465        name = "env-test"
2466        [proxy.listen]
2467
2468        [[backends]]
2469        name = "svc"
2470        transport = "stdio"
2471        command = "echo"
2472
2473        [backends.env]
2474        API_TOKEN = "${MCP_GW_TEST_TOKEN}"
2475        STATIC_VAL = "unchanged"
2476        "#;
2477
2478        let mut config = ProxyConfig::parse(toml).unwrap();
2479        config.resolve_env_vars();
2480
2481        assert_eq!(
2482            config.backends[0].env.get("API_TOKEN").unwrap(),
2483            "secret-123"
2484        );
2485        assert_eq!(
2486            config.backends[0].env.get("STATIC_VAL").unwrap(),
2487            "unchanged"
2488        );
2489
2490        // SAFETY: same as above
2491        unsafe { std::env::remove_var("MCP_GW_TEST_TOKEN") };
2492    }
2493
2494    #[test]
2495    fn reject_invalid_or_unsupported_headers() {
2496        let base = "[proxy]\nname = 'test'\n[proxy.listen]\n[[backends]]\nname = 'api'\ntransport = 'http'\nurl = 'http://localhost:1'\n";
2497        for extra in [
2498            "headres = {}",
2499            "[backends.headers]\n'bad name' = 'value'",
2500            "[backends.headers]\nX = 'value'\nx = 'other'",
2501            "[backends.headers]\nX = \"line\\nvalue\"",
2502        ] {
2503            assert!(
2504                ProxyConfig::parse(&format!("{base}{extra}")).is_err(),
2505                "accepted: {extra}"
2506            );
2507        }
2508        let stdio = "[proxy]\nname = 'test'\n[proxy.listen]\n[[backends]]\nname = 'local'\ntransport = 'stdio'\ncommand = 'echo'\nheaders = { X = 'value' }";
2509        assert!(ProxyConfig::parse(stdio).is_err());
2510    }
2511
2512    #[test]
2513    fn test_parse_backend_headers() {
2514        let toml = r#"
2515        [proxy]
2516        name = "hdr-gw"
2517        [proxy.listen]
2518
2519        [[backends]]
2520        name = "signoz"
2521        transport = "http"
2522        url = "http://localhost:3000"
2523        bearer_token = "tok"
2524
2525        [backends.headers]
2526        "X-API-Key" = "key123"
2527        "X-Tenant" = "acme"
2528        "#;
2529
2530        let config = ProxyConfig::parse(toml).unwrap();
2531        let headers = &config.backends[0].headers;
2532        assert_eq!(headers.get("X-API-Key").map(String::as_str), Some("key123"));
2533        assert_eq!(headers.get("X-Tenant").map(String::as_str), Some("acme"));
2534        assert!(config.backends[0].bearer_token.is_some());
2535        // backends without a headers block default to empty
2536        let empty = ProxyConfig::parse(
2537            r#"
2538        [proxy]
2539        name = "g"
2540        [proxy.listen]
2541
2542        [[backends]]
2543        name = "db"
2544        transport = "http"
2545        url = "http://localhost:1"
2546        "#,
2547        )
2548        .unwrap();
2549        assert!(empty.backends[0].headers.is_empty());
2550    }
2551
2552    #[test]
2553    fn test_resolve_headers_env_var() {
2554        unsafe { std::env::set_var("MCP_GW_TEST_HEADER", "resolved-value") };
2555        let toml = r#"
2556        [proxy]
2557        name = "hdr-env-gw"
2558        [proxy.listen]
2559
2560        [[backends]]
2561        name = "signoz"
2562        transport = "http"
2563        url = "http://localhost:3000"
2564
2565        [backends.headers]
2566        "X-API-Key" = "${MCP_GW_TEST_HEADER}"
2567        "X-Literal" = "plain"
2568        "#;
2569        let mut config = ProxyConfig::parse(toml).unwrap();
2570        config.resolve_env_vars();
2571        unsafe { std::env::remove_var("MCP_GW_TEST_HEADER") };
2572        let headers = &config.backends[0].headers;
2573        assert_eq!(
2574            headers.get("X-API-Key").map(String::as_str),
2575            Some("resolved-value")
2576        );
2577        assert_eq!(headers.get("X-Literal").map(String::as_str), Some("plain"));
2578    }
2579
2580    #[test]
2581    fn test_parse_bearer_token_and_forward_auth() {
2582        let toml = r#"
2583        [proxy]
2584        name = "token-gw"
2585        [proxy.listen]
2586
2587        [[backends]]
2588        name = "github"
2589        transport = "http"
2590        url = "http://localhost:3000"
2591        bearer_token = "ghp_abc123"
2592        forward_auth = true
2593
2594        [[backends]]
2595        name = "db"
2596        transport = "http"
2597        url = "http://localhost:5432"
2598        "#;
2599
2600        let config = ProxyConfig::parse(toml).unwrap();
2601        assert_eq!(
2602            config.backends[0].bearer_token.as_deref(),
2603            Some("ghp_abc123")
2604        );
2605        assert!(config.backends[0].forward_auth);
2606        assert!(config.backends[1].bearer_token.is_none());
2607        assert!(!config.backends[1].forward_auth);
2608    }
2609
2610    #[test]
2611    fn test_resolve_bearer_token_env_var() {
2612        unsafe { std::env::set_var("MCP_GW_TEST_BEARER", "resolved-token") };
2613
2614        let toml = r#"
2615        [proxy]
2616        name = "env-token"
2617        [proxy.listen]
2618
2619        [[backends]]
2620        name = "api"
2621        transport = "http"
2622        url = "http://localhost:3000"
2623        bearer_token = "${MCP_GW_TEST_BEARER}"
2624        "#;
2625
2626        let mut config = ProxyConfig::parse(toml).unwrap();
2627        config.resolve_env_vars();
2628
2629        assert_eq!(
2630            config.backends[0].bearer_token.as_deref(),
2631            Some("resolved-token")
2632        );
2633
2634        unsafe { std::env::remove_var("MCP_GW_TEST_BEARER") };
2635    }
2636
2637    #[test]
2638    fn test_parse_outlier_detection() {
2639        let toml = r#"
2640        [proxy]
2641        name = "od-gw"
2642        [proxy.listen]
2643
2644        [[backends]]
2645        name = "flaky"
2646        transport = "http"
2647        url = "http://localhost:8080"
2648
2649        [backends.outlier_detection]
2650        consecutive_errors = 3
2651        interval_seconds = 5
2652        base_ejection_seconds = 60
2653        max_ejection_percent = 25
2654        "#;
2655
2656        let config = ProxyConfig::parse(toml).unwrap();
2657        let od = config.backends[0]
2658            .outlier_detection
2659            .as_ref()
2660            .expect("should have outlier_detection");
2661        assert_eq!(od.consecutive_errors, 3);
2662        assert_eq!(od.interval_seconds, 5);
2663        assert_eq!(od.base_ejection_seconds, 60);
2664        assert_eq!(od.max_ejection_percent, 25);
2665    }
2666
2667    #[test]
2668    fn test_parse_outlier_detection_defaults() {
2669        let toml = r#"
2670        [proxy]
2671        name = "od-gw"
2672        [proxy.listen]
2673
2674        [[backends]]
2675        name = "flaky"
2676        transport = "http"
2677        url = "http://localhost:8080"
2678
2679        [backends.outlier_detection]
2680        "#;
2681
2682        let config = ProxyConfig::parse(toml).unwrap();
2683        let od = config.backends[0]
2684            .outlier_detection
2685            .as_ref()
2686            .expect("should have outlier_detection");
2687        assert_eq!(od.consecutive_errors, 5);
2688        assert_eq!(od.interval_seconds, 10);
2689        assert_eq!(od.base_ejection_seconds, 30);
2690        assert_eq!(od.max_ejection_percent, 50);
2691    }
2692
2693    #[test]
2694    fn test_parse_mirror_config() {
2695        let toml = r#"
2696        [proxy]
2697        name = "mirror-gw"
2698        [proxy.listen]
2699
2700        [[backends]]
2701        name = "api"
2702        transport = "http"
2703        url = "http://localhost:8080"
2704
2705        [[backends]]
2706        name = "api-v2"
2707        transport = "http"
2708        url = "http://localhost:8081"
2709        mirror_of = "api"
2710        mirror_percent = 10
2711        "#;
2712
2713        let config = ProxyConfig::parse(toml).unwrap();
2714        assert!(config.backends[0].mirror_of.is_none());
2715        assert_eq!(config.backends[1].mirror_of.as_deref(), Some("api"));
2716        assert_eq!(config.backends[1].mirror_percent, 10);
2717    }
2718
2719    #[test]
2720    fn test_mirror_percent_defaults_to_100() {
2721        let toml = r#"
2722        [proxy]
2723        name = "mirror-gw"
2724        [proxy.listen]
2725
2726        [[backends]]
2727        name = "api"
2728        transport = "http"
2729        url = "http://localhost:8080"
2730
2731        [[backends]]
2732        name = "api-v2"
2733        transport = "http"
2734        url = "http://localhost:8081"
2735        mirror_of = "api"
2736        "#;
2737
2738        let config = ProxyConfig::parse(toml).unwrap();
2739        assert_eq!(config.backends[1].mirror_percent, 100);
2740    }
2741
2742    #[test]
2743    fn test_reject_mirror_unknown_backend() {
2744        let toml = r#"
2745        [proxy]
2746        name = "bad"
2747        [proxy.listen]
2748
2749        [[backends]]
2750        name = "api-v2"
2751        transport = "http"
2752        url = "http://localhost:8081"
2753        mirror_of = "nonexistent"
2754        "#;
2755
2756        let err = ProxyConfig::parse(toml).unwrap_err();
2757        assert!(
2758            format!("{err}").contains("mirror_of references unknown backend"),
2759            "unexpected error: {err}"
2760        );
2761    }
2762
2763    #[test]
2764    fn test_reject_mirror_percent_over_100() {
2765        let toml = r#"
2766        [proxy]
2767        name = "bad"
2768        [proxy.listen]
2769
2770        [[backends]]
2771        name = "primary"
2772        transport = "stdio"
2773        command = "echo"
2774
2775        [[backends]]
2776        name = "mirror"
2777        transport = "stdio"
2778        command = "echo"
2779        mirror_of = "primary"
2780        mirror_percent = 101
2781        "#;
2782        let err = ProxyConfig::parse(toml).unwrap_err();
2783        assert!(
2784            format!("{err}").contains("mirror_percent must be 0-100"),
2785            "unexpected error: {err}"
2786        );
2787    }
2788
2789    #[test]
2790    fn test_reject_canary_weight_over_100() {
2791        let toml = r#"
2792        [proxy]
2793        name = "bad"
2794        [proxy.listen]
2795
2796        [[backends]]
2797        name = "primary"
2798        transport = "stdio"
2799        command = "echo"
2800
2801        [[backends]]
2802        name = "canary"
2803        transport = "stdio"
2804        command = "echo"
2805        canary_of = "primary"
2806        weight = 101
2807        "#;
2808        let err = ProxyConfig::parse(toml).unwrap_err();
2809        assert!(
2810            format!("{err}").contains("weight must be 1-100"),
2811            "unexpected error: {err}"
2812        );
2813    }
2814
2815    #[test]
2816    fn test_reject_mirror_self() {
2817        let toml = r#"
2818        [proxy]
2819        name = "bad"
2820        [proxy.listen]
2821
2822        [[backends]]
2823        name = "api"
2824        transport = "http"
2825        url = "http://localhost:8080"
2826        mirror_of = "api"
2827        "#;
2828
2829        let err = ProxyConfig::parse(toml).unwrap_err();
2830        assert!(
2831            format!("{err}").contains("mirror_of cannot reference itself"),
2832            "unexpected error: {err}"
2833        );
2834    }
2835
2836    #[test]
2837    fn test_parse_hedging_config() {
2838        let toml = r#"
2839        [proxy]
2840        name = "hedge-gw"
2841        [proxy.listen]
2842
2843        [[backends]]
2844        name = "api"
2845        transport = "http"
2846        url = "http://localhost:8080"
2847
2848        [backends.hedging]
2849        delay_ms = 150
2850        max_hedges = 2
2851        "#;
2852
2853        let config = ProxyConfig::parse(toml).unwrap();
2854        let hedge = config.backends[0]
2855            .hedging
2856            .as_ref()
2857            .expect("should have hedging");
2858        assert_eq!(hedge.delay_ms, 150);
2859        assert_eq!(hedge.max_hedges, 2);
2860    }
2861
2862    #[test]
2863    fn test_parse_hedging_defaults() {
2864        let toml = r#"
2865        [proxy]
2866        name = "hedge-gw"
2867        [proxy.listen]
2868
2869        [[backends]]
2870        name = "api"
2871        transport = "http"
2872        url = "http://localhost:8080"
2873
2874        [backends.hedging]
2875        "#;
2876
2877        let config = ProxyConfig::parse(toml).unwrap();
2878        let hedge = config.backends[0]
2879            .hedging
2880            .as_ref()
2881            .expect("should have hedging");
2882        assert_eq!(hedge.delay_ms, 200);
2883        assert_eq!(hedge.max_hedges, 1);
2884    }
2885
2886    // ========================================================================
2887    // Capability filter building
2888    // ========================================================================
2889
2890    #[test]
2891    fn test_build_filter_allowlist() {
2892        let toml = r#"
2893        [proxy]
2894        name = "filter"
2895        [proxy.listen]
2896
2897        [[backends]]
2898        name = "svc"
2899        transport = "stdio"
2900        command = "echo"
2901        expose_tools = ["read", "list"]
2902        "#;
2903
2904        let config = ProxyConfig::parse(toml).unwrap();
2905        let filter = config.backends[0]
2906            .build_filter(&config.proxy.separator)
2907            .unwrap()
2908            .expect("should have filter");
2909        assert_eq!(filter.namespace, "svc/");
2910        assert!(filter.tool_filter.allows("read"));
2911        assert!(filter.tool_filter.allows("list"));
2912        assert!(!filter.tool_filter.allows("delete"));
2913    }
2914
2915    #[test]
2916    fn test_build_filter_denylist() {
2917        let toml = r#"
2918        [proxy]
2919        name = "filter"
2920        [proxy.listen]
2921
2922        [[backends]]
2923        name = "svc"
2924        transport = "stdio"
2925        command = "echo"
2926        hide_tools = ["delete", "write"]
2927        "#;
2928
2929        let config = ProxyConfig::parse(toml).unwrap();
2930        let filter = config.backends[0]
2931            .build_filter(&config.proxy.separator)
2932            .unwrap()
2933            .expect("should have filter");
2934        assert!(filter.tool_filter.allows("read"));
2935        assert!(!filter.tool_filter.allows("delete"));
2936        assert!(!filter.tool_filter.allows("write"));
2937    }
2938
2939    #[test]
2940    fn test_parse_inject_args() {
2941        let toml = r#"
2942        [proxy]
2943        name = "inject-gw"
2944        [proxy.listen]
2945
2946        [[backends]]
2947        name = "db"
2948        transport = "http"
2949        url = "http://localhost:8080"
2950
2951        [backends.default_args]
2952        timeout = 30
2953
2954        [[backends.inject_args]]
2955        tool = "query"
2956        args = { read_only = true, max_rows = 1000 }
2957
2958        [[backends.inject_args]]
2959        tool = "dangerous_op"
2960        args = { dry_run = true }
2961        overwrite = true
2962        "#;
2963
2964        let config = ProxyConfig::parse(toml).unwrap();
2965        let backend = &config.backends[0];
2966
2967        assert_eq!(backend.default_args.len(), 1);
2968        assert_eq!(backend.default_args["timeout"], 30);
2969
2970        assert_eq!(backend.inject_args.len(), 2);
2971        assert_eq!(backend.inject_args[0].tool, "query");
2972        assert_eq!(backend.inject_args[0].args["read_only"], true);
2973        assert_eq!(backend.inject_args[0].args["max_rows"], 1000);
2974        assert!(!backend.inject_args[0].overwrite);
2975
2976        assert_eq!(backend.inject_args[1].tool, "dangerous_op");
2977        assert_eq!(backend.inject_args[1].args["dry_run"], true);
2978        assert!(backend.inject_args[1].overwrite);
2979    }
2980
2981    #[test]
2982    fn test_parse_inject_args_defaults_to_empty() {
2983        let config = ProxyConfig::parse(minimal_config()).unwrap();
2984        assert!(config.backends[0].default_args.is_empty());
2985        assert!(config.backends[0].inject_args.is_empty());
2986    }
2987
2988    #[test]
2989    fn test_build_filter_none_when_no_filtering() {
2990        let config = ProxyConfig::parse(minimal_config()).unwrap();
2991        assert!(
2992            config.backends[0]
2993                .build_filter(&config.proxy.separator)
2994                .unwrap()
2995                .is_none()
2996        );
2997    }
2998
2999    #[test]
3000    fn test_validate_rejects_duplicate_backend_names() {
3001        let toml = r#"
3002        [proxy]
3003        name = "test"
3004        [proxy.listen]
3005
3006        [[backends]]
3007        name = "echo"
3008        transport = "stdio"
3009        command = "echo"
3010
3011        [[backends]]
3012        name = "echo"
3013        transport = "stdio"
3014        command = "cat"
3015        "#;
3016        let err = ProxyConfig::parse(toml).unwrap_err();
3017        assert!(
3018            err.to_string().contains("duplicate backend name"),
3019            "expected duplicate error, got: {}",
3020            err
3021        );
3022    }
3023
3024    #[test]
3025    fn test_validate_global_rate_limit_zero_requests() {
3026        let toml = r#"
3027        [proxy]
3028        name = "test"
3029        [proxy.listen]
3030        [proxy.rate_limit]
3031        requests = 0
3032
3033        [[backends]]
3034        name = "echo"
3035        transport = "stdio"
3036        command = "echo"
3037        "#;
3038        let err = ProxyConfig::parse(toml).unwrap_err();
3039        assert!(err.to_string().contains("requests must be > 0"));
3040    }
3041
3042    #[test]
3043    fn test_validate_jwt_requires_admin_token() {
3044        // JWT auth without security.admin_token must be rejected: the admin
3045        // plane has no token fallback for JWT/OAuth and would be left open.
3046        let toml = r#"
3047        [proxy]
3048        name = "jwt-gw"
3049        [proxy.listen]
3050
3051        [[backends]]
3052        name = "echo"
3053        transport = "stdio"
3054        command = "echo"
3055
3056        [auth]
3057        type = "jwt"
3058        issuer = "https://auth.example.com"
3059        audience = "mcp-proxy"
3060        jwks_uri = "https://auth.example.com/.well-known/jwks.json"
3061        "#;
3062        let err = ProxyConfig::parse(toml).unwrap_err();
3063        assert!(
3064            err.to_string().contains("admin_token"),
3065            "expected admin_token error, got: {err}"
3066        );
3067    }
3068
3069    #[test]
3070    fn test_validate_jwt_with_admin_token_ok() {
3071        // Same config with an explicit admin_token validates successfully.
3072        let toml = r#"
3073        [proxy]
3074        name = "jwt-gw"
3075        [proxy.listen]
3076
3077        [[backends]]
3078        name = "echo"
3079        transport = "stdio"
3080        command = "echo"
3081
3082        [auth]
3083        type = "jwt"
3084        issuer = "https://auth.example.com"
3085        audience = "mcp-proxy"
3086        jwks_uri = "https://auth.example.com/.well-known/jwks.json"
3087
3088        [security]
3089        admin_token = "admin-secret"
3090        "#;
3091        assert!(ProxyConfig::parse(toml).is_ok());
3092    }
3093
3094    #[test]
3095    fn test_validate_oauth_requires_admin_token() {
3096        // OAuth (JWT-validation strategy, so credentials aren't required) without
3097        // admin_token must also be rejected.
3098        let toml = r#"
3099        [proxy]
3100        name = "oauth-gw"
3101        [proxy.listen]
3102
3103        [[backends]]
3104        name = "echo"
3105        transport = "stdio"
3106        command = "echo"
3107
3108        [auth]
3109        type = "oauth"
3110        issuer = "https://auth.example.com"
3111        audience = "mcp-proxy"
3112        "#;
3113        let err = ProxyConfig::parse(toml).unwrap_err();
3114        assert!(
3115            err.to_string().contains("admin_token"),
3116            "expected admin_token error, got: {err}"
3117        );
3118    }
3119
3120    #[test]
3121    fn test_parse_global_rate_limit() {
3122        let toml = r#"
3123        [proxy]
3124        name = "test"
3125        [proxy.listen]
3126        [proxy.rate_limit]
3127        requests = 500
3128        period_seconds = 1
3129
3130        [[backends]]
3131        name = "echo"
3132        transport = "stdio"
3133        command = "echo"
3134        "#;
3135        let config = ProxyConfig::parse(toml).unwrap();
3136        let rl = config.proxy.rate_limit.unwrap();
3137        assert_eq!(rl.requests, 500);
3138        assert_eq!(rl.period_seconds, 1);
3139    }
3140
3141    #[test]
3142    fn test_name_filter_glob_wildcard() {
3143        let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
3144        assert!(filter.allows("read_file"));
3145        assert!(filter.allows("write_file"));
3146        assert!(!filter.allows("query"));
3147        assert!(!filter.allows("file_read"));
3148    }
3149
3150    #[test]
3151    fn test_name_filter_glob_prefix() {
3152        let filter = NameFilter::allow_list(["list_*".to_string()]).unwrap();
3153        assert!(filter.allows("list_files"));
3154        assert!(filter.allows("list_users"));
3155        assert!(!filter.allows("get_files"));
3156    }
3157
3158    #[test]
3159    fn test_name_filter_glob_question_mark() {
3160        let filter = NameFilter::allow_list(["get_?".to_string()]).unwrap();
3161        assert!(filter.allows("get_a"));
3162        assert!(filter.allows("get_1"));
3163        assert!(!filter.allows("get_ab"));
3164        assert!(!filter.allows("get_"));
3165    }
3166
3167    #[test]
3168    fn test_name_filter_glob_deny_list() {
3169        let filter = NameFilter::deny_list(["*_delete*".to_string()]).unwrap();
3170        assert!(filter.allows("read_file"));
3171        assert!(filter.allows("create_issue"));
3172        assert!(!filter.allows("force_delete_all"));
3173        assert!(!filter.allows("soft_delete"));
3174    }
3175
3176    #[test]
3177    fn test_name_filter_glob_exact_match_still_works() {
3178        let filter = NameFilter::allow_list(["read_file".to_string()]).unwrap();
3179        assert!(filter.allows("read_file"));
3180        assert!(!filter.allows("write_file"));
3181    }
3182
3183    #[test]
3184    fn test_name_filter_glob_multiple_patterns() {
3185        let filter = NameFilter::allow_list(["read_*".to_string(), "list_*".to_string()]).unwrap();
3186        assert!(filter.allows("read_file"));
3187        assert!(filter.allows("list_users"));
3188        assert!(!filter.allows("delete_file"));
3189    }
3190
3191    #[test]
3192    fn test_name_filter_regex_allow_list() {
3193        let filter =
3194            NameFilter::allow_list(["re:^list_.*$".to_string(), "re:^get_\\w+$".to_string()])
3195                .unwrap();
3196        assert!(filter.allows("list_files"));
3197        assert!(filter.allows("list_users"));
3198        assert!(filter.allows("get_item"));
3199        assert!(!filter.allows("delete_file"));
3200        assert!(!filter.allows("create_issue"));
3201    }
3202
3203    #[test]
3204    fn test_name_filter_regex_deny_list() {
3205        let filter = NameFilter::deny_list(["re:^delete_".to_string()]).unwrap();
3206        assert!(filter.allows("read_file"));
3207        assert!(filter.allows("list_users"));
3208        assert!(!filter.allows("delete_file"));
3209        assert!(!filter.allows("delete_all"));
3210    }
3211
3212    #[test]
3213    fn test_name_filter_mixed_glob_and_regex() {
3214        let filter =
3215            NameFilter::allow_list(["read_*".to_string(), "re:^list_\\w+$".to_string()]).unwrap();
3216        assert!(filter.allows("read_file"));
3217        assert!(filter.allows("read_dir"));
3218        assert!(filter.allows("list_users"));
3219        assert!(!filter.allows("delete_file"));
3220    }
3221
3222    #[test]
3223    fn test_name_filter_regex_invalid_pattern() {
3224        let result = NameFilter::allow_list(["re:[invalid".to_string()]);
3225        assert!(result.is_err(), "invalid regex should produce an error");
3226    }
3227
3228    #[test]
3229    fn test_name_filter_regex_partial_match() {
3230        // Regex without anchors matches substrings
3231        let filter = NameFilter::allow_list(["re:list".to_string()]).unwrap();
3232        assert!(filter.allows("list_files"));
3233        assert!(filter.allows("my_list_tool"));
3234        assert!(!filter.allows("read_file"));
3235    }
3236
3237    #[test]
3238    fn test_config_parse_regex_filter() {
3239        let toml = r#"
3240        [proxy]
3241        name = "regex-gw"
3242        [proxy.listen]
3243
3244        [[backends]]
3245        name = "svc"
3246        transport = "stdio"
3247        command = "echo"
3248        expose_tools = ["*_issue", "re:^list_.*$"]
3249        "#;
3250
3251        let config = ProxyConfig::parse(toml).unwrap();
3252        let filter = config.backends[0]
3253            .build_filter(&config.proxy.separator)
3254            .unwrap()
3255            .expect("should have filter");
3256        assert!(filter.tool_filter.allows("create_issue"));
3257        assert!(filter.tool_filter.allows("list_files"));
3258        assert!(filter.tool_filter.allows("list_users"));
3259        assert!(!filter.tool_filter.allows("delete_file"));
3260    }
3261
3262    #[test]
3263    fn test_parse_param_overrides() {
3264        let toml = r#"
3265        [proxy]
3266        name = "override-gw"
3267        [proxy.listen]
3268
3269        [[backends]]
3270        name = "fs"
3271        transport = "http"
3272        url = "http://localhost:8080"
3273
3274        [[backends.param_overrides]]
3275        tool = "list_directory"
3276        hide = ["path"]
3277        rename = { recursive = "deep_search" }
3278
3279        [backends.param_overrides.defaults]
3280        path = "/home/docs"
3281        "#;
3282
3283        let config = ProxyConfig::parse(toml).unwrap();
3284        assert_eq!(config.backends[0].param_overrides.len(), 1);
3285        let po = &config.backends[0].param_overrides[0];
3286        assert_eq!(po.tool, "list_directory");
3287        assert_eq!(po.hide, vec!["path"]);
3288        assert_eq!(po.defaults.get("path").unwrap(), "/home/docs");
3289        assert_eq!(po.rename.get("recursive").unwrap(), "deep_search");
3290    }
3291
3292    #[test]
3293    fn test_reject_param_override_empty_tool() {
3294        let toml = r#"
3295        [proxy]
3296        name = "bad"
3297        [proxy.listen]
3298
3299        [[backends]]
3300        name = "fs"
3301        transport = "http"
3302        url = "http://localhost:8080"
3303
3304        [[backends.param_overrides]]
3305        tool = ""
3306        hide = ["path"]
3307        "#;
3308
3309        let err = ProxyConfig::parse(toml).unwrap_err();
3310        assert!(
3311            format!("{err}").contains("tool must not be empty"),
3312            "unexpected error: {err}"
3313        );
3314    }
3315
3316    #[test]
3317    fn test_reject_param_override_duplicate_tool() {
3318        let toml = r#"
3319        [proxy]
3320        name = "bad"
3321        [proxy.listen]
3322
3323        [[backends]]
3324        name = "fs"
3325        transport = "http"
3326        url = "http://localhost:8080"
3327
3328        [[backends.param_overrides]]
3329        tool = "list_directory"
3330        hide = ["path"]
3331
3332        [[backends.param_overrides]]
3333        tool = "list_directory"
3334        hide = ["pattern"]
3335        "#;
3336
3337        let err = ProxyConfig::parse(toml).unwrap_err();
3338        assert!(
3339            format!("{err}").contains("duplicate param_overrides"),
3340            "unexpected error: {err}"
3341        );
3342    }
3343
3344    #[test]
3345    fn test_reject_param_override_hide_and_rename_same_param() {
3346        let toml = r#"
3347        [proxy]
3348        name = "bad"
3349        [proxy.listen]
3350
3351        [[backends]]
3352        name = "fs"
3353        transport = "http"
3354        url = "http://localhost:8080"
3355
3356        [[backends.param_overrides]]
3357        tool = "list_directory"
3358        hide = ["path"]
3359        rename = { path = "dir" }
3360        "#;
3361
3362        let err = ProxyConfig::parse(toml).unwrap_err();
3363        assert!(
3364            format!("{err}").contains("cannot be both hidden and renamed"),
3365            "unexpected error: {err}"
3366        );
3367    }
3368
3369    #[test]
3370    fn test_reject_param_override_duplicate_rename_target() {
3371        let toml = r#"
3372        [proxy]
3373        name = "bad"
3374        [proxy.listen]
3375
3376        [[backends]]
3377        name = "fs"
3378        transport = "http"
3379        url = "http://localhost:8080"
3380
3381        [[backends.param_overrides]]
3382        tool = "list_directory"
3383        rename = { path = "location", dir = "location" }
3384        "#;
3385
3386        let err = ProxyConfig::parse(toml).unwrap_err();
3387        assert!(
3388            format!("{err}").contains("duplicate rename target"),
3389            "unexpected error: {err}"
3390        );
3391    }
3392
3393    #[test]
3394    fn test_cache_backend_defaults_to_memory() {
3395        let config = ProxyConfig::parse(minimal_config()).unwrap();
3396        assert_eq!(config.cache.backend, "memory");
3397        assert!(config.cache.url.is_none());
3398    }
3399
3400    #[test]
3401    fn test_cache_backend_redis_requires_url() {
3402        let toml = r#"
3403        [proxy]
3404        name = "test"
3405        [proxy.listen]
3406        [cache]
3407        backend = "redis"
3408
3409        [[backends]]
3410        name = "echo"
3411        transport = "stdio"
3412        command = "echo"
3413        "#;
3414        let err = ProxyConfig::parse(toml).unwrap_err();
3415        assert!(err.to_string().contains("cache.url is required"));
3416    }
3417
3418    #[test]
3419    fn test_cache_backend_unknown_rejected() {
3420        let toml = r#"
3421        [proxy]
3422        name = "test"
3423        [proxy.listen]
3424        [cache]
3425        backend = "memcached"
3426
3427        [[backends]]
3428        name = "echo"
3429        transport = "stdio"
3430        command = "echo"
3431        "#;
3432        let err = ProxyConfig::parse(toml).unwrap_err();
3433        assert!(err.to_string().contains("unknown cache backend"));
3434    }
3435
3436    const REDIS_CACHE_CONFIG: &str = r#"
3437        [proxy]
3438        name = "test"
3439        [proxy.listen]
3440        [cache]
3441        backend = "redis"
3442        url = "redis://localhost:6379"
3443        prefix = "myapp:"
3444
3445        [[backends]]
3446        name = "echo"
3447        transport = "stdio"
3448        command = "echo"
3449        "#;
3450
3451    #[cfg(feature = "redis-cache")]
3452    #[test]
3453    fn test_cache_backend_redis_with_url() {
3454        let config = ProxyConfig::parse(REDIS_CACHE_CONFIG).unwrap();
3455        assert_eq!(config.cache.backend, "redis");
3456        assert_eq!(config.cache.url.as_deref(), Some("redis://localhost:6379"));
3457        assert_eq!(config.cache.prefix, "myapp:");
3458    }
3459
3460    #[cfg(not(feature = "redis-cache"))]
3461    #[test]
3462    fn test_cache_backend_redis_rejected_without_feature() {
3463        let err = ProxyConfig::parse(REDIS_CACHE_CONFIG).unwrap_err();
3464        assert!(
3465            err.to_string()
3466                .contains("requires the 'redis-cache' feature")
3467        );
3468    }
3469
3470    const SQLITE_CACHE_CONFIG: &str = r#"
3471        [proxy]
3472        name = "test"
3473        [proxy.listen]
3474        [cache]
3475        backend = "sqlite"
3476        url = "cache.db"
3477
3478        [[backends]]
3479        name = "echo"
3480        transport = "stdio"
3481        command = "echo"
3482        "#;
3483
3484    #[cfg(feature = "sqlite-cache")]
3485    #[test]
3486    fn test_cache_backend_sqlite_with_url() {
3487        let config = ProxyConfig::parse(SQLITE_CACHE_CONFIG).unwrap();
3488        assert_eq!(config.cache.backend, "sqlite");
3489        assert_eq!(config.cache.url.as_deref(), Some("cache.db"));
3490    }
3491
3492    #[cfg(not(feature = "sqlite-cache"))]
3493    #[test]
3494    fn test_cache_backend_sqlite_rejected_without_feature() {
3495        let err = ProxyConfig::parse(SQLITE_CACHE_CONFIG).unwrap_err();
3496        assert!(
3497            err.to_string()
3498                .contains("requires the 'sqlite-cache' feature")
3499        );
3500    }
3501
3502    #[cfg(not(feature = "websocket"))]
3503    const WEBSOCKET_BACKEND_CONFIG: &str = r#"
3504        [proxy]
3505        name = "test"
3506        [proxy.listen]
3507
3508        [[backends]]
3509        name = "ws"
3510        transport = "websocket"
3511        url = "ws://localhost:9000"
3512        "#;
3513
3514    #[cfg(not(feature = "websocket"))]
3515    #[test]
3516    fn test_websocket_transport_rejected_without_feature() {
3517        let err = ProxyConfig::parse(WEBSOCKET_BACKEND_CONFIG).unwrap_err();
3518        assert!(err.to_string().contains("requires the 'websocket' feature"));
3519    }
3520
3521    #[test]
3522    fn test_parse_bearer_scoped_tokens() {
3523        let toml = r#"
3524        [proxy]
3525        name = "scoped"
3526        [proxy.listen]
3527
3528        [[backends]]
3529        name = "echo"
3530        transport = "stdio"
3531        command = "echo"
3532
3533        [auth]
3534        type = "bearer"
3535
3536        [[auth.scoped_tokens]]
3537        token = "frontend-token"
3538        allow_tools = ["echo/read_file"]
3539
3540        [[auth.scoped_tokens]]
3541        token = "admin-token"
3542        "#;
3543
3544        let config = ProxyConfig::parse(toml).unwrap();
3545        match &config.auth {
3546            Some(AuthConfig::Bearer {
3547                tokens,
3548                scoped_tokens,
3549            }) => {
3550                assert!(tokens.is_empty());
3551                assert_eq!(scoped_tokens.len(), 2);
3552                assert_eq!(scoped_tokens[0].token, "frontend-token");
3553                assert_eq!(scoped_tokens[0].allow_tools, vec!["echo/read_file"]);
3554                assert!(scoped_tokens[1].allow_tools.is_empty());
3555            }
3556            other => panic!("expected Bearer auth, got: {other:?}"),
3557        }
3558    }
3559
3560    #[test]
3561    fn test_parse_bearer_mixed_tokens() {
3562        let toml = r#"
3563        [proxy]
3564        name = "mixed"
3565        [proxy.listen]
3566
3567        [[backends]]
3568        name = "echo"
3569        transport = "stdio"
3570        command = "echo"
3571
3572        [auth]
3573        type = "bearer"
3574        tokens = ["simple-token"]
3575
3576        [[auth.scoped_tokens]]
3577        token = "scoped-token"
3578        deny_tools = ["echo/delete"]
3579        "#;
3580
3581        let config = ProxyConfig::parse(toml).unwrap();
3582        match &config.auth {
3583            Some(AuthConfig::Bearer {
3584                tokens,
3585                scoped_tokens,
3586            }) => {
3587                assert_eq!(tokens, &["simple-token"]);
3588                assert_eq!(scoped_tokens.len(), 1);
3589                assert_eq!(scoped_tokens[0].deny_tools, vec!["echo/delete"]);
3590            }
3591            other => panic!("expected Bearer auth, got: {other:?}"),
3592        }
3593    }
3594
3595    #[test]
3596    fn test_bearer_empty_tokens_rejected() {
3597        let toml = r#"
3598        [proxy]
3599        name = "empty"
3600        [proxy.listen]
3601
3602        [[backends]]
3603        name = "echo"
3604        transport = "stdio"
3605        command = "echo"
3606
3607        [auth]
3608        type = "bearer"
3609        "#;
3610
3611        let err = ProxyConfig::parse(toml).unwrap_err();
3612        assert!(
3613            err.to_string().contains("at least one token"),
3614            "unexpected error: {err}"
3615        );
3616    }
3617
3618    #[test]
3619    fn test_bearer_duplicate_across_lists_rejected() {
3620        let toml = r#"
3621        [proxy]
3622        name = "dup"
3623        [proxy.listen]
3624
3625        [[backends]]
3626        name = "echo"
3627        transport = "stdio"
3628        command = "echo"
3629
3630        [auth]
3631        type = "bearer"
3632        tokens = ["shared-token"]
3633
3634        [[auth.scoped_tokens]]
3635        token = "shared-token"
3636        allow_tools = ["echo/read"]
3637        "#;
3638
3639        let err = ProxyConfig::parse(toml).unwrap_err();
3640        assert!(
3641            err.to_string().contains("duplicate bearer token"),
3642            "unexpected error: {err}"
3643        );
3644    }
3645
3646    #[test]
3647    fn test_bearer_allow_and_deny_rejected() {
3648        let toml = r#"
3649        [proxy]
3650        name = "both"
3651        [proxy.listen]
3652
3653        [[backends]]
3654        name = "echo"
3655        transport = "stdio"
3656        command = "echo"
3657
3658        [auth]
3659        type = "bearer"
3660
3661        [[auth.scoped_tokens]]
3662        token = "conflict"
3663        allow_tools = ["echo/read"]
3664        deny_tools = ["echo/write"]
3665        "#;
3666
3667        let err = ProxyConfig::parse(toml).unwrap_err();
3668        assert!(
3669            err.to_string().contains("cannot specify both"),
3670            "unexpected error: {err}"
3671        );
3672    }
3673
3674    #[cfg(feature = "websocket")]
3675    #[test]
3676    fn test_parse_websocket_transport() {
3677        let toml = r#"
3678        [proxy]
3679        name = "ws-proxy"
3680        [proxy.listen]
3681
3682        [[backends]]
3683        name = "ws-backend"
3684        transport = "websocket"
3685        url = "ws://localhost:9090/ws"
3686        "#;
3687
3688        let config = ProxyConfig::parse(toml).unwrap();
3689        assert!(matches!(
3690            config.backends[0].transport,
3691            TransportType::Websocket
3692        ));
3693        assert_eq!(
3694            config.backends[0].url.as_deref(),
3695            Some("ws://localhost:9090/ws")
3696        );
3697    }
3698
3699    #[test]
3700    fn test_websocket_transport_requires_url() {
3701        let toml = r#"
3702        [proxy]
3703        name = "ws-proxy"
3704        [proxy.listen]
3705
3706        [[backends]]
3707        name = "ws-backend"
3708        transport = "websocket"
3709        "#;
3710
3711        let err = ProxyConfig::parse(toml).unwrap_err();
3712        assert!(
3713            err.to_string()
3714                .contains("websocket transport requires 'url'"),
3715            "unexpected error: {err}"
3716        );
3717    }
3718
3719    #[cfg(feature = "websocket")]
3720    #[test]
3721    fn test_websocket_with_bearer_token() {
3722        let toml = r#"
3723        [proxy]
3724        name = "ws-proxy"
3725        [proxy.listen]
3726
3727        [[backends]]
3728        name = "ws-backend"
3729        transport = "websocket"
3730        url = "wss://secure.example.com/mcp"
3731        bearer_token = "my-secret"
3732        "#;
3733
3734        let config = ProxyConfig::parse(toml).unwrap();
3735        assert_eq!(
3736            config.backends[0].bearer_token.as_deref(),
3737            Some("my-secret")
3738        );
3739    }
3740
3741    #[test]
3742    fn test_tool_discovery_defaults_false() {
3743        let config = ProxyConfig::parse(minimal_config()).unwrap();
3744        assert!(!config.proxy.tool_discovery);
3745    }
3746
3747    #[test]
3748    fn test_tool_discovery_enabled() {
3749        let toml = r#"
3750        [proxy]
3751        name = "discovery"
3752        tool_discovery = true
3753        [proxy.listen]
3754
3755        [[backends]]
3756        name = "echo"
3757        transport = "stdio"
3758        command = "echo"
3759        "#;
3760
3761        let config = ProxyConfig::parse(toml).unwrap();
3762        assert!(config.proxy.tool_discovery);
3763    }
3764
3765    #[test]
3766    fn test_parse_oauth_config() {
3767        let toml = r#"
3768        [proxy]
3769        name = "oauth-proxy"
3770        [proxy.listen]
3771
3772        [[backends]]
3773        name = "echo"
3774        transport = "stdio"
3775        command = "echo"
3776
3777        [auth]
3778        type = "oauth"
3779        issuer = "https://accounts.google.com"
3780        audience = "mcp-proxy"
3781
3782        [security]
3783        admin_token = "admin-secret"
3784        "#;
3785
3786        let config = ProxyConfig::parse(toml).unwrap();
3787        match &config.auth {
3788            Some(AuthConfig::OAuth {
3789                issuer,
3790                audience,
3791                token_validation,
3792                ..
3793            }) => {
3794                assert_eq!(issuer, "https://accounts.google.com");
3795                assert_eq!(audience, "mcp-proxy");
3796                assert_eq!(token_validation, &TokenValidationStrategy::Jwt);
3797            }
3798            other => panic!("expected OAuth auth, got: {other:?}"),
3799        }
3800    }
3801
3802    #[test]
3803    fn test_parse_oauth_with_introspection() {
3804        let toml = r#"
3805        [proxy]
3806        name = "oauth-proxy"
3807        [proxy.listen]
3808
3809        [[backends]]
3810        name = "echo"
3811        transport = "stdio"
3812        command = "echo"
3813
3814        [auth]
3815        type = "oauth"
3816        issuer = "https://auth.example.com"
3817        audience = "mcp-proxy"
3818        client_id = "my-client"
3819        client_secret = "my-secret"
3820        token_validation = "introspection"
3821
3822        [security]
3823        admin_token = "admin-secret"
3824        "#;
3825
3826        let config = ProxyConfig::parse(toml).unwrap();
3827        match &config.auth {
3828            Some(AuthConfig::OAuth {
3829                token_validation,
3830                client_id,
3831                client_secret,
3832                ..
3833            }) => {
3834                assert_eq!(token_validation, &TokenValidationStrategy::Introspection);
3835                assert_eq!(client_id.as_deref(), Some("my-client"));
3836                assert_eq!(client_secret.as_deref(), Some("my-secret"));
3837            }
3838            other => panic!("expected OAuth auth, got: {other:?}"),
3839        }
3840    }
3841
3842    #[test]
3843    fn test_oauth_introspection_requires_credentials() {
3844        let toml = r#"
3845        [proxy]
3846        name = "oauth-proxy"
3847        [proxy.listen]
3848
3849        [[backends]]
3850        name = "echo"
3851        transport = "stdio"
3852        command = "echo"
3853
3854        [auth]
3855        type = "oauth"
3856        issuer = "https://auth.example.com"
3857        audience = "mcp-proxy"
3858        token_validation = "introspection"
3859        "#;
3860
3861        let err = ProxyConfig::parse(toml).unwrap_err();
3862        assert!(
3863            err.to_string().contains("client_id"),
3864            "unexpected error: {err}"
3865        );
3866    }
3867
3868    #[test]
3869    fn test_parse_oauth_with_overrides() {
3870        let toml = r#"
3871        [proxy]
3872        name = "oauth-proxy"
3873        [proxy.listen]
3874
3875        [[backends]]
3876        name = "echo"
3877        transport = "stdio"
3878        command = "echo"
3879
3880        [auth]
3881        type = "oauth"
3882        issuer = "https://auth.example.com"
3883        audience = "mcp-proxy"
3884        jwks_uri = "https://auth.example.com/custom/jwks"
3885        introspection_endpoint = "https://auth.example.com/custom/introspect"
3886        client_id = "my-client"
3887        client_secret = "my-secret"
3888        token_validation = "both"
3889        required_scopes = ["read", "write"]
3890
3891        [security]
3892        admin_token = "admin-secret"
3893        "#;
3894
3895        let config = ProxyConfig::parse(toml).unwrap();
3896        match &config.auth {
3897            Some(AuthConfig::OAuth {
3898                jwks_uri,
3899                introspection_endpoint,
3900                token_validation,
3901                required_scopes,
3902                ..
3903            }) => {
3904                assert_eq!(
3905                    jwks_uri.as_deref(),
3906                    Some("https://auth.example.com/custom/jwks")
3907                );
3908                assert_eq!(
3909                    introspection_endpoint.as_deref(),
3910                    Some("https://auth.example.com/custom/introspect")
3911                );
3912                assert_eq!(token_validation, &TokenValidationStrategy::Both);
3913                assert_eq!(required_scopes, &["read", "write"]);
3914            }
3915            other => panic!("expected OAuth auth, got: {other:?}"),
3916        }
3917    }
3918
3919    #[test]
3920    fn test_check_env_vars_warns_on_unset() {
3921        let toml = r#"
3922        [proxy]
3923        name = "env-check"
3924        [proxy.listen]
3925
3926        [[backends]]
3927        name = "svc"
3928        transport = "stdio"
3929        command = "echo"
3930        bearer_token = "${TOTALLY_UNSET_VAR_1}"
3931
3932        [backends.env]
3933        API_KEY = "${TOTALLY_UNSET_VAR_2}"
3934        STATIC = "plain-value"
3935
3936        [auth]
3937        type = "bearer"
3938        tokens = ["${TOTALLY_UNSET_VAR_3}", "literal-token"]
3939
3940        [[auth.scoped_tokens]]
3941        token = "${TOTALLY_UNSET_VAR_4}"
3942        allow_tools = ["svc/echo"]
3943        "#;
3944
3945        let config = ProxyConfig::parse(toml).unwrap();
3946        let warnings = config.check_env_vars();
3947
3948        assert_eq!(warnings.len(), 4, "warnings: {warnings:?}");
3949        assert!(warnings[0].contains("TOTALLY_UNSET_VAR_1"));
3950        assert!(warnings[0].contains("bearer_token"));
3951        assert!(warnings[1].contains("TOTALLY_UNSET_VAR_2"));
3952        assert!(warnings[1].contains("env.API_KEY"));
3953        assert!(warnings[2].contains("TOTALLY_UNSET_VAR_3"));
3954        assert!(warnings[2].contains("tokens[0]"));
3955        assert!(warnings[3].contains("TOTALLY_UNSET_VAR_4"));
3956        assert!(warnings[3].contains("scoped_tokens[0]"));
3957    }
3958
3959    #[test]
3960    fn test_check_env_vars_no_warnings_when_set() {
3961        // SAFETY: test runs single-threaded
3962        unsafe { std::env::set_var("MCP_CHECK_TEST_VAR", "value") };
3963
3964        let toml = r#"
3965        [proxy]
3966        name = "env-check"
3967        [proxy.listen]
3968
3969        [[backends]]
3970        name = "svc"
3971        transport = "stdio"
3972        command = "echo"
3973        bearer_token = "${MCP_CHECK_TEST_VAR}"
3974        "#;
3975
3976        let config = ProxyConfig::parse(toml).unwrap();
3977        let warnings = config.check_env_vars();
3978        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3979
3980        // SAFETY: same as above
3981        unsafe { std::env::remove_var("MCP_CHECK_TEST_VAR") };
3982    }
3983
3984    #[test]
3985    fn test_check_env_vars_no_warnings_for_literals() {
3986        let toml = r#"
3987        [proxy]
3988        name = "env-check"
3989        [proxy.listen]
3990
3991        [[backends]]
3992        name = "svc"
3993        transport = "stdio"
3994        command = "echo"
3995        bearer_token = "literal-token"
3996        "#;
3997
3998        let config = ProxyConfig::parse(toml).unwrap();
3999        let warnings = config.check_env_vars();
4000        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
4001    }
4002
4003    #[test]
4004    fn test_check_env_vars_oauth_client_secret() {
4005        let toml = r#"
4006        [proxy]
4007        name = "oauth-check"
4008        [proxy.listen]
4009
4010        [[backends]]
4011        name = "svc"
4012        transport = "http"
4013        url = "http://localhost:3000"
4014
4015        [auth]
4016        type = "oauth"
4017        issuer = "https://auth.example.com"
4018        audience = "mcp-proxy"
4019        client_id = "my-client"
4020        client_secret = "${TOTALLY_UNSET_OAUTH_SECRET}"
4021        token_validation = "introspection"
4022
4023        [security]
4024        admin_token = "admin-secret"
4025        "#;
4026
4027        let config = ProxyConfig::parse(toml).unwrap();
4028        let warnings = config.check_env_vars();
4029        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
4030        assert!(warnings[0].contains("TOTALLY_UNSET_OAUTH_SECRET"));
4031        assert!(warnings[0].contains("client_secret"));
4032    }
4033
4034    #[cfg(feature = "yaml")]
4035    #[test]
4036    fn test_parse_yaml_config() {
4037        let yaml = r#"
4038proxy:
4039  name: yaml-proxy
4040  listen:
4041    host: "127.0.0.1"
4042    port: 8080
4043backends:
4044  - name: echo
4045    transport: stdio
4046    command: echo
4047"#;
4048        let config = ProxyConfig::parse_yaml(yaml).unwrap();
4049        assert_eq!(config.proxy.name, "yaml-proxy");
4050        assert_eq!(config.backends.len(), 1);
4051        assert_eq!(config.backends[0].name, "echo");
4052    }
4053
4054    #[cfg(feature = "yaml")]
4055    #[test]
4056    fn test_parse_yaml_with_auth() {
4057        let yaml = r#"
4058proxy:
4059  name: auth-proxy
4060  listen:
4061    host: "127.0.0.1"
4062    port: 9090
4063backends:
4064  - name: api
4065    transport: stdio
4066    command: echo
4067auth:
4068  type: bearer
4069  tokens:
4070    - token-1
4071    - token-2
4072"#;
4073        let config = ProxyConfig::parse_yaml(yaml).unwrap();
4074        match &config.auth {
4075            Some(AuthConfig::Bearer { tokens, .. }) => {
4076                assert_eq!(tokens, &["token-1", "token-2"]);
4077            }
4078            other => panic!("expected Bearer auth, got: {other:?}"),
4079        }
4080    }
4081
4082    #[cfg(feature = "yaml")]
4083    #[test]
4084    fn test_parse_yaml_with_middleware() {
4085        let yaml = r#"
4086proxy:
4087  name: mw-proxy
4088  listen:
4089    host: "127.0.0.1"
4090    port: 8080
4091backends:
4092  - name: api
4093    transport: stdio
4094    command: echo
4095    timeout:
4096      seconds: 30
4097    rate_limit:
4098      requests: 100
4099      period_seconds: 1
4100    expose_tools:
4101      - read_file
4102      - list_directory
4103"#;
4104        let config = ProxyConfig::parse_yaml(yaml).unwrap();
4105        assert_eq!(config.backends[0].timeout.as_ref().unwrap().seconds, 30);
4106        assert_eq!(
4107            config.backends[0].rate_limit.as_ref().unwrap().requests,
4108            100
4109        );
4110        assert_eq!(
4111            config.backends[0].expose_tools,
4112            vec!["read_file", "list_directory"]
4113        );
4114    }
4115
4116    #[cfg(feature = "yaml")]
4117    #[test]
4118    fn yaml_round_trip_preserves_auth_headers_and_middleware() {
4119        let original = ProxyConfig::parse_yaml(
4120            r#"
4121proxy:
4122  name: yaml-round-trip
4123  listen: {}
4124auth:
4125  type: bearer
4126  tokens: [test-token]
4127backends:
4128  - name: api
4129    transport: http
4130    url: https://example.com/mcp
4131    headers:
4132      X-API-Key: "${API_KEY}"
4133      Authorization: "ApiKey literal"
4134    timeout:
4135      seconds: 90
4136    rate_limit:
4137      requests: 5
4138      period_seconds: 2
4139"#,
4140        )
4141        .unwrap();
4142        let serialized = yaml_serde::to_string(&original).unwrap();
4143        let restored = ProxyConfig::parse_yaml(&serialized).unwrap();
4144        assert_eq!(
4145            serde_json::to_value(&original).unwrap(),
4146            serde_json::to_value(&restored).unwrap()
4147        );
4148        assert_eq!(restored.backends[0].headers["X-API-Key"], "${API_KEY}");
4149    }
4150
4151    #[cfg(feature = "yaml")]
4152    #[test]
4153    fn yaml_file_extensions_load_through_the_config_loader() {
4154        let dir = tempfile::tempdir().unwrap();
4155        let content = "proxy: {name: yaml-file, listen: {}}\nbackends:\n  - {name: api, transport: http, url: 'http://localhost:8080'}\n";
4156        for extension in ["yaml", "yml"] {
4157            let path = dir.path().join(format!("proxy.{extension}"));
4158            std::fs::write(&path, content).unwrap();
4159            let config = ProxyConfig::load(&path).unwrap();
4160            assert_eq!(config.proxy.name, "yaml-file");
4161            assert_eq!(config.source_path.as_ref(), Some(&path));
4162            assert_eq!(
4163                config.backends[0].url.as_deref(),
4164                Some("http://localhost:8080")
4165            );
4166        }
4167    }
4168
4169    #[cfg(feature = "yaml")]
4170    #[test]
4171    fn yaml_rejects_malformed_duplicate_and_unknown_backend_fields() {
4172        let malformed = ProxyConfig::parse_yaml("proxy: [unterminated").unwrap_err();
4173        assert!(format!("{malformed:#}").contains("parsing YAML config"));
4174        let prefix = "proxy: {name: invalid-yaml, listen: {}}\nbackends:\n  - name: api\n    transport: http\n    url: 'http://localhost:8080'\n";
4175        for (suffix, expected) in [
4176            ("    name: duplicate\n", "duplicate field"),
4177            ("    typo: value\n", "unknown field"),
4178        ] {
4179            let error = ProxyConfig::parse_yaml(&format!("{prefix}{suffix}")).unwrap_err();
4180            assert!(format!("{error:#}").contains(expected), "{error:#}");
4181        }
4182    }
4183
4184    #[test]
4185    fn test_from_mcp_json() {
4186        let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json");
4187        let project_dir = dir.join("my-project");
4188        std::fs::create_dir_all(&project_dir).unwrap();
4189
4190        let mcp_json_path = project_dir.join(".mcp.json");
4191        std::fs::write(
4192            &mcp_json_path,
4193            r#"{
4194                "mcpServers": {
4195                    "github": {
4196                        "command": "npx",
4197                        "args": ["-y", "@modelcontextprotocol/server-github"]
4198                    },
4199                    "api": {
4200                        "url": "http://localhost:9000"
4201                    }
4202                }
4203            }"#,
4204        )
4205        .unwrap();
4206
4207        let config = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap();
4208
4209        // Name derived from parent directory
4210        assert_eq!(config.proxy.name, "my-project");
4211        // Sensible defaults
4212        assert_eq!(config.proxy.listen.host, "127.0.0.1");
4213        assert_eq!(config.proxy.listen.port, 8080);
4214        assert_eq!(config.proxy.version, "0.1.0");
4215        assert_eq!(config.proxy.separator, "/");
4216        // No auth or middleware
4217        assert!(config.auth.is_none());
4218        assert!(config.composite_tools.is_empty());
4219        // Backends imported
4220        assert_eq!(config.backends.len(), 2);
4221        assert_eq!(config.backends[0].name, "api");
4222        assert_eq!(config.backends[1].name, "github");
4223
4224        std::fs::remove_dir_all(&dir).unwrap();
4225    }
4226
4227    #[test]
4228    fn test_from_mcp_json_empty_rejects() {
4229        let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json_empty");
4230        std::fs::create_dir_all(&dir).unwrap();
4231
4232        let mcp_json_path = dir.join(".mcp.json");
4233        std::fs::write(&mcp_json_path, r#"{ "mcpServers": {} }"#).unwrap();
4234
4235        let err = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap_err();
4236        assert!(
4237            err.to_string().contains("at least one backend"),
4238            "unexpected error: {err}"
4239        );
4240
4241        std::fs::remove_dir_all(&dir).unwrap();
4242    }
4243
4244    #[test]
4245    fn test_priority_defaults_to_zero() {
4246        let toml = r#"
4247        [proxy]
4248        name = "test"
4249        [proxy.listen]
4250
4251        [[backends]]
4252        name = "api"
4253        transport = "stdio"
4254        command = "echo"
4255        "#;
4256
4257        let config = ProxyConfig::parse(toml).unwrap();
4258        assert_eq!(config.backends[0].priority, 0);
4259    }
4260
4261    #[test]
4262    fn test_priority_parsed_from_config() {
4263        let toml = r#"
4264        [proxy]
4265        name = "test"
4266        [proxy.listen]
4267
4268        [[backends]]
4269        name = "api"
4270        transport = "stdio"
4271        command = "echo"
4272
4273        [[backends]]
4274        name = "api-backup-1"
4275        transport = "stdio"
4276        command = "echo"
4277        failover_for = "api"
4278        priority = 10
4279
4280        [[backends]]
4281        name = "api-backup-2"
4282        transport = "stdio"
4283        command = "echo"
4284        failover_for = "api"
4285        priority = 5
4286        "#;
4287
4288        let config = ProxyConfig::parse(toml).unwrap();
4289        assert_eq!(config.backends[0].priority, 0);
4290        assert_eq!(config.backends[1].priority, 10);
4291        assert_eq!(config.backends[2].priority, 5);
4292    }
4293}