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            }
1638        }
1639
1640        // Validate failover_for references
1641        for backend in &self.backends {
1642            if let Some(ref primary) = backend.failover_for {
1643                if !backend_names.contains(primary.as_str()) {
1644                    anyhow::bail!(
1645                        "backend '{}': failover_for references unknown backend '{}'",
1646                        backend.name,
1647                        primary
1648                    );
1649                }
1650                if primary == &backend.name {
1651                    anyhow::bail!(
1652                        "backend '{}': failover_for cannot reference itself",
1653                        backend.name
1654                    );
1655                }
1656            }
1657        }
1658
1659        // Validate composite tools
1660        {
1661            let mut composite_names = HashSet::new();
1662            for ct in &self.composite_tools {
1663                if ct.name.is_empty() {
1664                    anyhow::bail!("composite_tools: name must not be empty");
1665                }
1666                if ct.tools.is_empty() {
1667                    anyhow::bail!(
1668                        "composite_tools '{}': must reference at least one tool",
1669                        ct.name
1670                    );
1671                }
1672                if !composite_names.insert(&ct.name) {
1673                    anyhow::bail!("duplicate composite_tools name '{}'", ct.name);
1674                }
1675            }
1676        }
1677
1678        // Validate canary_of references
1679        for backend in &self.backends {
1680            if let Some(ref primary) = backend.canary_of {
1681                if !backend_names.contains(primary.as_str()) {
1682                    anyhow::bail!(
1683                        "backend '{}': canary_of references unknown backend '{}'",
1684                        backend.name,
1685                        primary
1686                    );
1687                }
1688                if primary == &backend.name {
1689                    anyhow::bail!(
1690                        "backend '{}': canary_of cannot reference itself",
1691                        backend.name
1692                    );
1693                }
1694                if backend.weight == 0 {
1695                    anyhow::bail!("backend '{}': weight must be > 0", backend.name);
1696                }
1697            }
1698        }
1699
1700        // Validate tool_exposure = "search" requires the discovery feature
1701        #[cfg(not(feature = "discovery"))]
1702        if self.proxy.tool_exposure == ToolExposure::Search {
1703            anyhow::bail!(
1704                "tool_exposure = \"search\" requires the 'discovery' feature. \
1705                 Rebuild with: cargo install mcp-proxy --features discovery"
1706            );
1707        }
1708
1709        // Validate param_overrides
1710        for backend in &self.backends {
1711            let mut seen_tools = HashSet::new();
1712            for po in &backend.param_overrides {
1713                if po.tool.is_empty() {
1714                    anyhow::bail!(
1715                        "backend '{}': param_overrides.tool must not be empty",
1716                        backend.name
1717                    );
1718                }
1719                if !seen_tools.insert(&po.tool) {
1720                    anyhow::bail!(
1721                        "backend '{}': duplicate param_overrides for tool '{}'",
1722                        backend.name,
1723                        po.tool
1724                    );
1725                }
1726                // Hidden params that have no default are a warning-level concern,
1727                // but renamed params that conflict with hide are an error.
1728                for hidden in &po.hide {
1729                    if po.rename.contains_key(hidden) {
1730                        anyhow::bail!(
1731                            "backend '{}': param_overrides for tool '{}': \
1732                             parameter '{}' cannot be both hidden and renamed",
1733                            backend.name,
1734                            po.tool,
1735                            hidden
1736                        );
1737                    }
1738                }
1739                // Check for rename target conflicts (two originals mapping to same name)
1740                let mut rename_targets = HashSet::new();
1741                for target in po.rename.values() {
1742                    if !rename_targets.insert(target) {
1743                        anyhow::bail!(
1744                            "backend '{}': param_overrides for tool '{}': \
1745                             duplicate rename target '{}'",
1746                            backend.name,
1747                            po.tool,
1748                            target
1749                        );
1750                    }
1751                }
1752            }
1753        }
1754
1755        Ok(())
1756    }
1757
1758    /// Resolve environment variable references in config values.
1759    /// Replaces `${VAR_NAME}` with the value of the environment variable.
1760    pub fn resolve_env_vars(&mut self) {
1761        for backend in &mut self.backends {
1762            for value in backend.env.values_mut() {
1763                if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1764                    && let Ok(env_val) = std::env::var(var_name)
1765                {
1766                    *value = env_val;
1767                }
1768            }
1769            if let Some(ref mut token) = backend.bearer_token
1770                && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1771                && let Ok(env_val) = std::env::var(var_name)
1772            {
1773                *token = env_val;
1774            }
1775        }
1776
1777        // Resolve env vars in auth config
1778        if let Some(AuthConfig::Bearer {
1779            tokens,
1780            scoped_tokens,
1781        }) = &mut self.auth
1782        {
1783            for token in tokens.iter_mut() {
1784                if let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1785                    && let Ok(env_val) = std::env::var(var_name)
1786                {
1787                    *token = env_val;
1788                }
1789            }
1790            for st in scoped_tokens.iter_mut() {
1791                if let Some(var_name) = st
1792                    .token
1793                    .strip_prefix("${")
1794                    .and_then(|s| s.strip_suffix('}'))
1795                    && let Ok(env_val) = std::env::var(var_name)
1796                {
1797                    st.token = env_val;
1798                }
1799            }
1800        }
1801
1802        // Resolve env vars in admin_token
1803        if let Some(ref mut token) = self.security.admin_token
1804            && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1805            && let Ok(env_val) = std::env::var(var_name)
1806        {
1807            *token = env_val;
1808        }
1809
1810        // Resolve env vars in OAuth config
1811        if let Some(AuthConfig::OAuth { client_secret, .. }) = &mut self.auth
1812            && let Some(secret) = client_secret
1813            && let Some(var_name) = secret.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1814            && let Ok(env_val) = std::env::var(var_name)
1815        {
1816            *secret = env_val;
1817        }
1818    }
1819
1820    /// Check for `${VAR}` references where the environment variable is not set.
1821    ///
1822    /// Returns a list of human-readable warning strings. This method does not
1823    /// modify the config or fail -- it only reports potential issues.
1824    ///
1825    /// # Example
1826    ///
1827    /// ```
1828    /// use mcp_proxy::config::ProxyConfig;
1829    ///
1830    /// let toml = r#"
1831    /// [proxy]
1832    /// name = "test"
1833    /// [proxy.listen]
1834    ///
1835    /// [[backends]]
1836    /// name = "svc"
1837    /// transport = "stdio"
1838    /// command = "echo"
1839    /// bearer_token = "${UNSET_VAR}"
1840    /// "#;
1841    ///
1842    /// let config = ProxyConfig::parse(toml).unwrap();
1843    /// let warnings = config.check_env_vars();
1844    /// assert!(!warnings.is_empty());
1845    /// ```
1846    pub fn check_env_vars(&self) -> Vec<String> {
1847        fn is_unset_env_ref(value: &str) -> Option<&str> {
1848            let var_name = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))?;
1849            if std::env::var(var_name).is_err() {
1850                Some(var_name)
1851            } else {
1852                None
1853            }
1854        }
1855
1856        let mut warnings = Vec::new();
1857
1858        for backend in &self.backends {
1859            // backend.bearer_token
1860            if let Some(ref token) = backend.bearer_token
1861                && let Some(var) = is_unset_env_ref(token)
1862            {
1863                warnings.push(format!(
1864                    "backend '{}': bearer_token references unset env var '{}'",
1865                    backend.name, var
1866                ));
1867            }
1868            // backend.env values
1869            for (key, value) in &backend.env {
1870                if let Some(var) = is_unset_env_ref(value) {
1871                    warnings.push(format!(
1872                        "backend '{}': env.{} references unset env var '{}'",
1873                        backend.name, key, var
1874                    ));
1875                }
1876            }
1877        }
1878
1879        match &self.auth {
1880            Some(AuthConfig::Bearer {
1881                tokens,
1882                scoped_tokens,
1883            }) => {
1884                for (i, token) in tokens.iter().enumerate() {
1885                    if let Some(var) = is_unset_env_ref(token) {
1886                        warnings.push(format!(
1887                            "auth.bearer: tokens[{}] references unset env var '{}'",
1888                            i, var
1889                        ));
1890                    }
1891                }
1892                for (i, st) in scoped_tokens.iter().enumerate() {
1893                    if let Some(var) = is_unset_env_ref(&st.token) {
1894                        warnings.push(format!(
1895                            "auth.bearer: scoped_tokens[{}] references unset env var '{}'",
1896                            i, var
1897                        ));
1898                    }
1899                }
1900            }
1901            Some(AuthConfig::OAuth {
1902                client_secret: Some(secret),
1903                ..
1904            }) => {
1905                if let Some(var) = is_unset_env_ref(secret) {
1906                    warnings.push(format!(
1907                        "auth.oauth: client_secret references unset env var '{}'",
1908                        var
1909                    ));
1910                }
1911            }
1912            _ => {}
1913        }
1914
1915        warnings
1916    }
1917}
1918
1919#[cfg(test)]
1920mod tests {
1921    use super::*;
1922
1923    fn minimal_config() -> &'static str {
1924        r#"
1925        [proxy]
1926        name = "test"
1927        [proxy.listen]
1928
1929        [[backends]]
1930        name = "echo"
1931        transport = "stdio"
1932        command = "echo"
1933        "#
1934    }
1935
1936    #[test]
1937    fn test_parse_minimal_config() {
1938        let config = ProxyConfig::parse(minimal_config()).unwrap();
1939        assert_eq!(config.proxy.name, "test");
1940        assert_eq!(config.proxy.version, "0.1.0"); // default
1941        assert_eq!(config.proxy.separator, "/"); // default
1942        assert_eq!(config.proxy.listen.host, "127.0.0.1"); // default
1943        assert_eq!(config.proxy.listen.port, 8080); // default
1944        assert_eq!(config.proxy.shutdown_timeout_seconds, 30); // default
1945        assert!(!config.proxy.hot_reload); // default false
1946        assert_eq!(config.backends.len(), 1);
1947        assert_eq!(config.backends[0].name, "echo");
1948        assert!(config.auth.is_none());
1949        assert!(!config.observability.audit);
1950        assert!(!config.observability.metrics.enabled);
1951    }
1952
1953    #[test]
1954    fn test_parse_full_config() {
1955        let toml = r#"
1956        [proxy]
1957        name = "full-gw"
1958        version = "2.0.0"
1959        separator = "."
1960        shutdown_timeout_seconds = 60
1961        hot_reload = true
1962        instructions = "A test proxy"
1963        [proxy.listen]
1964        host = "0.0.0.0"
1965        port = 9090
1966
1967        [[backends]]
1968        name = "files"
1969        transport = "stdio"
1970        command = "file-server"
1971        args = ["--root", "/tmp"]
1972        expose_tools = ["read_file"]
1973
1974        [backends.env]
1975        LOG_LEVEL = "debug"
1976
1977        [backends.timeout]
1978        seconds = 30
1979
1980        [backends.concurrency]
1981        max_concurrent = 5
1982
1983        [backends.rate_limit]
1984        requests = 100
1985        period_seconds = 10
1986
1987        [backends.circuit_breaker]
1988        failure_rate_threshold = 0.5
1989        minimum_calls = 10
1990        wait_duration_seconds = 60
1991        permitted_calls_in_half_open = 2
1992
1993        [backends.cache]
1994        resource_ttl_seconds = 300
1995        tool_ttl_seconds = 60
1996        max_entries = 500
1997
1998        [[backends.aliases]]
1999        from = "read_file"
2000        to = "read"
2001
2002        [[backends]]
2003        name = "remote"
2004        transport = "http"
2005        url = "http://localhost:3000"
2006
2007        [observability]
2008        audit = true
2009        log_level = "debug"
2010        json_logs = true
2011
2012        [observability.metrics]
2013        enabled = true
2014
2015        [observability.tracing]
2016        enabled = true
2017        endpoint = "http://jaeger:4317"
2018        service_name = "test-gw"
2019
2020        [performance]
2021        coalesce_requests = true
2022
2023        [security]
2024        max_argument_size = 1048576
2025        "#;
2026
2027        let config = ProxyConfig::parse(toml).unwrap();
2028        assert_eq!(config.proxy.name, "full-gw");
2029        assert_eq!(config.proxy.version, "2.0.0");
2030        assert_eq!(config.proxy.separator, ".");
2031        assert_eq!(config.proxy.shutdown_timeout_seconds, 60);
2032        assert!(config.proxy.hot_reload);
2033        assert_eq!(config.proxy.instructions.as_deref(), Some("A test proxy"));
2034        assert_eq!(config.proxy.listen.host, "0.0.0.0");
2035        assert_eq!(config.proxy.listen.port, 9090);
2036
2037        assert_eq!(config.backends.len(), 2);
2038
2039        let files = &config.backends[0];
2040        assert_eq!(files.command.as_deref(), Some("file-server"));
2041        assert_eq!(files.args, vec!["--root", "/tmp"]);
2042        assert_eq!(files.expose_tools, vec!["read_file"]);
2043        assert_eq!(files.env.get("LOG_LEVEL").unwrap(), "debug");
2044        assert_eq!(files.timeout.as_ref().unwrap().seconds, 30);
2045        assert_eq!(files.concurrency.as_ref().unwrap().max_concurrent, 5);
2046        assert_eq!(files.rate_limit.as_ref().unwrap().requests, 100);
2047        assert_eq!(files.cache.as_ref().unwrap().resource_ttl_seconds, 300);
2048        assert_eq!(files.cache.as_ref().unwrap().tool_ttl_seconds, 60);
2049        assert_eq!(files.cache.as_ref().unwrap().max_entries, 500);
2050        assert_eq!(files.aliases.len(), 1);
2051        assert_eq!(files.aliases[0].from, "read_file");
2052        assert_eq!(files.aliases[0].to, "read");
2053
2054        let cb = files.circuit_breaker.as_ref().unwrap();
2055        assert_eq!(cb.failure_rate_threshold, 0.5);
2056        assert_eq!(cb.minimum_calls, 10);
2057        assert_eq!(cb.wait_duration_seconds, 60);
2058        assert_eq!(cb.permitted_calls_in_half_open, 2);
2059
2060        let remote = &config.backends[1];
2061        assert_eq!(remote.url.as_deref(), Some("http://localhost:3000"));
2062
2063        assert!(config.observability.audit);
2064        assert_eq!(config.observability.log_level, "debug");
2065        assert!(config.observability.json_logs);
2066        assert!(config.observability.metrics.enabled);
2067        assert!(config.observability.tracing.enabled);
2068        assert_eq!(config.observability.tracing.endpoint, "http://jaeger:4317");
2069
2070        assert!(config.performance.coalesce_requests);
2071        assert_eq!(config.security.max_argument_size, Some(1048576));
2072    }
2073
2074    #[test]
2075    fn test_parse_bearer_auth() {
2076        let toml = r#"
2077        [proxy]
2078        name = "auth-gw"
2079        [proxy.listen]
2080
2081        [[backends]]
2082        name = "echo"
2083        transport = "stdio"
2084        command = "echo"
2085
2086        [auth]
2087        type = "bearer"
2088        tokens = ["token-1", "token-2"]
2089        "#;
2090
2091        let config = ProxyConfig::parse(toml).unwrap();
2092        match &config.auth {
2093            Some(AuthConfig::Bearer { tokens, .. }) => {
2094                assert_eq!(tokens, &["token-1", "token-2"]);
2095            }
2096            other => panic!("expected Bearer auth, got: {:?}", other),
2097        }
2098    }
2099
2100    #[test]
2101    fn test_parse_jwt_auth_with_rbac() {
2102        let toml = r#"
2103        [proxy]
2104        name = "jwt-gw"
2105        [proxy.listen]
2106
2107        [[backends]]
2108        name = "echo"
2109        transport = "stdio"
2110        command = "echo"
2111
2112        [auth]
2113        type = "jwt"
2114        issuer = "https://auth.example.com"
2115        audience = "mcp-proxy"
2116        jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2117
2118        [[auth.roles]]
2119        name = "reader"
2120        allow_tools = ["echo/read"]
2121
2122        [[auth.roles]]
2123        name = "admin"
2124
2125        [auth.role_mapping]
2126        claim = "scope"
2127        mapping = { "mcp:read" = "reader", "mcp:admin" = "admin" }
2128
2129        [security]
2130        admin_token = "admin-secret"
2131        "#;
2132
2133        let config = ProxyConfig::parse(toml).unwrap();
2134        match &config.auth {
2135            Some(AuthConfig::Jwt {
2136                issuer,
2137                audience,
2138                jwks_uri,
2139                roles,
2140                role_mapping,
2141            }) => {
2142                assert_eq!(issuer, "https://auth.example.com");
2143                assert_eq!(audience, "mcp-proxy");
2144                assert_eq!(jwks_uri, "https://auth.example.com/.well-known/jwks.json");
2145                assert_eq!(roles.len(), 2);
2146                assert_eq!(roles[0].name, "reader");
2147                assert_eq!(roles[0].allow_tools, vec!["echo/read"]);
2148                let mapping = role_mapping.as_ref().unwrap();
2149                assert_eq!(mapping.claim, "scope");
2150                assert_eq!(mapping.mapping.get("mcp:read").unwrap(), "reader");
2151            }
2152            other => panic!("expected Jwt auth, got: {:?}", other),
2153        }
2154    }
2155
2156    // ========================================================================
2157    // Validation errors
2158    // ========================================================================
2159
2160    #[test]
2161    fn test_reject_no_backends() {
2162        let toml = r#"
2163        [proxy]
2164        name = "empty"
2165        [proxy.listen]
2166        "#;
2167
2168        let err = ProxyConfig::parse(toml).unwrap_err();
2169        assert!(
2170            format!("{err}").contains("at least one backend"),
2171            "unexpected error: {err}"
2172        );
2173    }
2174
2175    #[test]
2176    fn test_reject_stdio_without_command() {
2177        let toml = r#"
2178        [proxy]
2179        name = "bad"
2180        [proxy.listen]
2181
2182        [[backends]]
2183        name = "broken"
2184        transport = "stdio"
2185        "#;
2186
2187        let err = ProxyConfig::parse(toml).unwrap_err();
2188        assert!(
2189            format!("{err}").contains("stdio transport requires 'command'"),
2190            "unexpected error: {err}"
2191        );
2192    }
2193
2194    #[test]
2195    fn test_reject_http_without_url() {
2196        let toml = r#"
2197        [proxy]
2198        name = "bad"
2199        [proxy.listen]
2200
2201        [[backends]]
2202        name = "broken"
2203        transport = "http"
2204        "#;
2205
2206        let err = ProxyConfig::parse(toml).unwrap_err();
2207        assert!(
2208            format!("{err}").contains("http transport requires 'url'"),
2209            "unexpected error: {err}"
2210        );
2211    }
2212
2213    #[test]
2214    fn test_reject_invalid_circuit_breaker_threshold() {
2215        let toml = r#"
2216        [proxy]
2217        name = "bad"
2218        [proxy.listen]
2219
2220        [[backends]]
2221        name = "svc"
2222        transport = "stdio"
2223        command = "echo"
2224
2225        [backends.circuit_breaker]
2226        failure_rate_threshold = 1.5
2227        "#;
2228
2229        let err = ProxyConfig::parse(toml).unwrap_err();
2230        assert!(
2231            format!("{err}").contains("failure_rate_threshold must be in (0.0, 1.0]"),
2232            "unexpected error: {err}"
2233        );
2234    }
2235
2236    #[test]
2237    fn test_reject_zero_rate_limit() {
2238        let toml = r#"
2239        [proxy]
2240        name = "bad"
2241        [proxy.listen]
2242
2243        [[backends]]
2244        name = "svc"
2245        transport = "stdio"
2246        command = "echo"
2247
2248        [backends.rate_limit]
2249        requests = 0
2250        "#;
2251
2252        let err = ProxyConfig::parse(toml).unwrap_err();
2253        assert!(
2254            format!("{err}").contains("rate_limit.requests must be > 0"),
2255            "unexpected error: {err}"
2256        );
2257    }
2258
2259    #[test]
2260    fn test_reject_zero_concurrency() {
2261        let toml = r#"
2262        [proxy]
2263        name = "bad"
2264        [proxy.listen]
2265
2266        [[backends]]
2267        name = "svc"
2268        transport = "stdio"
2269        command = "echo"
2270
2271        [backends.concurrency]
2272        max_concurrent = 0
2273        "#;
2274
2275        let err = ProxyConfig::parse(toml).unwrap_err();
2276        assert!(
2277            format!("{err}").contains("concurrency.max_concurrent must be > 0"),
2278            "unexpected error: {err}"
2279        );
2280    }
2281
2282    #[test]
2283    fn test_reject_expose_and_hide_tools() {
2284        let toml = r#"
2285        [proxy]
2286        name = "bad"
2287        [proxy.listen]
2288
2289        [[backends]]
2290        name = "svc"
2291        transport = "stdio"
2292        command = "echo"
2293        expose_tools = ["read"]
2294        hide_tools = ["write"]
2295        "#;
2296
2297        let err = ProxyConfig::parse(toml).unwrap_err();
2298        assert!(
2299            format!("{err}").contains("cannot specify both expose_tools and hide_tools"),
2300            "unexpected error: {err}"
2301        );
2302    }
2303
2304    #[test]
2305    fn test_reject_expose_and_hide_resources() {
2306        let toml = r#"
2307        [proxy]
2308        name = "bad"
2309        [proxy.listen]
2310
2311        [[backends]]
2312        name = "svc"
2313        transport = "stdio"
2314        command = "echo"
2315        expose_resources = ["file:///a"]
2316        hide_resources = ["file:///b"]
2317        "#;
2318
2319        let err = ProxyConfig::parse(toml).unwrap_err();
2320        assert!(
2321            format!("{err}").contains("cannot specify both expose_resources and hide_resources"),
2322            "unexpected error: {err}"
2323        );
2324    }
2325
2326    #[test]
2327    fn test_reject_expose_and_hide_prompts() {
2328        let toml = r#"
2329        [proxy]
2330        name = "bad"
2331        [proxy.listen]
2332
2333        [[backends]]
2334        name = "svc"
2335        transport = "stdio"
2336        command = "echo"
2337        expose_prompts = ["help"]
2338        hide_prompts = ["admin"]
2339        "#;
2340
2341        let err = ProxyConfig::parse(toml).unwrap_err();
2342        assert!(
2343            format!("{err}").contains("cannot specify both expose_prompts and hide_prompts"),
2344            "unexpected error: {err}"
2345        );
2346    }
2347
2348    // ========================================================================
2349    // Env var resolution
2350    // ========================================================================
2351
2352    #[test]
2353    fn test_resolve_env_vars() {
2354        // SAFETY: test runs single-threaded, no other threads reading this var
2355        unsafe { std::env::set_var("MCP_GW_TEST_TOKEN", "secret-123") };
2356
2357        let toml = r#"
2358        [proxy]
2359        name = "env-test"
2360        [proxy.listen]
2361
2362        [[backends]]
2363        name = "svc"
2364        transport = "stdio"
2365        command = "echo"
2366
2367        [backends.env]
2368        API_TOKEN = "${MCP_GW_TEST_TOKEN}"
2369        STATIC_VAL = "unchanged"
2370        "#;
2371
2372        let mut config = ProxyConfig::parse(toml).unwrap();
2373        config.resolve_env_vars();
2374
2375        assert_eq!(
2376            config.backends[0].env.get("API_TOKEN").unwrap(),
2377            "secret-123"
2378        );
2379        assert_eq!(
2380            config.backends[0].env.get("STATIC_VAL").unwrap(),
2381            "unchanged"
2382        );
2383
2384        // SAFETY: same as above
2385        unsafe { std::env::remove_var("MCP_GW_TEST_TOKEN") };
2386    }
2387
2388    #[test]
2389    fn test_parse_bearer_token_and_forward_auth() {
2390        let toml = r#"
2391        [proxy]
2392        name = "token-gw"
2393        [proxy.listen]
2394
2395        [[backends]]
2396        name = "github"
2397        transport = "http"
2398        url = "http://localhost:3000"
2399        bearer_token = "ghp_abc123"
2400        forward_auth = true
2401
2402        [[backends]]
2403        name = "db"
2404        transport = "http"
2405        url = "http://localhost:5432"
2406        "#;
2407
2408        let config = ProxyConfig::parse(toml).unwrap();
2409        assert_eq!(
2410            config.backends[0].bearer_token.as_deref(),
2411            Some("ghp_abc123")
2412        );
2413        assert!(config.backends[0].forward_auth);
2414        assert!(config.backends[1].bearer_token.is_none());
2415        assert!(!config.backends[1].forward_auth);
2416    }
2417
2418    #[test]
2419    fn test_resolve_bearer_token_env_var() {
2420        unsafe { std::env::set_var("MCP_GW_TEST_BEARER", "resolved-token") };
2421
2422        let toml = r#"
2423        [proxy]
2424        name = "env-token"
2425        [proxy.listen]
2426
2427        [[backends]]
2428        name = "api"
2429        transport = "http"
2430        url = "http://localhost:3000"
2431        bearer_token = "${MCP_GW_TEST_BEARER}"
2432        "#;
2433
2434        let mut config = ProxyConfig::parse(toml).unwrap();
2435        config.resolve_env_vars();
2436
2437        assert_eq!(
2438            config.backends[0].bearer_token.as_deref(),
2439            Some("resolved-token")
2440        );
2441
2442        unsafe { std::env::remove_var("MCP_GW_TEST_BEARER") };
2443    }
2444
2445    #[test]
2446    fn test_parse_outlier_detection() {
2447        let toml = r#"
2448        [proxy]
2449        name = "od-gw"
2450        [proxy.listen]
2451
2452        [[backends]]
2453        name = "flaky"
2454        transport = "http"
2455        url = "http://localhost:8080"
2456
2457        [backends.outlier_detection]
2458        consecutive_errors = 3
2459        interval_seconds = 5
2460        base_ejection_seconds = 60
2461        max_ejection_percent = 25
2462        "#;
2463
2464        let config = ProxyConfig::parse(toml).unwrap();
2465        let od = config.backends[0]
2466            .outlier_detection
2467            .as_ref()
2468            .expect("should have outlier_detection");
2469        assert_eq!(od.consecutive_errors, 3);
2470        assert_eq!(od.interval_seconds, 5);
2471        assert_eq!(od.base_ejection_seconds, 60);
2472        assert_eq!(od.max_ejection_percent, 25);
2473    }
2474
2475    #[test]
2476    fn test_parse_outlier_detection_defaults() {
2477        let toml = r#"
2478        [proxy]
2479        name = "od-gw"
2480        [proxy.listen]
2481
2482        [[backends]]
2483        name = "flaky"
2484        transport = "http"
2485        url = "http://localhost:8080"
2486
2487        [backends.outlier_detection]
2488        "#;
2489
2490        let config = ProxyConfig::parse(toml).unwrap();
2491        let od = config.backends[0]
2492            .outlier_detection
2493            .as_ref()
2494            .expect("should have outlier_detection");
2495        assert_eq!(od.consecutive_errors, 5);
2496        assert_eq!(od.interval_seconds, 10);
2497        assert_eq!(od.base_ejection_seconds, 30);
2498        assert_eq!(od.max_ejection_percent, 50);
2499    }
2500
2501    #[test]
2502    fn test_parse_mirror_config() {
2503        let toml = r#"
2504        [proxy]
2505        name = "mirror-gw"
2506        [proxy.listen]
2507
2508        [[backends]]
2509        name = "api"
2510        transport = "http"
2511        url = "http://localhost:8080"
2512
2513        [[backends]]
2514        name = "api-v2"
2515        transport = "http"
2516        url = "http://localhost:8081"
2517        mirror_of = "api"
2518        mirror_percent = 10
2519        "#;
2520
2521        let config = ProxyConfig::parse(toml).unwrap();
2522        assert!(config.backends[0].mirror_of.is_none());
2523        assert_eq!(config.backends[1].mirror_of.as_deref(), Some("api"));
2524        assert_eq!(config.backends[1].mirror_percent, 10);
2525    }
2526
2527    #[test]
2528    fn test_mirror_percent_defaults_to_100() {
2529        let toml = r#"
2530        [proxy]
2531        name = "mirror-gw"
2532        [proxy.listen]
2533
2534        [[backends]]
2535        name = "api"
2536        transport = "http"
2537        url = "http://localhost:8080"
2538
2539        [[backends]]
2540        name = "api-v2"
2541        transport = "http"
2542        url = "http://localhost:8081"
2543        mirror_of = "api"
2544        "#;
2545
2546        let config = ProxyConfig::parse(toml).unwrap();
2547        assert_eq!(config.backends[1].mirror_percent, 100);
2548    }
2549
2550    #[test]
2551    fn test_reject_mirror_unknown_backend() {
2552        let toml = r#"
2553        [proxy]
2554        name = "bad"
2555        [proxy.listen]
2556
2557        [[backends]]
2558        name = "api-v2"
2559        transport = "http"
2560        url = "http://localhost:8081"
2561        mirror_of = "nonexistent"
2562        "#;
2563
2564        let err = ProxyConfig::parse(toml).unwrap_err();
2565        assert!(
2566            format!("{err}").contains("mirror_of references unknown backend"),
2567            "unexpected error: {err}"
2568        );
2569    }
2570
2571    #[test]
2572    fn test_reject_mirror_self() {
2573        let toml = r#"
2574        [proxy]
2575        name = "bad"
2576        [proxy.listen]
2577
2578        [[backends]]
2579        name = "api"
2580        transport = "http"
2581        url = "http://localhost:8080"
2582        mirror_of = "api"
2583        "#;
2584
2585        let err = ProxyConfig::parse(toml).unwrap_err();
2586        assert!(
2587            format!("{err}").contains("mirror_of cannot reference itself"),
2588            "unexpected error: {err}"
2589        );
2590    }
2591
2592    #[test]
2593    fn test_parse_hedging_config() {
2594        let toml = r#"
2595        [proxy]
2596        name = "hedge-gw"
2597        [proxy.listen]
2598
2599        [[backends]]
2600        name = "api"
2601        transport = "http"
2602        url = "http://localhost:8080"
2603
2604        [backends.hedging]
2605        delay_ms = 150
2606        max_hedges = 2
2607        "#;
2608
2609        let config = ProxyConfig::parse(toml).unwrap();
2610        let hedge = config.backends[0]
2611            .hedging
2612            .as_ref()
2613            .expect("should have hedging");
2614        assert_eq!(hedge.delay_ms, 150);
2615        assert_eq!(hedge.max_hedges, 2);
2616    }
2617
2618    #[test]
2619    fn test_parse_hedging_defaults() {
2620        let toml = r#"
2621        [proxy]
2622        name = "hedge-gw"
2623        [proxy.listen]
2624
2625        [[backends]]
2626        name = "api"
2627        transport = "http"
2628        url = "http://localhost:8080"
2629
2630        [backends.hedging]
2631        "#;
2632
2633        let config = ProxyConfig::parse(toml).unwrap();
2634        let hedge = config.backends[0]
2635            .hedging
2636            .as_ref()
2637            .expect("should have hedging");
2638        assert_eq!(hedge.delay_ms, 200);
2639        assert_eq!(hedge.max_hedges, 1);
2640    }
2641
2642    // ========================================================================
2643    // Capability filter building
2644    // ========================================================================
2645
2646    #[test]
2647    fn test_build_filter_allowlist() {
2648        let toml = r#"
2649        [proxy]
2650        name = "filter"
2651        [proxy.listen]
2652
2653        [[backends]]
2654        name = "svc"
2655        transport = "stdio"
2656        command = "echo"
2657        expose_tools = ["read", "list"]
2658        "#;
2659
2660        let config = ProxyConfig::parse(toml).unwrap();
2661        let filter = config.backends[0]
2662            .build_filter(&config.proxy.separator)
2663            .unwrap()
2664            .expect("should have filter");
2665        assert_eq!(filter.namespace, "svc/");
2666        assert!(filter.tool_filter.allows("read"));
2667        assert!(filter.tool_filter.allows("list"));
2668        assert!(!filter.tool_filter.allows("delete"));
2669    }
2670
2671    #[test]
2672    fn test_build_filter_denylist() {
2673        let toml = r#"
2674        [proxy]
2675        name = "filter"
2676        [proxy.listen]
2677
2678        [[backends]]
2679        name = "svc"
2680        transport = "stdio"
2681        command = "echo"
2682        hide_tools = ["delete", "write"]
2683        "#;
2684
2685        let config = ProxyConfig::parse(toml).unwrap();
2686        let filter = config.backends[0]
2687            .build_filter(&config.proxy.separator)
2688            .unwrap()
2689            .expect("should have filter");
2690        assert!(filter.tool_filter.allows("read"));
2691        assert!(!filter.tool_filter.allows("delete"));
2692        assert!(!filter.tool_filter.allows("write"));
2693    }
2694
2695    #[test]
2696    fn test_parse_inject_args() {
2697        let toml = r#"
2698        [proxy]
2699        name = "inject-gw"
2700        [proxy.listen]
2701
2702        [[backends]]
2703        name = "db"
2704        transport = "http"
2705        url = "http://localhost:8080"
2706
2707        [backends.default_args]
2708        timeout = 30
2709
2710        [[backends.inject_args]]
2711        tool = "query"
2712        args = { read_only = true, max_rows = 1000 }
2713
2714        [[backends.inject_args]]
2715        tool = "dangerous_op"
2716        args = { dry_run = true }
2717        overwrite = true
2718        "#;
2719
2720        let config = ProxyConfig::parse(toml).unwrap();
2721        let backend = &config.backends[0];
2722
2723        assert_eq!(backend.default_args.len(), 1);
2724        assert_eq!(backend.default_args["timeout"], 30);
2725
2726        assert_eq!(backend.inject_args.len(), 2);
2727        assert_eq!(backend.inject_args[0].tool, "query");
2728        assert_eq!(backend.inject_args[0].args["read_only"], true);
2729        assert_eq!(backend.inject_args[0].args["max_rows"], 1000);
2730        assert!(!backend.inject_args[0].overwrite);
2731
2732        assert_eq!(backend.inject_args[1].tool, "dangerous_op");
2733        assert_eq!(backend.inject_args[1].args["dry_run"], true);
2734        assert!(backend.inject_args[1].overwrite);
2735    }
2736
2737    #[test]
2738    fn test_parse_inject_args_defaults_to_empty() {
2739        let config = ProxyConfig::parse(minimal_config()).unwrap();
2740        assert!(config.backends[0].default_args.is_empty());
2741        assert!(config.backends[0].inject_args.is_empty());
2742    }
2743
2744    #[test]
2745    fn test_build_filter_none_when_no_filtering() {
2746        let config = ProxyConfig::parse(minimal_config()).unwrap();
2747        assert!(
2748            config.backends[0]
2749                .build_filter(&config.proxy.separator)
2750                .unwrap()
2751                .is_none()
2752        );
2753    }
2754
2755    #[test]
2756    fn test_validate_rejects_duplicate_backend_names() {
2757        let toml = r#"
2758        [proxy]
2759        name = "test"
2760        [proxy.listen]
2761
2762        [[backends]]
2763        name = "echo"
2764        transport = "stdio"
2765        command = "echo"
2766
2767        [[backends]]
2768        name = "echo"
2769        transport = "stdio"
2770        command = "cat"
2771        "#;
2772        let err = ProxyConfig::parse(toml).unwrap_err();
2773        assert!(
2774            err.to_string().contains("duplicate backend name"),
2775            "expected duplicate error, got: {}",
2776            err
2777        );
2778    }
2779
2780    #[test]
2781    fn test_validate_global_rate_limit_zero_requests() {
2782        let toml = r#"
2783        [proxy]
2784        name = "test"
2785        [proxy.listen]
2786        [proxy.rate_limit]
2787        requests = 0
2788
2789        [[backends]]
2790        name = "echo"
2791        transport = "stdio"
2792        command = "echo"
2793        "#;
2794        let err = ProxyConfig::parse(toml).unwrap_err();
2795        assert!(err.to_string().contains("requests must be > 0"));
2796    }
2797
2798    #[test]
2799    fn test_validate_jwt_requires_admin_token() {
2800        // JWT auth without security.admin_token must be rejected: the admin
2801        // plane has no token fallback for JWT/OAuth and would be left open.
2802        let toml = r#"
2803        [proxy]
2804        name = "jwt-gw"
2805        [proxy.listen]
2806
2807        [[backends]]
2808        name = "echo"
2809        transport = "stdio"
2810        command = "echo"
2811
2812        [auth]
2813        type = "jwt"
2814        issuer = "https://auth.example.com"
2815        audience = "mcp-proxy"
2816        jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2817        "#;
2818        let err = ProxyConfig::parse(toml).unwrap_err();
2819        assert!(
2820            err.to_string().contains("admin_token"),
2821            "expected admin_token error, got: {err}"
2822        );
2823    }
2824
2825    #[test]
2826    fn test_validate_jwt_with_admin_token_ok() {
2827        // Same config with an explicit admin_token validates successfully.
2828        let toml = r#"
2829        [proxy]
2830        name = "jwt-gw"
2831        [proxy.listen]
2832
2833        [[backends]]
2834        name = "echo"
2835        transport = "stdio"
2836        command = "echo"
2837
2838        [auth]
2839        type = "jwt"
2840        issuer = "https://auth.example.com"
2841        audience = "mcp-proxy"
2842        jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2843
2844        [security]
2845        admin_token = "admin-secret"
2846        "#;
2847        assert!(ProxyConfig::parse(toml).is_ok());
2848    }
2849
2850    #[test]
2851    fn test_validate_oauth_requires_admin_token() {
2852        // OAuth (JWT-validation strategy, so credentials aren't required) without
2853        // admin_token must also be rejected.
2854        let toml = r#"
2855        [proxy]
2856        name = "oauth-gw"
2857        [proxy.listen]
2858
2859        [[backends]]
2860        name = "echo"
2861        transport = "stdio"
2862        command = "echo"
2863
2864        [auth]
2865        type = "oauth"
2866        issuer = "https://auth.example.com"
2867        audience = "mcp-proxy"
2868        "#;
2869        let err = ProxyConfig::parse(toml).unwrap_err();
2870        assert!(
2871            err.to_string().contains("admin_token"),
2872            "expected admin_token error, got: {err}"
2873        );
2874    }
2875
2876    #[test]
2877    fn test_parse_global_rate_limit() {
2878        let toml = r#"
2879        [proxy]
2880        name = "test"
2881        [proxy.listen]
2882        [proxy.rate_limit]
2883        requests = 500
2884        period_seconds = 1
2885
2886        [[backends]]
2887        name = "echo"
2888        transport = "stdio"
2889        command = "echo"
2890        "#;
2891        let config = ProxyConfig::parse(toml).unwrap();
2892        let rl = config.proxy.rate_limit.unwrap();
2893        assert_eq!(rl.requests, 500);
2894        assert_eq!(rl.period_seconds, 1);
2895    }
2896
2897    #[test]
2898    fn test_name_filter_glob_wildcard() {
2899        let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
2900        assert!(filter.allows("read_file"));
2901        assert!(filter.allows("write_file"));
2902        assert!(!filter.allows("query"));
2903        assert!(!filter.allows("file_read"));
2904    }
2905
2906    #[test]
2907    fn test_name_filter_glob_prefix() {
2908        let filter = NameFilter::allow_list(["list_*".to_string()]).unwrap();
2909        assert!(filter.allows("list_files"));
2910        assert!(filter.allows("list_users"));
2911        assert!(!filter.allows("get_files"));
2912    }
2913
2914    #[test]
2915    fn test_name_filter_glob_question_mark() {
2916        let filter = NameFilter::allow_list(["get_?".to_string()]).unwrap();
2917        assert!(filter.allows("get_a"));
2918        assert!(filter.allows("get_1"));
2919        assert!(!filter.allows("get_ab"));
2920        assert!(!filter.allows("get_"));
2921    }
2922
2923    #[test]
2924    fn test_name_filter_glob_deny_list() {
2925        let filter = NameFilter::deny_list(["*_delete*".to_string()]).unwrap();
2926        assert!(filter.allows("read_file"));
2927        assert!(filter.allows("create_issue"));
2928        assert!(!filter.allows("force_delete_all"));
2929        assert!(!filter.allows("soft_delete"));
2930    }
2931
2932    #[test]
2933    fn test_name_filter_glob_exact_match_still_works() {
2934        let filter = NameFilter::allow_list(["read_file".to_string()]).unwrap();
2935        assert!(filter.allows("read_file"));
2936        assert!(!filter.allows("write_file"));
2937    }
2938
2939    #[test]
2940    fn test_name_filter_glob_multiple_patterns() {
2941        let filter = NameFilter::allow_list(["read_*".to_string(), "list_*".to_string()]).unwrap();
2942        assert!(filter.allows("read_file"));
2943        assert!(filter.allows("list_users"));
2944        assert!(!filter.allows("delete_file"));
2945    }
2946
2947    #[test]
2948    fn test_name_filter_regex_allow_list() {
2949        let filter =
2950            NameFilter::allow_list(["re:^list_.*$".to_string(), "re:^get_\\w+$".to_string()])
2951                .unwrap();
2952        assert!(filter.allows("list_files"));
2953        assert!(filter.allows("list_users"));
2954        assert!(filter.allows("get_item"));
2955        assert!(!filter.allows("delete_file"));
2956        assert!(!filter.allows("create_issue"));
2957    }
2958
2959    #[test]
2960    fn test_name_filter_regex_deny_list() {
2961        let filter = NameFilter::deny_list(["re:^delete_".to_string()]).unwrap();
2962        assert!(filter.allows("read_file"));
2963        assert!(filter.allows("list_users"));
2964        assert!(!filter.allows("delete_file"));
2965        assert!(!filter.allows("delete_all"));
2966    }
2967
2968    #[test]
2969    fn test_name_filter_mixed_glob_and_regex() {
2970        let filter =
2971            NameFilter::allow_list(["read_*".to_string(), "re:^list_\\w+$".to_string()]).unwrap();
2972        assert!(filter.allows("read_file"));
2973        assert!(filter.allows("read_dir"));
2974        assert!(filter.allows("list_users"));
2975        assert!(!filter.allows("delete_file"));
2976    }
2977
2978    #[test]
2979    fn test_name_filter_regex_invalid_pattern() {
2980        let result = NameFilter::allow_list(["re:[invalid".to_string()]);
2981        assert!(result.is_err(), "invalid regex should produce an error");
2982    }
2983
2984    #[test]
2985    fn test_name_filter_regex_partial_match() {
2986        // Regex without anchors matches substrings
2987        let filter = NameFilter::allow_list(["re:list".to_string()]).unwrap();
2988        assert!(filter.allows("list_files"));
2989        assert!(filter.allows("my_list_tool"));
2990        assert!(!filter.allows("read_file"));
2991    }
2992
2993    #[test]
2994    fn test_config_parse_regex_filter() {
2995        let toml = r#"
2996        [proxy]
2997        name = "regex-gw"
2998        [proxy.listen]
2999
3000        [[backends]]
3001        name = "svc"
3002        transport = "stdio"
3003        command = "echo"
3004        expose_tools = ["*_issue", "re:^list_.*$"]
3005        "#;
3006
3007        let config = ProxyConfig::parse(toml).unwrap();
3008        let filter = config.backends[0]
3009            .build_filter(&config.proxy.separator)
3010            .unwrap()
3011            .expect("should have filter");
3012        assert!(filter.tool_filter.allows("create_issue"));
3013        assert!(filter.tool_filter.allows("list_files"));
3014        assert!(filter.tool_filter.allows("list_users"));
3015        assert!(!filter.tool_filter.allows("delete_file"));
3016    }
3017
3018    #[test]
3019    fn test_parse_param_overrides() {
3020        let toml = r#"
3021        [proxy]
3022        name = "override-gw"
3023        [proxy.listen]
3024
3025        [[backends]]
3026        name = "fs"
3027        transport = "http"
3028        url = "http://localhost:8080"
3029
3030        [[backends.param_overrides]]
3031        tool = "list_directory"
3032        hide = ["path"]
3033        rename = { recursive = "deep_search" }
3034
3035        [backends.param_overrides.defaults]
3036        path = "/home/docs"
3037        "#;
3038
3039        let config = ProxyConfig::parse(toml).unwrap();
3040        assert_eq!(config.backends[0].param_overrides.len(), 1);
3041        let po = &config.backends[0].param_overrides[0];
3042        assert_eq!(po.tool, "list_directory");
3043        assert_eq!(po.hide, vec!["path"]);
3044        assert_eq!(po.defaults.get("path").unwrap(), "/home/docs");
3045        assert_eq!(po.rename.get("recursive").unwrap(), "deep_search");
3046    }
3047
3048    #[test]
3049    fn test_reject_param_override_empty_tool() {
3050        let toml = r#"
3051        [proxy]
3052        name = "bad"
3053        [proxy.listen]
3054
3055        [[backends]]
3056        name = "fs"
3057        transport = "http"
3058        url = "http://localhost:8080"
3059
3060        [[backends.param_overrides]]
3061        tool = ""
3062        hide = ["path"]
3063        "#;
3064
3065        let err = ProxyConfig::parse(toml).unwrap_err();
3066        assert!(
3067            format!("{err}").contains("tool must not be empty"),
3068            "unexpected error: {err}"
3069        );
3070    }
3071
3072    #[test]
3073    fn test_reject_param_override_duplicate_tool() {
3074        let toml = r#"
3075        [proxy]
3076        name = "bad"
3077        [proxy.listen]
3078
3079        [[backends]]
3080        name = "fs"
3081        transport = "http"
3082        url = "http://localhost:8080"
3083
3084        [[backends.param_overrides]]
3085        tool = "list_directory"
3086        hide = ["path"]
3087
3088        [[backends.param_overrides]]
3089        tool = "list_directory"
3090        hide = ["pattern"]
3091        "#;
3092
3093        let err = ProxyConfig::parse(toml).unwrap_err();
3094        assert!(
3095            format!("{err}").contains("duplicate param_overrides"),
3096            "unexpected error: {err}"
3097        );
3098    }
3099
3100    #[test]
3101    fn test_reject_param_override_hide_and_rename_same_param() {
3102        let toml = r#"
3103        [proxy]
3104        name = "bad"
3105        [proxy.listen]
3106
3107        [[backends]]
3108        name = "fs"
3109        transport = "http"
3110        url = "http://localhost:8080"
3111
3112        [[backends.param_overrides]]
3113        tool = "list_directory"
3114        hide = ["path"]
3115        rename = { path = "dir" }
3116        "#;
3117
3118        let err = ProxyConfig::parse(toml).unwrap_err();
3119        assert!(
3120            format!("{err}").contains("cannot be both hidden and renamed"),
3121            "unexpected error: {err}"
3122        );
3123    }
3124
3125    #[test]
3126    fn test_reject_param_override_duplicate_rename_target() {
3127        let toml = r#"
3128        [proxy]
3129        name = "bad"
3130        [proxy.listen]
3131
3132        [[backends]]
3133        name = "fs"
3134        transport = "http"
3135        url = "http://localhost:8080"
3136
3137        [[backends.param_overrides]]
3138        tool = "list_directory"
3139        rename = { path = "location", dir = "location" }
3140        "#;
3141
3142        let err = ProxyConfig::parse(toml).unwrap_err();
3143        assert!(
3144            format!("{err}").contains("duplicate rename target"),
3145            "unexpected error: {err}"
3146        );
3147    }
3148
3149    #[test]
3150    fn test_cache_backend_defaults_to_memory() {
3151        let config = ProxyConfig::parse(minimal_config()).unwrap();
3152        assert_eq!(config.cache.backend, "memory");
3153        assert!(config.cache.url.is_none());
3154    }
3155
3156    #[test]
3157    fn test_cache_backend_redis_requires_url() {
3158        let toml = r#"
3159        [proxy]
3160        name = "test"
3161        [proxy.listen]
3162        [cache]
3163        backend = "redis"
3164
3165        [[backends]]
3166        name = "echo"
3167        transport = "stdio"
3168        command = "echo"
3169        "#;
3170        let err = ProxyConfig::parse(toml).unwrap_err();
3171        assert!(err.to_string().contains("cache.url is required"));
3172    }
3173
3174    #[test]
3175    fn test_cache_backend_unknown_rejected() {
3176        let toml = r#"
3177        [proxy]
3178        name = "test"
3179        [proxy.listen]
3180        [cache]
3181        backend = "memcached"
3182
3183        [[backends]]
3184        name = "echo"
3185        transport = "stdio"
3186        command = "echo"
3187        "#;
3188        let err = ProxyConfig::parse(toml).unwrap_err();
3189        assert!(err.to_string().contains("unknown cache backend"));
3190    }
3191
3192    #[test]
3193    fn test_cache_backend_redis_with_url() {
3194        let toml = r#"
3195        [proxy]
3196        name = "test"
3197        [proxy.listen]
3198        [cache]
3199        backend = "redis"
3200        url = "redis://localhost:6379"
3201        prefix = "myapp:"
3202
3203        [[backends]]
3204        name = "echo"
3205        transport = "stdio"
3206        command = "echo"
3207        "#;
3208        let config = ProxyConfig::parse(toml).unwrap();
3209        assert_eq!(config.cache.backend, "redis");
3210        assert_eq!(config.cache.url.as_deref(), Some("redis://localhost:6379"));
3211        assert_eq!(config.cache.prefix, "myapp:");
3212    }
3213
3214    #[test]
3215    fn test_parse_bearer_scoped_tokens() {
3216        let toml = r#"
3217        [proxy]
3218        name = "scoped"
3219        [proxy.listen]
3220
3221        [[backends]]
3222        name = "echo"
3223        transport = "stdio"
3224        command = "echo"
3225
3226        [auth]
3227        type = "bearer"
3228
3229        [[auth.scoped_tokens]]
3230        token = "frontend-token"
3231        allow_tools = ["echo/read_file"]
3232
3233        [[auth.scoped_tokens]]
3234        token = "admin-token"
3235        "#;
3236
3237        let config = ProxyConfig::parse(toml).unwrap();
3238        match &config.auth {
3239            Some(AuthConfig::Bearer {
3240                tokens,
3241                scoped_tokens,
3242            }) => {
3243                assert!(tokens.is_empty());
3244                assert_eq!(scoped_tokens.len(), 2);
3245                assert_eq!(scoped_tokens[0].token, "frontend-token");
3246                assert_eq!(scoped_tokens[0].allow_tools, vec!["echo/read_file"]);
3247                assert!(scoped_tokens[1].allow_tools.is_empty());
3248            }
3249            other => panic!("expected Bearer auth, got: {other:?}"),
3250        }
3251    }
3252
3253    #[test]
3254    fn test_parse_bearer_mixed_tokens() {
3255        let toml = r#"
3256        [proxy]
3257        name = "mixed"
3258        [proxy.listen]
3259
3260        [[backends]]
3261        name = "echo"
3262        transport = "stdio"
3263        command = "echo"
3264
3265        [auth]
3266        type = "bearer"
3267        tokens = ["simple-token"]
3268
3269        [[auth.scoped_tokens]]
3270        token = "scoped-token"
3271        deny_tools = ["echo/delete"]
3272        "#;
3273
3274        let config = ProxyConfig::parse(toml).unwrap();
3275        match &config.auth {
3276            Some(AuthConfig::Bearer {
3277                tokens,
3278                scoped_tokens,
3279            }) => {
3280                assert_eq!(tokens, &["simple-token"]);
3281                assert_eq!(scoped_tokens.len(), 1);
3282                assert_eq!(scoped_tokens[0].deny_tools, vec!["echo/delete"]);
3283            }
3284            other => panic!("expected Bearer auth, got: {other:?}"),
3285        }
3286    }
3287
3288    #[test]
3289    fn test_bearer_empty_tokens_rejected() {
3290        let toml = r#"
3291        [proxy]
3292        name = "empty"
3293        [proxy.listen]
3294
3295        [[backends]]
3296        name = "echo"
3297        transport = "stdio"
3298        command = "echo"
3299
3300        [auth]
3301        type = "bearer"
3302        "#;
3303
3304        let err = ProxyConfig::parse(toml).unwrap_err();
3305        assert!(
3306            err.to_string().contains("at least one token"),
3307            "unexpected error: {err}"
3308        );
3309    }
3310
3311    #[test]
3312    fn test_bearer_duplicate_across_lists_rejected() {
3313        let toml = r#"
3314        [proxy]
3315        name = "dup"
3316        [proxy.listen]
3317
3318        [[backends]]
3319        name = "echo"
3320        transport = "stdio"
3321        command = "echo"
3322
3323        [auth]
3324        type = "bearer"
3325        tokens = ["shared-token"]
3326
3327        [[auth.scoped_tokens]]
3328        token = "shared-token"
3329        allow_tools = ["echo/read"]
3330        "#;
3331
3332        let err = ProxyConfig::parse(toml).unwrap_err();
3333        assert!(
3334            err.to_string().contains("duplicate bearer token"),
3335            "unexpected error: {err}"
3336        );
3337    }
3338
3339    #[test]
3340    fn test_bearer_allow_and_deny_rejected() {
3341        let toml = r#"
3342        [proxy]
3343        name = "both"
3344        [proxy.listen]
3345
3346        [[backends]]
3347        name = "echo"
3348        transport = "stdio"
3349        command = "echo"
3350
3351        [auth]
3352        type = "bearer"
3353
3354        [[auth.scoped_tokens]]
3355        token = "conflict"
3356        allow_tools = ["echo/read"]
3357        deny_tools = ["echo/write"]
3358        "#;
3359
3360        let err = ProxyConfig::parse(toml).unwrap_err();
3361        assert!(
3362            err.to_string().contains("cannot specify both"),
3363            "unexpected error: {err}"
3364        );
3365    }
3366
3367    #[test]
3368    fn test_parse_websocket_transport() {
3369        let toml = r#"
3370        [proxy]
3371        name = "ws-proxy"
3372        [proxy.listen]
3373
3374        [[backends]]
3375        name = "ws-backend"
3376        transport = "websocket"
3377        url = "ws://localhost:9090/ws"
3378        "#;
3379
3380        let config = ProxyConfig::parse(toml).unwrap();
3381        assert!(matches!(
3382            config.backends[0].transport,
3383            TransportType::Websocket
3384        ));
3385        assert_eq!(
3386            config.backends[0].url.as_deref(),
3387            Some("ws://localhost:9090/ws")
3388        );
3389    }
3390
3391    #[test]
3392    fn test_websocket_transport_requires_url() {
3393        let toml = r#"
3394        [proxy]
3395        name = "ws-proxy"
3396        [proxy.listen]
3397
3398        [[backends]]
3399        name = "ws-backend"
3400        transport = "websocket"
3401        "#;
3402
3403        let err = ProxyConfig::parse(toml).unwrap_err();
3404        assert!(
3405            err.to_string()
3406                .contains("websocket transport requires 'url'"),
3407            "unexpected error: {err}"
3408        );
3409    }
3410
3411    #[test]
3412    fn test_websocket_with_bearer_token() {
3413        let toml = r#"
3414        [proxy]
3415        name = "ws-proxy"
3416        [proxy.listen]
3417
3418        [[backends]]
3419        name = "ws-backend"
3420        transport = "websocket"
3421        url = "wss://secure.example.com/mcp"
3422        bearer_token = "my-secret"
3423        "#;
3424
3425        let config = ProxyConfig::parse(toml).unwrap();
3426        assert_eq!(
3427            config.backends[0].bearer_token.as_deref(),
3428            Some("my-secret")
3429        );
3430    }
3431
3432    #[test]
3433    fn test_tool_discovery_defaults_false() {
3434        let config = ProxyConfig::parse(minimal_config()).unwrap();
3435        assert!(!config.proxy.tool_discovery);
3436    }
3437
3438    #[test]
3439    fn test_tool_discovery_enabled() {
3440        let toml = r#"
3441        [proxy]
3442        name = "discovery"
3443        tool_discovery = true
3444        [proxy.listen]
3445
3446        [[backends]]
3447        name = "echo"
3448        transport = "stdio"
3449        command = "echo"
3450        "#;
3451
3452        let config = ProxyConfig::parse(toml).unwrap();
3453        assert!(config.proxy.tool_discovery);
3454    }
3455
3456    #[test]
3457    fn test_parse_oauth_config() {
3458        let toml = r#"
3459        [proxy]
3460        name = "oauth-proxy"
3461        [proxy.listen]
3462
3463        [[backends]]
3464        name = "echo"
3465        transport = "stdio"
3466        command = "echo"
3467
3468        [auth]
3469        type = "oauth"
3470        issuer = "https://accounts.google.com"
3471        audience = "mcp-proxy"
3472
3473        [security]
3474        admin_token = "admin-secret"
3475        "#;
3476
3477        let config = ProxyConfig::parse(toml).unwrap();
3478        match &config.auth {
3479            Some(AuthConfig::OAuth {
3480                issuer,
3481                audience,
3482                token_validation,
3483                ..
3484            }) => {
3485                assert_eq!(issuer, "https://accounts.google.com");
3486                assert_eq!(audience, "mcp-proxy");
3487                assert_eq!(token_validation, &TokenValidationStrategy::Jwt);
3488            }
3489            other => panic!("expected OAuth auth, got: {other:?}"),
3490        }
3491    }
3492
3493    #[test]
3494    fn test_parse_oauth_with_introspection() {
3495        let toml = r#"
3496        [proxy]
3497        name = "oauth-proxy"
3498        [proxy.listen]
3499
3500        [[backends]]
3501        name = "echo"
3502        transport = "stdio"
3503        command = "echo"
3504
3505        [auth]
3506        type = "oauth"
3507        issuer = "https://auth.example.com"
3508        audience = "mcp-proxy"
3509        client_id = "my-client"
3510        client_secret = "my-secret"
3511        token_validation = "introspection"
3512
3513        [security]
3514        admin_token = "admin-secret"
3515        "#;
3516
3517        let config = ProxyConfig::parse(toml).unwrap();
3518        match &config.auth {
3519            Some(AuthConfig::OAuth {
3520                token_validation,
3521                client_id,
3522                client_secret,
3523                ..
3524            }) => {
3525                assert_eq!(token_validation, &TokenValidationStrategy::Introspection);
3526                assert_eq!(client_id.as_deref(), Some("my-client"));
3527                assert_eq!(client_secret.as_deref(), Some("my-secret"));
3528            }
3529            other => panic!("expected OAuth auth, got: {other:?}"),
3530        }
3531    }
3532
3533    #[test]
3534    fn test_oauth_introspection_requires_credentials() {
3535        let toml = r#"
3536        [proxy]
3537        name = "oauth-proxy"
3538        [proxy.listen]
3539
3540        [[backends]]
3541        name = "echo"
3542        transport = "stdio"
3543        command = "echo"
3544
3545        [auth]
3546        type = "oauth"
3547        issuer = "https://auth.example.com"
3548        audience = "mcp-proxy"
3549        token_validation = "introspection"
3550        "#;
3551
3552        let err = ProxyConfig::parse(toml).unwrap_err();
3553        assert!(
3554            err.to_string().contains("client_id"),
3555            "unexpected error: {err}"
3556        );
3557    }
3558
3559    #[test]
3560    fn test_parse_oauth_with_overrides() {
3561        let toml = r#"
3562        [proxy]
3563        name = "oauth-proxy"
3564        [proxy.listen]
3565
3566        [[backends]]
3567        name = "echo"
3568        transport = "stdio"
3569        command = "echo"
3570
3571        [auth]
3572        type = "oauth"
3573        issuer = "https://auth.example.com"
3574        audience = "mcp-proxy"
3575        jwks_uri = "https://auth.example.com/custom/jwks"
3576        introspection_endpoint = "https://auth.example.com/custom/introspect"
3577        client_id = "my-client"
3578        client_secret = "my-secret"
3579        token_validation = "both"
3580        required_scopes = ["read", "write"]
3581
3582        [security]
3583        admin_token = "admin-secret"
3584        "#;
3585
3586        let config = ProxyConfig::parse(toml).unwrap();
3587        match &config.auth {
3588            Some(AuthConfig::OAuth {
3589                jwks_uri,
3590                introspection_endpoint,
3591                token_validation,
3592                required_scopes,
3593                ..
3594            }) => {
3595                assert_eq!(
3596                    jwks_uri.as_deref(),
3597                    Some("https://auth.example.com/custom/jwks")
3598                );
3599                assert_eq!(
3600                    introspection_endpoint.as_deref(),
3601                    Some("https://auth.example.com/custom/introspect")
3602                );
3603                assert_eq!(token_validation, &TokenValidationStrategy::Both);
3604                assert_eq!(required_scopes, &["read", "write"]);
3605            }
3606            other => panic!("expected OAuth auth, got: {other:?}"),
3607        }
3608    }
3609
3610    #[test]
3611    fn test_check_env_vars_warns_on_unset() {
3612        let toml = r#"
3613        [proxy]
3614        name = "env-check"
3615        [proxy.listen]
3616
3617        [[backends]]
3618        name = "svc"
3619        transport = "stdio"
3620        command = "echo"
3621        bearer_token = "${TOTALLY_UNSET_VAR_1}"
3622
3623        [backends.env]
3624        API_KEY = "${TOTALLY_UNSET_VAR_2}"
3625        STATIC = "plain-value"
3626
3627        [auth]
3628        type = "bearer"
3629        tokens = ["${TOTALLY_UNSET_VAR_3}", "literal-token"]
3630
3631        [[auth.scoped_tokens]]
3632        token = "${TOTALLY_UNSET_VAR_4}"
3633        allow_tools = ["svc/echo"]
3634        "#;
3635
3636        let config = ProxyConfig::parse(toml).unwrap();
3637        let warnings = config.check_env_vars();
3638
3639        assert_eq!(warnings.len(), 4, "warnings: {warnings:?}");
3640        assert!(warnings[0].contains("TOTALLY_UNSET_VAR_1"));
3641        assert!(warnings[0].contains("bearer_token"));
3642        assert!(warnings[1].contains("TOTALLY_UNSET_VAR_2"));
3643        assert!(warnings[1].contains("env.API_KEY"));
3644        assert!(warnings[2].contains("TOTALLY_UNSET_VAR_3"));
3645        assert!(warnings[2].contains("tokens[0]"));
3646        assert!(warnings[3].contains("TOTALLY_UNSET_VAR_4"));
3647        assert!(warnings[3].contains("scoped_tokens[0]"));
3648    }
3649
3650    #[test]
3651    fn test_check_env_vars_no_warnings_when_set() {
3652        // SAFETY: test runs single-threaded
3653        unsafe { std::env::set_var("MCP_CHECK_TEST_VAR", "value") };
3654
3655        let toml = r#"
3656        [proxy]
3657        name = "env-check"
3658        [proxy.listen]
3659
3660        [[backends]]
3661        name = "svc"
3662        transport = "stdio"
3663        command = "echo"
3664        bearer_token = "${MCP_CHECK_TEST_VAR}"
3665        "#;
3666
3667        let config = ProxyConfig::parse(toml).unwrap();
3668        let warnings = config.check_env_vars();
3669        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3670
3671        // SAFETY: same as above
3672        unsafe { std::env::remove_var("MCP_CHECK_TEST_VAR") };
3673    }
3674
3675    #[test]
3676    fn test_check_env_vars_no_warnings_for_literals() {
3677        let toml = r#"
3678        [proxy]
3679        name = "env-check"
3680        [proxy.listen]
3681
3682        [[backends]]
3683        name = "svc"
3684        transport = "stdio"
3685        command = "echo"
3686        bearer_token = "literal-token"
3687        "#;
3688
3689        let config = ProxyConfig::parse(toml).unwrap();
3690        let warnings = config.check_env_vars();
3691        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3692    }
3693
3694    #[test]
3695    fn test_check_env_vars_oauth_client_secret() {
3696        let toml = r#"
3697        [proxy]
3698        name = "oauth-check"
3699        [proxy.listen]
3700
3701        [[backends]]
3702        name = "svc"
3703        transport = "http"
3704        url = "http://localhost:3000"
3705
3706        [auth]
3707        type = "oauth"
3708        issuer = "https://auth.example.com"
3709        audience = "mcp-proxy"
3710        client_id = "my-client"
3711        client_secret = "${TOTALLY_UNSET_OAUTH_SECRET}"
3712        token_validation = "introspection"
3713
3714        [security]
3715        admin_token = "admin-secret"
3716        "#;
3717
3718        let config = ProxyConfig::parse(toml).unwrap();
3719        let warnings = config.check_env_vars();
3720        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
3721        assert!(warnings[0].contains("TOTALLY_UNSET_OAUTH_SECRET"));
3722        assert!(warnings[0].contains("client_secret"));
3723    }
3724
3725    #[cfg(feature = "yaml")]
3726    #[test]
3727    fn test_parse_yaml_config() {
3728        let yaml = r#"
3729proxy:
3730  name: yaml-proxy
3731  listen:
3732    host: "127.0.0.1"
3733    port: 8080
3734backends:
3735  - name: echo
3736    transport: stdio
3737    command: echo
3738"#;
3739        let config = ProxyConfig::parse_yaml(yaml).unwrap();
3740        assert_eq!(config.proxy.name, "yaml-proxy");
3741        assert_eq!(config.backends.len(), 1);
3742        assert_eq!(config.backends[0].name, "echo");
3743    }
3744
3745    #[cfg(feature = "yaml")]
3746    #[test]
3747    fn test_parse_yaml_with_auth() {
3748        let yaml = r#"
3749proxy:
3750  name: auth-proxy
3751  listen:
3752    host: "127.0.0.1"
3753    port: 9090
3754backends:
3755  - name: api
3756    transport: stdio
3757    command: echo
3758auth:
3759  type: bearer
3760  tokens:
3761    - token-1
3762    - token-2
3763"#;
3764        let config = ProxyConfig::parse_yaml(yaml).unwrap();
3765        match &config.auth {
3766            Some(AuthConfig::Bearer { tokens, .. }) => {
3767                assert_eq!(tokens, &["token-1", "token-2"]);
3768            }
3769            other => panic!("expected Bearer auth, got: {other:?}"),
3770        }
3771    }
3772
3773    #[cfg(feature = "yaml")]
3774    #[test]
3775    fn test_parse_yaml_with_middleware() {
3776        let yaml = r#"
3777proxy:
3778  name: mw-proxy
3779  listen:
3780    host: "127.0.0.1"
3781    port: 8080
3782backends:
3783  - name: api
3784    transport: stdio
3785    command: echo
3786    timeout:
3787      seconds: 30
3788    rate_limit:
3789      requests: 100
3790      period_seconds: 1
3791    expose_tools:
3792      - read_file
3793      - list_directory
3794"#;
3795        let config = ProxyConfig::parse_yaml(yaml).unwrap();
3796        assert_eq!(config.backends[0].timeout.as_ref().unwrap().seconds, 30);
3797        assert_eq!(
3798            config.backends[0].rate_limit.as_ref().unwrap().requests,
3799            100
3800        );
3801        assert_eq!(
3802            config.backends[0].expose_tools,
3803            vec!["read_file", "list_directory"]
3804        );
3805    }
3806
3807    #[test]
3808    fn test_from_mcp_json() {
3809        let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json");
3810        let project_dir = dir.join("my-project");
3811        std::fs::create_dir_all(&project_dir).unwrap();
3812
3813        let mcp_json_path = project_dir.join(".mcp.json");
3814        std::fs::write(
3815            &mcp_json_path,
3816            r#"{
3817                "mcpServers": {
3818                    "github": {
3819                        "command": "npx",
3820                        "args": ["-y", "@modelcontextprotocol/server-github"]
3821                    },
3822                    "api": {
3823                        "url": "http://localhost:9000"
3824                    }
3825                }
3826            }"#,
3827        )
3828        .unwrap();
3829
3830        let config = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap();
3831
3832        // Name derived from parent directory
3833        assert_eq!(config.proxy.name, "my-project");
3834        // Sensible defaults
3835        assert_eq!(config.proxy.listen.host, "127.0.0.1");
3836        assert_eq!(config.proxy.listen.port, 8080);
3837        assert_eq!(config.proxy.version, "0.1.0");
3838        assert_eq!(config.proxy.separator, "/");
3839        // No auth or middleware
3840        assert!(config.auth.is_none());
3841        assert!(config.composite_tools.is_empty());
3842        // Backends imported
3843        assert_eq!(config.backends.len(), 2);
3844        assert_eq!(config.backends[0].name, "api");
3845        assert_eq!(config.backends[1].name, "github");
3846
3847        std::fs::remove_dir_all(&dir).unwrap();
3848    }
3849
3850    #[test]
3851    fn test_from_mcp_json_empty_rejects() {
3852        let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json_empty");
3853        std::fs::create_dir_all(&dir).unwrap();
3854
3855        let mcp_json_path = dir.join(".mcp.json");
3856        std::fs::write(&mcp_json_path, r#"{ "mcpServers": {} }"#).unwrap();
3857
3858        let err = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap_err();
3859        assert!(
3860            err.to_string().contains("at least one backend"),
3861            "unexpected error: {err}"
3862        );
3863
3864        std::fs::remove_dir_all(&dir).unwrap();
3865    }
3866
3867    #[test]
3868    fn test_priority_defaults_to_zero() {
3869        let toml = r#"
3870        [proxy]
3871        name = "test"
3872        [proxy.listen]
3873
3874        [[backends]]
3875        name = "api"
3876        transport = "stdio"
3877        command = "echo"
3878        "#;
3879
3880        let config = ProxyConfig::parse(toml).unwrap();
3881        assert_eq!(config.backends[0].priority, 0);
3882    }
3883
3884    #[test]
3885    fn test_priority_parsed_from_config() {
3886        let toml = r#"
3887        [proxy]
3888        name = "test"
3889        [proxy.listen]
3890
3891        [[backends]]
3892        name = "api"
3893        transport = "stdio"
3894        command = "echo"
3895
3896        [[backends]]
3897        name = "api-backup-1"
3898        transport = "stdio"
3899        command = "echo"
3900        failover_for = "api"
3901        priority = 10
3902
3903        [[backends]]
3904        name = "api-backup-2"
3905        transport = "stdio"
3906        command = "echo"
3907        failover_for = "api"
3908        priority = 5
3909        "#;
3910
3911        let config = ProxyConfig::parse(toml).unwrap();
3912        assert_eq!(config.backends[0].priority, 0);
3913        assert_eq!(config.backends[1].priority, 10);
3914        assert_eq!(config.backends[2].priority, 5);
3915    }
3916}