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