Skip to main content

mcp_proxy/
config.rs

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