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        /// Required scopes for access (space-delimited).
705        #[serde(default)]
706        required_scopes: Vec<String>,
707        /// RBAC role definitions.
708        #[serde(default)]
709        roles: Vec<RoleConfig>,
710        /// Map JWT/token claims to roles.
711        role_mapping: Option<RoleMappingConfig>,
712    },
713}
714
715/// Token validation strategy for OAuth 2.1 auth.
716#[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq)]
717#[serde(rename_all = "lowercase")]
718pub enum TokenValidationStrategy {
719    /// Validate JWTs locally via JWKS (default). Fast, no network call per request.
720    #[default]
721    Jwt,
722    /// Validate tokens via the authorization server's introspection endpoint (RFC 7662).
723    /// Works with opaque tokens. Requires `client_id` and `client_secret`.
724    Introspection,
725    /// Try JWT validation first; fall back to introspection for non-JWT tokens.
726    /// Requires `client_id` and `client_secret`.
727    Both,
728}
729
730/// Per-token configuration for bearer auth with optional tool scoping.
731///
732/// Allows restricting which tools each bearer token can access, bridging
733/// the gap between all-or-nothing bearer auth and full JWT/RBAC.
734///
735/// # Examples
736///
737/// ```
738/// use mcp_proxy::config::BearerTokenConfig;
739///
740/// let frontend = BearerTokenConfig {
741///     token: "frontend-token".into(),
742///     allow_tools: vec!["files/read_file".into()],
743///     deny_tools: vec![],
744/// };
745///
746/// let admin = BearerTokenConfig {
747///     token: "admin-token".into(),
748///     allow_tools: vec![],
749///     deny_tools: vec![],
750/// };
751/// ```
752#[derive(Debug, Clone, Deserialize, Serialize)]
753pub struct BearerTokenConfig {
754    /// The bearer token value. Supports `${ENV_VAR}` syntax.
755    pub token: String,
756    /// Tools this token can access (namespaced, e.g. "files/read_file").
757    /// Empty means all tools allowed.
758    #[serde(default)]
759    pub allow_tools: Vec<String>,
760    /// Tools this token cannot access.
761    #[serde(default)]
762    pub deny_tools: Vec<String>,
763}
764
765/// RBAC role definition.
766#[derive(Debug, Deserialize, Serialize)]
767pub struct RoleConfig {
768    /// Role name, referenced by `RoleMappingConfig`.
769    pub name: String,
770    /// Tools this role can access (namespaced, e.g. "files/read_file")
771    #[serde(default)]
772    pub allow_tools: Vec<String>,
773    /// Tools this role cannot access
774    #[serde(default)]
775    pub deny_tools: Vec<String>,
776}
777
778/// Maps JWT claim values to RBAC role names.
779#[derive(Debug, Deserialize, Serialize)]
780pub struct RoleMappingConfig {
781    /// JWT claim to read for role resolution (e.g. "scope", "role", "groups")
782    pub claim: String,
783    /// Map claim values to role names
784    pub mapping: HashMap<String, String>,
785}
786
787/// Tool alias: exposes a backend tool under a different name.
788#[derive(Debug, Deserialize, Serialize)]
789pub struct AliasConfig {
790    /// Original tool name (backend-local, without namespace prefix)
791    pub from: String,
792    /// New tool name to expose (will be namespaced as backend/to)
793    pub to: String,
794}
795
796/// Per-backend response cache configuration.
797#[derive(Debug, Deserialize, Serialize)]
798pub struct BackendCacheConfig {
799    /// TTL for cached resource reads in seconds (0 = disabled)
800    #[serde(default)]
801    pub resource_ttl_seconds: u64,
802    /// TTL for cached tool call results in seconds (0 = disabled)
803    #[serde(default)]
804    pub tool_ttl_seconds: u64,
805    /// Maximum number of cached entries per backend (default: 1000)
806    #[serde(default = "default_max_cache_entries")]
807    pub max_entries: u64,
808}
809
810/// Global cache backend configuration.
811///
812/// Controls which storage backend is used for response caching. Per-backend
813/// TTL and max_entries settings remain the same regardless of backend.
814///
815/// # Backends
816///
817/// - `"memory"` (default): In-process cache using moka. Fast, no external deps,
818///   but not shared across proxy instances.
819/// - `"redis"`: External Redis cache. Shared across instances. Requires the
820///   `redis-cache` feature.
821/// - `"sqlite"`: Local SQLite cache. Persistent across restarts. Requires the
822///   `sqlite-cache` feature.
823#[derive(Debug, Deserialize, Serialize, Clone)]
824pub struct CacheBackendConfig {
825    /// Cache backend type: "memory" (default), "redis", or "sqlite".
826    #[serde(default = "default_cache_backend")]
827    pub backend: String,
828    /// Connection URL for external backends (Redis or SQLite path).
829    pub url: Option<String>,
830    /// Key prefix for external cache entries (default: "mcp-proxy:").
831    #[serde(default = "default_cache_prefix")]
832    pub prefix: String,
833}
834
835impl Default for CacheBackendConfig {
836    fn default() -> Self {
837        Self {
838            backend: default_cache_backend(),
839            url: None,
840            prefix: default_cache_prefix(),
841        }
842    }
843}
844
845fn default_cache_backend() -> String {
846    "memory".to_string()
847}
848
849fn default_cache_prefix() -> String {
850    "mcp-proxy:".to_string()
851}
852
853/// Performance tuning options.
854#[derive(Debug, Default, Deserialize, Serialize)]
855pub struct PerformanceConfig {
856    /// Deduplicate identical concurrent tool calls and resource reads
857    #[serde(default)]
858    pub coalesce_requests: bool,
859}
860
861/// Security policies.
862#[derive(Debug, Default, Deserialize, Serialize)]
863pub struct SecurityConfig {
864    /// Maximum size of tool call arguments in bytes (default: unlimited)
865    pub max_argument_size: Option<usize>,
866    /// Bearer token for admin API access. If set, all admin endpoints require
867    /// `Authorization: Bearer <token>`. If not set, falls back to the proxy's
868    /// inbound auth config. If neither is set, admin API is open.
869    /// Supports `${ENV_VAR}` syntax.
870    pub admin_token: Option<String>,
871}
872
873/// Logging, metrics, and distributed tracing configuration.
874#[derive(Debug, Default, Deserialize, Serialize)]
875pub struct ObservabilityConfig {
876    /// Enable audit logging of all MCP requests (default: false).
877    #[serde(default)]
878    pub audit: bool,
879    /// Log level filter (default: "info").
880    #[serde(default = "default_log_level")]
881    pub log_level: String,
882    /// Emit structured JSON logs (default: false).
883    #[serde(default)]
884    pub json_logs: bool,
885    /// Prometheus metrics configuration.
886    #[serde(default)]
887    pub metrics: MetricsConfig,
888    /// OpenTelemetry distributed tracing configuration.
889    #[serde(default)]
890    pub tracing: TracingConfig,
891    /// Structured access logging configuration.
892    #[serde(default)]
893    pub access_log: AccessLogConfig,
894}
895
896/// Structured access log configuration.
897#[derive(Debug, Default, Deserialize, Serialize)]
898pub struct AccessLogConfig {
899    /// Enable structured access logging (default: false).
900    #[serde(default)]
901    pub enabled: bool,
902}
903
904/// Prometheus metrics configuration.
905#[derive(Debug, Default, Deserialize, Serialize)]
906pub struct MetricsConfig {
907    /// Enable Prometheus metrics at `/admin/metrics` (default: false).
908    #[serde(default)]
909    pub enabled: bool,
910}
911
912/// OpenTelemetry distributed tracing configuration.
913#[derive(Debug, Default, Deserialize, Serialize)]
914pub struct TracingConfig {
915    /// Enable OTLP trace export (default: false).
916    #[serde(default)]
917    pub enabled: bool,
918    /// OTLP endpoint (default: http://localhost:4317)
919    #[serde(default = "default_otlp_endpoint")]
920    pub endpoint: String,
921    /// Service name for traces (default: "mcp-proxy")
922    #[serde(default = "default_service_name")]
923    pub service_name: String,
924}
925
926// Defaults
927
928fn default_version() -> String {
929    "0.1.0".to_string()
930}
931
932fn default_separator() -> String {
933    "/".to_string()
934}
935
936fn default_host() -> String {
937    "127.0.0.1".to_string()
938}
939
940fn default_port() -> u16 {
941    8080
942}
943
944fn default_log_level() -> String {
945    "info".to_string()
946}
947
948fn default_failure_rate() -> f64 {
949    0.5
950}
951
952fn default_min_calls() -> usize {
953    5
954}
955
956fn default_wait_duration() -> u64 {
957    30
958}
959
960fn default_half_open_calls() -> usize {
961    3
962}
963
964fn default_rate_period() -> u64 {
965    1
966}
967
968fn default_max_retries() -> u32 {
969    3
970}
971
972fn default_initial_backoff_ms() -> u64 {
973    100
974}
975
976fn default_max_backoff_ms() -> u64 {
977    5000
978}
979
980fn default_min_retries_per_sec() -> u32 {
981    10
982}
983
984fn default_consecutive_errors() -> u32 {
985    5
986}
987
988fn default_interval_seconds() -> u64 {
989    10
990}
991
992fn default_base_ejection_seconds() -> u64 {
993    30
994}
995
996fn default_max_ejection_percent() -> u32 {
997    50
998}
999
1000fn default_hedge_delay_ms() -> u64 {
1001    200
1002}
1003
1004fn default_max_hedges() -> usize {
1005    1
1006}
1007
1008fn default_mirror_percent() -> u32 {
1009    100
1010}
1011
1012fn default_weight() -> u32 {
1013    100
1014}
1015
1016fn default_max_cache_entries() -> u64 {
1017    1000
1018}
1019
1020fn default_shutdown_timeout() -> u64 {
1021    30
1022}
1023
1024fn default_otlp_endpoint() -> String {
1025    "http://localhost:4317".to_string()
1026}
1027
1028fn default_service_name() -> String {
1029    "mcp-proxy".to_string()
1030}
1031
1032/// Resolved filter rules for a backend's capabilities.
1033#[derive(Debug, Clone)]
1034pub struct BackendFilter {
1035    /// Namespace prefix (e.g. "db/") this filter applies to.
1036    pub namespace: String,
1037    /// Filter for tool names.
1038    pub tool_filter: NameFilter,
1039    /// Filter for resource URIs.
1040    pub resource_filter: NameFilter,
1041    /// Filter for prompt names.
1042    pub prompt_filter: NameFilter,
1043    /// Hide tools with `destructive_hint = true`.
1044    pub hide_destructive: bool,
1045    /// Only allow tools with `read_only_hint = true`.
1046    pub read_only_only: bool,
1047}
1048
1049/// A compiled pattern for name matching -- either a glob or a regex.
1050///
1051/// Constructed internally by [`NameFilter::allow_list`] and
1052/// [`NameFilter::deny_list`].
1053#[derive(Debug, Clone)]
1054pub enum CompiledPattern {
1055    /// A glob pattern (matched via `glob_match`).
1056    Glob(String),
1057    /// A pre-compiled regex pattern (from `re:` prefix).
1058    Regex(regex::Regex),
1059}
1060
1061impl CompiledPattern {
1062    /// Compile a pattern string. Patterns prefixed with `re:` are treated as
1063    /// regular expressions; all others are treated as glob patterns.
1064    fn compile(pattern: &str) -> Result<Self> {
1065        if let Some(re_pat) = pattern.strip_prefix("re:") {
1066            let re = regex::Regex::new(re_pat)
1067                .with_context(|| format!("invalid regex in filter pattern: {pattern}"))?;
1068            Ok(Self::Regex(re))
1069        } else {
1070            Ok(Self::Glob(pattern.to_string()))
1071        }
1072    }
1073
1074    /// Check if this pattern matches the given name.
1075    fn matches(&self, name: &str) -> bool {
1076        match self {
1077            Self::Glob(pat) => glob_match::glob_match(pat, name),
1078            Self::Regex(re) => re.is_match(name),
1079        }
1080    }
1081}
1082
1083/// A name-based allow/deny filter.
1084///
1085/// Patterns support two syntaxes:
1086/// - **Glob** (default): `*` matches any sequence, `?` matches one character.
1087/// - **Regex** (`re:` prefix): e.g. `re:^list_.*$` uses the `regex` crate.
1088///
1089/// Regex patterns are compiled once at config parse time.
1090#[derive(Debug, Clone)]
1091pub enum NameFilter {
1092    /// No filtering -- everything passes.
1093    PassAll,
1094    /// Only items matching at least one pattern are allowed.
1095    AllowList(Vec<CompiledPattern>),
1096    /// Items matching any pattern are denied.
1097    DenyList(Vec<CompiledPattern>),
1098}
1099
1100impl NameFilter {
1101    /// Build an allow-list filter from raw pattern strings.
1102    ///
1103    /// Patterns prefixed with `re:` are compiled as regular expressions;
1104    /// all others are treated as glob patterns.
1105    ///
1106    /// # Errors
1107    ///
1108    /// Returns an error if any `re:` pattern contains invalid regex syntax.
1109    pub fn allow_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1110        let compiled: Result<Vec<_>> = patterns
1111            .into_iter()
1112            .map(|p| CompiledPattern::compile(&p))
1113            .collect();
1114        Ok(Self::AllowList(compiled?))
1115    }
1116
1117    /// Build a deny-list filter from raw pattern strings.
1118    ///
1119    /// Patterns prefixed with `re:` are compiled as regular expressions;
1120    /// all others are treated as glob patterns.
1121    ///
1122    /// # Errors
1123    ///
1124    /// Returns an error if any `re:` pattern contains invalid regex syntax.
1125    pub fn deny_list(patterns: impl IntoIterator<Item = String>) -> Result<Self> {
1126        let compiled: Result<Vec<_>> = patterns
1127            .into_iter()
1128            .map(|p| CompiledPattern::compile(&p))
1129            .collect();
1130        Ok(Self::DenyList(compiled?))
1131    }
1132
1133    /// Check if a capability name is allowed by this filter.
1134    ///
1135    /// Supports glob patterns (`*`, `?`) and regex patterns (`re:` prefix).
1136    /// Exact strings match themselves.
1137    ///
1138    /// # Examples
1139    ///
1140    /// ```
1141    /// use mcp_proxy::config::NameFilter;
1142    ///
1143    /// let filter = NameFilter::deny_list(["delete".to_string()]).unwrap();
1144    /// assert!(filter.allows("read"));
1145    /// assert!(!filter.allows("delete"));
1146    ///
1147    /// let filter = NameFilter::allow_list(["read".to_string()]).unwrap();
1148    /// assert!(filter.allows("read"));
1149    /// assert!(!filter.allows("write"));
1150    ///
1151    /// assert!(NameFilter::PassAll.allows("anything"));
1152    ///
1153    /// // Glob patterns
1154    /// let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
1155    /// assert!(filter.allows("read_file"));
1156    /// assert!(filter.allows("write_file"));
1157    /// assert!(!filter.allows("query"));
1158    ///
1159    /// // Regex patterns
1160    /// let filter = NameFilter::allow_list(["re:^list_.*$".to_string()]).unwrap();
1161    /// assert!(filter.allows("list_files"));
1162    /// assert!(!filter.allows("get_files"));
1163    /// ```
1164    pub fn allows(&self, name: &str) -> bool {
1165        match self {
1166            Self::PassAll => true,
1167            Self::AllowList(patterns) => patterns.iter().any(|p| p.matches(name)),
1168            Self::DenyList(patterns) => !patterns.iter().any(|p| p.matches(name)),
1169        }
1170    }
1171}
1172
1173impl BackendConfig {
1174    /// Build a [`BackendFilter`] from this backend's expose/hide lists.
1175    /// Returns `None` if no filtering is configured.
1176    ///
1177    /// Canary and failover backends automatically hide all capabilities so
1178    /// their tools don't appear in `ListTools` responses (traffic reaches
1179    /// them via routing middleware, not direct tool calls).
1180    pub fn build_filter(&self, separator: &str) -> Result<Option<BackendFilter>> {
1181        // Canary and failover backends hide all capabilities -- tools are
1182        // accessed via routing middleware rewriting the primary namespace.
1183        if self.canary_of.is_some() || self.failover_for.is_some() {
1184            return Ok(Some(BackendFilter {
1185                namespace: format!("{}{}", self.name, separator),
1186                tool_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1187                resource_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1188                prompt_filter: NameFilter::allow_list(std::iter::empty::<String>())?,
1189                hide_destructive: false,
1190                read_only_only: false,
1191            }));
1192        }
1193
1194        let tool_filter = if !self.expose_tools.is_empty() {
1195            NameFilter::allow_list(self.expose_tools.iter().cloned())?
1196        } else if !self.hide_tools.is_empty() {
1197            NameFilter::deny_list(self.hide_tools.iter().cloned())?
1198        } else {
1199            NameFilter::PassAll
1200        };
1201
1202        let resource_filter = if !self.expose_resources.is_empty() {
1203            NameFilter::allow_list(self.expose_resources.iter().cloned())?
1204        } else if !self.hide_resources.is_empty() {
1205            NameFilter::deny_list(self.hide_resources.iter().cloned())?
1206        } else {
1207            NameFilter::PassAll
1208        };
1209
1210        let prompt_filter = if !self.expose_prompts.is_empty() {
1211            NameFilter::allow_list(self.expose_prompts.iter().cloned())?
1212        } else if !self.hide_prompts.is_empty() {
1213            NameFilter::deny_list(self.hide_prompts.iter().cloned())?
1214        } else {
1215            NameFilter::PassAll
1216        };
1217
1218        // Only create a filter if at least one dimension has filtering
1219        if matches!(tool_filter, NameFilter::PassAll)
1220            && matches!(resource_filter, NameFilter::PassAll)
1221            && matches!(prompt_filter, NameFilter::PassAll)
1222            && !self.hide_destructive
1223            && !self.read_only_only
1224        {
1225            return Ok(None);
1226        }
1227
1228        Ok(Some(BackendFilter {
1229            namespace: format!("{}{}", self.name, separator),
1230            tool_filter,
1231            resource_filter,
1232            prompt_filter,
1233            hide_destructive: self.hide_destructive,
1234            read_only_only: self.read_only_only,
1235        }))
1236    }
1237}
1238
1239impl ProxyConfig {
1240    /// Load and validate a config from a file path.
1241    ///
1242    /// If `import_backends` is set in the config, backends from the referenced
1243    /// `.mcp.json` file are merged (TOML backends take precedence on name conflicts).
1244    pub fn load(path: &Path) -> Result<Self> {
1245        let content =
1246            std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
1247
1248        let mut config: Self = match path.extension().and_then(|e| e.to_str()) {
1249            #[cfg(feature = "yaml")]
1250            Some("yaml" | "yml") => serde_yaml::from_str(&content)
1251                .with_context(|| format!("parsing YAML {}", path.display()))?,
1252            #[cfg(not(feature = "yaml"))]
1253            Some("yaml" | "yml") => {
1254                anyhow::bail!(
1255                    "YAML config requires the 'yaml' feature. Rebuild with: cargo install mcp-proxy --features yaml"
1256                );
1257            }
1258            _ => toml::from_str(&content).with_context(|| format!("parsing {}", path.display()))?,
1259        };
1260
1261        // Import backends from .mcp.json if configured
1262        if let Some(ref mcp_json_path) = config.proxy.import_backends {
1263            let mcp_path = if std::path::Path::new(mcp_json_path).is_relative() {
1264                // Resolve relative to config file directory
1265                path.parent().unwrap_or(Path::new(".")).join(mcp_json_path)
1266            } else {
1267                std::path::PathBuf::from(mcp_json_path)
1268            };
1269
1270            let mcp_json = crate::mcp_json::McpJsonConfig::load(&mcp_path)
1271                .with_context(|| format!("importing backends from {}", mcp_path.display()))?;
1272
1273            let existing_names: HashSet<String> =
1274                config.backends.iter().map(|b| b.name.clone()).collect();
1275
1276            for backend in mcp_json.into_backends()? {
1277                if !existing_names.contains(&backend.name) {
1278                    config.backends.push(backend);
1279                }
1280            }
1281        }
1282
1283        config.source_path = Some(path.to_path_buf());
1284        config.validate()?;
1285        Ok(config)
1286    }
1287
1288    /// Build a minimal `ProxyConfig` from a `.mcp.json` file.
1289    ///
1290    /// This is a convenience mode for quick local development. The proxy name
1291    /// is derived from the file's parent directory (or the filename itself),
1292    /// and the server listens on `127.0.0.1:8080` with no middleware or auth.
1293    ///
1294    /// # Examples
1295    ///
1296    /// ```no_run
1297    /// use std::path::Path;
1298    /// use mcp_proxy::ProxyConfig;
1299    ///
1300    /// let config = ProxyConfig::from_mcp_json(Path::new(".mcp.json")).unwrap();
1301    /// assert_eq!(config.proxy.listen.host, "127.0.0.1");
1302    /// assert_eq!(config.proxy.listen.port, 8080);
1303    /// ```
1304    pub fn from_mcp_json(path: &Path) -> Result<Self> {
1305        let mcp_json = crate::mcp_json::McpJsonConfig::load(path)?;
1306        let backends = mcp_json.into_backends()?;
1307
1308        // Derive a proxy name from the parent directory or filename
1309        let name = path
1310            .parent()
1311            .and_then(|p| p.file_name())
1312            .or_else(|| path.file_stem())
1313            .map(|s| s.to_string_lossy().into_owned())
1314            .unwrap_or_else(|| "mcp-proxy".to_string());
1315
1316        let config = Self {
1317            proxy: ProxySettings {
1318                name,
1319                version: default_version(),
1320                separator: default_separator(),
1321                listen: ListenConfig {
1322                    host: default_host(),
1323                    port: default_port(),
1324                },
1325                instructions: None,
1326                shutdown_timeout_seconds: default_shutdown_timeout(),
1327                hot_reload: false,
1328                import_backends: None,
1329                rate_limit: None,
1330                tool_discovery: false,
1331                tool_exposure: ToolExposure::default(),
1332            },
1333            backends,
1334            auth: None,
1335            performance: PerformanceConfig::default(),
1336            security: SecurityConfig::default(),
1337            cache: CacheBackendConfig::default(),
1338            observability: ObservabilityConfig::default(),
1339            composite_tools: Vec::new(),
1340            source_path: Some(path.to_path_buf()),
1341        };
1342
1343        config.validate()?;
1344        Ok(config)
1345    }
1346
1347    /// Parse and validate a config from a TOML string.
1348    ///
1349    /// # Examples
1350    ///
1351    /// ```
1352    /// use mcp_proxy::ProxyConfig;
1353    ///
1354    /// let config = ProxyConfig::parse(r#"
1355    ///     [proxy]
1356    ///     name = "my-proxy"
1357    ///     [proxy.listen]
1358    ///
1359    ///     [[backends]]
1360    ///     name = "echo"
1361    ///     transport = "stdio"
1362    ///     command = "echo"
1363    /// "#).unwrap();
1364    ///
1365    /// assert_eq!(config.proxy.name, "my-proxy");
1366    /// assert_eq!(config.backends.len(), 1);
1367    /// ```
1368    pub fn parse(toml: &str) -> Result<Self> {
1369        let config: Self = toml::from_str(toml).context("parsing config")?;
1370        config.validate()?;
1371        Ok(config)
1372    }
1373
1374    /// Parse and validate a config from a YAML string.
1375    ///
1376    /// # Examples
1377    ///
1378    /// ```
1379    /// use mcp_proxy::ProxyConfig;
1380    ///
1381    /// let config = ProxyConfig::parse_yaml(r#"
1382    /// proxy:
1383    ///   name: my-proxy
1384    ///   listen:
1385    ///     host: "127.0.0.1"
1386    ///     port: 8080
1387    /// backends:
1388    ///   - name: echo
1389    ///     transport: stdio
1390    ///     command: echo
1391    /// "#).unwrap();
1392    ///
1393    /// assert_eq!(config.proxy.name, "my-proxy");
1394    /// ```
1395    #[cfg(feature = "yaml")]
1396    pub fn parse_yaml(yaml: &str) -> Result<Self> {
1397        let config: Self = serde_yaml::from_str(yaml).context("parsing YAML config")?;
1398        config.validate()?;
1399        Ok(config)
1400    }
1401
1402    fn validate(&self) -> Result<()> {
1403        if self.backends.is_empty() {
1404            anyhow::bail!("at least one backend is required");
1405        }
1406
1407        // Validate cache backend
1408        match self.cache.backend.as_str() {
1409            "memory" => {}
1410            "redis" => {
1411                if self.cache.url.is_none() {
1412                    anyhow::bail!(
1413                        "cache.url is required when cache.backend = \"{}\"",
1414                        self.cache.backend
1415                    );
1416                }
1417                #[cfg(not(feature = "redis-cache"))]
1418                anyhow::bail!(
1419                    "cache.backend = \"redis\" requires the 'redis-cache' feature. \
1420                     Rebuild with: cargo install mcp-proxy --features redis-cache"
1421                );
1422            }
1423            "sqlite" => {
1424                if self.cache.url.is_none() {
1425                    anyhow::bail!(
1426                        "cache.url is required when cache.backend = \"{}\"",
1427                        self.cache.backend
1428                    );
1429                }
1430                #[cfg(not(feature = "sqlite-cache"))]
1431                anyhow::bail!(
1432                    "cache.backend = \"sqlite\" requires the 'sqlite-cache' feature. \
1433                     Rebuild with: cargo install mcp-proxy --features sqlite-cache"
1434                );
1435            }
1436            other => {
1437                anyhow::bail!(
1438                    "unknown cache backend \"{}\", expected \"memory\", \"redis\", or \"sqlite\"",
1439                    other
1440                );
1441            }
1442        }
1443
1444        // Validate global rate limit
1445        if let Some(rl) = &self.proxy.rate_limit {
1446            if rl.requests == 0 {
1447                anyhow::bail!("proxy.rate_limit.requests must be > 0");
1448            }
1449            if rl.period_seconds == 0 {
1450                anyhow::bail!("proxy.rate_limit.period_seconds must be > 0");
1451            }
1452        }
1453
1454        // Validate bearer auth config
1455        if let Some(AuthConfig::Bearer {
1456            tokens,
1457            scoped_tokens,
1458        }) = &self.auth
1459        {
1460            if tokens.is_empty() && scoped_tokens.is_empty() {
1461                anyhow::bail!(
1462                    "bearer auth requires at least one token in 'tokens' or 'scoped_tokens'"
1463                );
1464            }
1465            // Check for duplicate tokens across both lists
1466            let mut seen_tokens = HashSet::new();
1467            for t in tokens {
1468                if !seen_tokens.insert(t.as_str()) {
1469                    anyhow::bail!("duplicate bearer token in 'tokens'");
1470                }
1471            }
1472            for st in scoped_tokens {
1473                if !seen_tokens.insert(st.token.as_str()) {
1474                    anyhow::bail!(
1475                        "duplicate bearer token (appears in both 'tokens' and 'scoped_tokens' or duplicated within 'scoped_tokens')"
1476                    );
1477                }
1478                if !st.allow_tools.is_empty() && !st.deny_tools.is_empty() {
1479                    anyhow::bail!(
1480                        "scoped_tokens: cannot specify both allow_tools and deny_tools for the same token"
1481                    );
1482                }
1483            }
1484        }
1485
1486        // Validate OAuth config
1487        if let Some(AuthConfig::OAuth {
1488            token_validation,
1489            client_id,
1490            client_secret,
1491            ..
1492        }) = &self.auth
1493            && matches!(
1494                token_validation,
1495                TokenValidationStrategy::Introspection | TokenValidationStrategy::Both
1496            )
1497            && (client_id.is_none() || client_secret.is_none())
1498        {
1499            anyhow::bail!("OAuth introspection requires both 'client_id' and 'client_secret'");
1500        }
1501
1502        // Check for duplicate backend names
1503        let mut seen_names = HashSet::new();
1504        for backend in &self.backends {
1505            if !seen_names.insert(&backend.name) {
1506                anyhow::bail!("duplicate backend name '{}'", backend.name);
1507            }
1508        }
1509
1510        for backend in &self.backends {
1511            match backend.transport {
1512                TransportType::Stdio => {
1513                    if backend.command.is_none() {
1514                        anyhow::bail!(
1515                            "backend '{}': stdio transport requires 'command'",
1516                            backend.name
1517                        );
1518                    }
1519                }
1520                TransportType::Http => {
1521                    if backend.url.is_none() {
1522                        anyhow::bail!("backend '{}': http transport requires 'url'", backend.name);
1523                    }
1524                }
1525                TransportType::Websocket => {
1526                    if backend.url.is_none() {
1527                        anyhow::bail!(
1528                            "backend '{}': websocket transport requires 'url'",
1529                            backend.name
1530                        );
1531                    }
1532                }
1533            }
1534
1535            if let Some(cb) = &backend.circuit_breaker
1536                && (cb.failure_rate_threshold <= 0.0 || cb.failure_rate_threshold > 1.0)
1537            {
1538                anyhow::bail!(
1539                    "backend '{}': circuit_breaker.failure_rate_threshold must be in (0.0, 1.0]",
1540                    backend.name
1541                );
1542            }
1543
1544            if let Some(rl) = &backend.rate_limit
1545                && rl.requests == 0
1546            {
1547                anyhow::bail!(
1548                    "backend '{}': rate_limit.requests must be > 0",
1549                    backend.name
1550                );
1551            }
1552
1553            if let Some(cc) = &backend.concurrency
1554                && cc.max_concurrent == 0
1555            {
1556                anyhow::bail!(
1557                    "backend '{}': concurrency.max_concurrent must be > 0",
1558                    backend.name
1559                );
1560            }
1561
1562            if !backend.expose_tools.is_empty() && !backend.hide_tools.is_empty() {
1563                anyhow::bail!(
1564                    "backend '{}': cannot specify both expose_tools and hide_tools",
1565                    backend.name
1566                );
1567            }
1568            if !backend.expose_resources.is_empty() && !backend.hide_resources.is_empty() {
1569                anyhow::bail!(
1570                    "backend '{}': cannot specify both expose_resources and hide_resources",
1571                    backend.name
1572                );
1573            }
1574            if !backend.expose_prompts.is_empty() && !backend.hide_prompts.is_empty() {
1575                anyhow::bail!(
1576                    "backend '{}': cannot specify both expose_prompts and hide_prompts",
1577                    backend.name
1578                );
1579            }
1580        }
1581
1582        // Validate mirror_of references
1583        let backend_names: HashSet<&str> = self.backends.iter().map(|b| b.name.as_str()).collect();
1584        for backend in &self.backends {
1585            if let Some(ref source) = backend.mirror_of {
1586                if !backend_names.contains(source.as_str()) {
1587                    anyhow::bail!(
1588                        "backend '{}': mirror_of references unknown backend '{}'",
1589                        backend.name,
1590                        source
1591                    );
1592                }
1593                if source == &backend.name {
1594                    anyhow::bail!(
1595                        "backend '{}': mirror_of cannot reference itself",
1596                        backend.name
1597                    );
1598                }
1599            }
1600        }
1601
1602        // Validate failover_for references
1603        for backend in &self.backends {
1604            if let Some(ref primary) = backend.failover_for {
1605                if !backend_names.contains(primary.as_str()) {
1606                    anyhow::bail!(
1607                        "backend '{}': failover_for references unknown backend '{}'",
1608                        backend.name,
1609                        primary
1610                    );
1611                }
1612                if primary == &backend.name {
1613                    anyhow::bail!(
1614                        "backend '{}': failover_for cannot reference itself",
1615                        backend.name
1616                    );
1617                }
1618            }
1619        }
1620
1621        // Validate composite tools
1622        {
1623            let mut composite_names = HashSet::new();
1624            for ct in &self.composite_tools {
1625                if ct.name.is_empty() {
1626                    anyhow::bail!("composite_tools: name must not be empty");
1627                }
1628                if ct.tools.is_empty() {
1629                    anyhow::bail!(
1630                        "composite_tools '{}': must reference at least one tool",
1631                        ct.name
1632                    );
1633                }
1634                if !composite_names.insert(&ct.name) {
1635                    anyhow::bail!("duplicate composite_tools name '{}'", ct.name);
1636                }
1637            }
1638        }
1639
1640        // Validate canary_of references
1641        for backend in &self.backends {
1642            if let Some(ref primary) = backend.canary_of {
1643                if !backend_names.contains(primary.as_str()) {
1644                    anyhow::bail!(
1645                        "backend '{}': canary_of references unknown backend '{}'",
1646                        backend.name,
1647                        primary
1648                    );
1649                }
1650                if primary == &backend.name {
1651                    anyhow::bail!(
1652                        "backend '{}': canary_of cannot reference itself",
1653                        backend.name
1654                    );
1655                }
1656                if backend.weight == 0 {
1657                    anyhow::bail!("backend '{}': weight must be > 0", backend.name);
1658                }
1659            }
1660        }
1661
1662        // Validate tool_exposure = "search" requires the discovery feature
1663        #[cfg(not(feature = "discovery"))]
1664        if self.proxy.tool_exposure == ToolExposure::Search {
1665            anyhow::bail!(
1666                "tool_exposure = \"search\" requires the 'discovery' feature. \
1667                 Rebuild with: cargo install mcp-proxy --features discovery"
1668            );
1669        }
1670
1671        // Validate param_overrides
1672        for backend in &self.backends {
1673            let mut seen_tools = HashSet::new();
1674            for po in &backend.param_overrides {
1675                if po.tool.is_empty() {
1676                    anyhow::bail!(
1677                        "backend '{}': param_overrides.tool must not be empty",
1678                        backend.name
1679                    );
1680                }
1681                if !seen_tools.insert(&po.tool) {
1682                    anyhow::bail!(
1683                        "backend '{}': duplicate param_overrides for tool '{}'",
1684                        backend.name,
1685                        po.tool
1686                    );
1687                }
1688                // Hidden params that have no default are a warning-level concern,
1689                // but renamed params that conflict with hide are an error.
1690                for hidden in &po.hide {
1691                    if po.rename.contains_key(hidden) {
1692                        anyhow::bail!(
1693                            "backend '{}': param_overrides for tool '{}': \
1694                             parameter '{}' cannot be both hidden and renamed",
1695                            backend.name,
1696                            po.tool,
1697                            hidden
1698                        );
1699                    }
1700                }
1701                // Check for rename target conflicts (two originals mapping to same name)
1702                let mut rename_targets = HashSet::new();
1703                for target in po.rename.values() {
1704                    if !rename_targets.insert(target) {
1705                        anyhow::bail!(
1706                            "backend '{}': param_overrides for tool '{}': \
1707                             duplicate rename target '{}'",
1708                            backend.name,
1709                            po.tool,
1710                            target
1711                        );
1712                    }
1713                }
1714            }
1715        }
1716
1717        Ok(())
1718    }
1719
1720    /// Resolve environment variable references in config values.
1721    /// Replaces `${VAR_NAME}` with the value of the environment variable.
1722    pub fn resolve_env_vars(&mut self) {
1723        for backend in &mut self.backends {
1724            for value in backend.env.values_mut() {
1725                if let Some(var_name) = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1726                    && let Ok(env_val) = std::env::var(var_name)
1727                {
1728                    *value = env_val;
1729                }
1730            }
1731            if let Some(ref mut token) = backend.bearer_token
1732                && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1733                && let Ok(env_val) = std::env::var(var_name)
1734            {
1735                *token = env_val;
1736            }
1737        }
1738
1739        // Resolve env vars in auth config
1740        if let Some(AuthConfig::Bearer {
1741            tokens,
1742            scoped_tokens,
1743        }) = &mut self.auth
1744        {
1745            for token in tokens.iter_mut() {
1746                if let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1747                    && let Ok(env_val) = std::env::var(var_name)
1748                {
1749                    *token = env_val;
1750                }
1751            }
1752            for st in scoped_tokens.iter_mut() {
1753                if let Some(var_name) = st
1754                    .token
1755                    .strip_prefix("${")
1756                    .and_then(|s| s.strip_suffix('}'))
1757                    && let Ok(env_val) = std::env::var(var_name)
1758                {
1759                    st.token = env_val;
1760                }
1761            }
1762        }
1763
1764        // Resolve env vars in admin_token
1765        if let Some(ref mut token) = self.security.admin_token
1766            && let Some(var_name) = token.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1767            && let Ok(env_val) = std::env::var(var_name)
1768        {
1769            *token = env_val;
1770        }
1771
1772        // Resolve env vars in OAuth config
1773        if let Some(AuthConfig::OAuth { client_secret, .. }) = &mut self.auth
1774            && let Some(secret) = client_secret
1775            && let Some(var_name) = secret.strip_prefix("${").and_then(|s| s.strip_suffix('}'))
1776            && let Ok(env_val) = std::env::var(var_name)
1777        {
1778            *secret = env_val;
1779        }
1780    }
1781
1782    /// Check for `${VAR}` references where the environment variable is not set.
1783    ///
1784    /// Returns a list of human-readable warning strings. This method does not
1785    /// modify the config or fail -- it only reports potential issues.
1786    ///
1787    /// # Example
1788    ///
1789    /// ```
1790    /// use mcp_proxy::config::ProxyConfig;
1791    ///
1792    /// let toml = r#"
1793    /// [proxy]
1794    /// name = "test"
1795    /// [proxy.listen]
1796    ///
1797    /// [[backends]]
1798    /// name = "svc"
1799    /// transport = "stdio"
1800    /// command = "echo"
1801    /// bearer_token = "${UNSET_VAR}"
1802    /// "#;
1803    ///
1804    /// let config = ProxyConfig::parse(toml).unwrap();
1805    /// let warnings = config.check_env_vars();
1806    /// assert!(!warnings.is_empty());
1807    /// ```
1808    pub fn check_env_vars(&self) -> Vec<String> {
1809        fn is_unset_env_ref(value: &str) -> Option<&str> {
1810            let var_name = value.strip_prefix("${").and_then(|s| s.strip_suffix('}'))?;
1811            if std::env::var(var_name).is_err() {
1812                Some(var_name)
1813            } else {
1814                None
1815            }
1816        }
1817
1818        let mut warnings = Vec::new();
1819
1820        for backend in &self.backends {
1821            // backend.bearer_token
1822            if let Some(ref token) = backend.bearer_token
1823                && let Some(var) = is_unset_env_ref(token)
1824            {
1825                warnings.push(format!(
1826                    "backend '{}': bearer_token references unset env var '{}'",
1827                    backend.name, var
1828                ));
1829            }
1830            // backend.env values
1831            for (key, value) in &backend.env {
1832                if let Some(var) = is_unset_env_ref(value) {
1833                    warnings.push(format!(
1834                        "backend '{}': env.{} references unset env var '{}'",
1835                        backend.name, key, var
1836                    ));
1837                }
1838            }
1839        }
1840
1841        match &self.auth {
1842            Some(AuthConfig::Bearer {
1843                tokens,
1844                scoped_tokens,
1845            }) => {
1846                for (i, token) in tokens.iter().enumerate() {
1847                    if let Some(var) = is_unset_env_ref(token) {
1848                        warnings.push(format!(
1849                            "auth.bearer: tokens[{}] references unset env var '{}'",
1850                            i, var
1851                        ));
1852                    }
1853                }
1854                for (i, st) in scoped_tokens.iter().enumerate() {
1855                    if let Some(var) = is_unset_env_ref(&st.token) {
1856                        warnings.push(format!(
1857                            "auth.bearer: scoped_tokens[{}] references unset env var '{}'",
1858                            i, var
1859                        ));
1860                    }
1861                }
1862            }
1863            Some(AuthConfig::OAuth {
1864                client_secret: Some(secret),
1865                ..
1866            }) => {
1867                if let Some(var) = is_unset_env_ref(secret) {
1868                    warnings.push(format!(
1869                        "auth.oauth: client_secret references unset env var '{}'",
1870                        var
1871                    ));
1872                }
1873            }
1874            _ => {}
1875        }
1876
1877        warnings
1878    }
1879}
1880
1881#[cfg(test)]
1882mod tests {
1883    use super::*;
1884
1885    fn minimal_config() -> &'static str {
1886        r#"
1887        [proxy]
1888        name = "test"
1889        [proxy.listen]
1890
1891        [[backends]]
1892        name = "echo"
1893        transport = "stdio"
1894        command = "echo"
1895        "#
1896    }
1897
1898    #[test]
1899    fn test_parse_minimal_config() {
1900        let config = ProxyConfig::parse(minimal_config()).unwrap();
1901        assert_eq!(config.proxy.name, "test");
1902        assert_eq!(config.proxy.version, "0.1.0"); // default
1903        assert_eq!(config.proxy.separator, "/"); // default
1904        assert_eq!(config.proxy.listen.host, "127.0.0.1"); // default
1905        assert_eq!(config.proxy.listen.port, 8080); // default
1906        assert_eq!(config.proxy.shutdown_timeout_seconds, 30); // default
1907        assert!(!config.proxy.hot_reload); // default false
1908        assert_eq!(config.backends.len(), 1);
1909        assert_eq!(config.backends[0].name, "echo");
1910        assert!(config.auth.is_none());
1911        assert!(!config.observability.audit);
1912        assert!(!config.observability.metrics.enabled);
1913    }
1914
1915    #[test]
1916    fn test_parse_full_config() {
1917        let toml = r#"
1918        [proxy]
1919        name = "full-gw"
1920        version = "2.0.0"
1921        separator = "."
1922        shutdown_timeout_seconds = 60
1923        hot_reload = true
1924        instructions = "A test proxy"
1925        [proxy.listen]
1926        host = "0.0.0.0"
1927        port = 9090
1928
1929        [[backends]]
1930        name = "files"
1931        transport = "stdio"
1932        command = "file-server"
1933        args = ["--root", "/tmp"]
1934        expose_tools = ["read_file"]
1935
1936        [backends.env]
1937        LOG_LEVEL = "debug"
1938
1939        [backends.timeout]
1940        seconds = 30
1941
1942        [backends.concurrency]
1943        max_concurrent = 5
1944
1945        [backends.rate_limit]
1946        requests = 100
1947        period_seconds = 10
1948
1949        [backends.circuit_breaker]
1950        failure_rate_threshold = 0.5
1951        minimum_calls = 10
1952        wait_duration_seconds = 60
1953        permitted_calls_in_half_open = 2
1954
1955        [backends.cache]
1956        resource_ttl_seconds = 300
1957        tool_ttl_seconds = 60
1958        max_entries = 500
1959
1960        [[backends.aliases]]
1961        from = "read_file"
1962        to = "read"
1963
1964        [[backends]]
1965        name = "remote"
1966        transport = "http"
1967        url = "http://localhost:3000"
1968
1969        [observability]
1970        audit = true
1971        log_level = "debug"
1972        json_logs = true
1973
1974        [observability.metrics]
1975        enabled = true
1976
1977        [observability.tracing]
1978        enabled = true
1979        endpoint = "http://jaeger:4317"
1980        service_name = "test-gw"
1981
1982        [performance]
1983        coalesce_requests = true
1984
1985        [security]
1986        max_argument_size = 1048576
1987        "#;
1988
1989        let config = ProxyConfig::parse(toml).unwrap();
1990        assert_eq!(config.proxy.name, "full-gw");
1991        assert_eq!(config.proxy.version, "2.0.0");
1992        assert_eq!(config.proxy.separator, ".");
1993        assert_eq!(config.proxy.shutdown_timeout_seconds, 60);
1994        assert!(config.proxy.hot_reload);
1995        assert_eq!(config.proxy.instructions.as_deref(), Some("A test proxy"));
1996        assert_eq!(config.proxy.listen.host, "0.0.0.0");
1997        assert_eq!(config.proxy.listen.port, 9090);
1998
1999        assert_eq!(config.backends.len(), 2);
2000
2001        let files = &config.backends[0];
2002        assert_eq!(files.command.as_deref(), Some("file-server"));
2003        assert_eq!(files.args, vec!["--root", "/tmp"]);
2004        assert_eq!(files.expose_tools, vec!["read_file"]);
2005        assert_eq!(files.env.get("LOG_LEVEL").unwrap(), "debug");
2006        assert_eq!(files.timeout.as_ref().unwrap().seconds, 30);
2007        assert_eq!(files.concurrency.as_ref().unwrap().max_concurrent, 5);
2008        assert_eq!(files.rate_limit.as_ref().unwrap().requests, 100);
2009        assert_eq!(files.cache.as_ref().unwrap().resource_ttl_seconds, 300);
2010        assert_eq!(files.cache.as_ref().unwrap().tool_ttl_seconds, 60);
2011        assert_eq!(files.cache.as_ref().unwrap().max_entries, 500);
2012        assert_eq!(files.aliases.len(), 1);
2013        assert_eq!(files.aliases[0].from, "read_file");
2014        assert_eq!(files.aliases[0].to, "read");
2015
2016        let cb = files.circuit_breaker.as_ref().unwrap();
2017        assert_eq!(cb.failure_rate_threshold, 0.5);
2018        assert_eq!(cb.minimum_calls, 10);
2019        assert_eq!(cb.wait_duration_seconds, 60);
2020        assert_eq!(cb.permitted_calls_in_half_open, 2);
2021
2022        let remote = &config.backends[1];
2023        assert_eq!(remote.url.as_deref(), Some("http://localhost:3000"));
2024
2025        assert!(config.observability.audit);
2026        assert_eq!(config.observability.log_level, "debug");
2027        assert!(config.observability.json_logs);
2028        assert!(config.observability.metrics.enabled);
2029        assert!(config.observability.tracing.enabled);
2030        assert_eq!(config.observability.tracing.endpoint, "http://jaeger:4317");
2031
2032        assert!(config.performance.coalesce_requests);
2033        assert_eq!(config.security.max_argument_size, Some(1048576));
2034    }
2035
2036    #[test]
2037    fn test_parse_bearer_auth() {
2038        let toml = r#"
2039        [proxy]
2040        name = "auth-gw"
2041        [proxy.listen]
2042
2043        [[backends]]
2044        name = "echo"
2045        transport = "stdio"
2046        command = "echo"
2047
2048        [auth]
2049        type = "bearer"
2050        tokens = ["token-1", "token-2"]
2051        "#;
2052
2053        let config = ProxyConfig::parse(toml).unwrap();
2054        match &config.auth {
2055            Some(AuthConfig::Bearer { tokens, .. }) => {
2056                assert_eq!(tokens, &["token-1", "token-2"]);
2057            }
2058            other => panic!("expected Bearer auth, got: {:?}", other),
2059        }
2060    }
2061
2062    #[test]
2063    fn test_parse_jwt_auth_with_rbac() {
2064        let toml = r#"
2065        [proxy]
2066        name = "jwt-gw"
2067        [proxy.listen]
2068
2069        [[backends]]
2070        name = "echo"
2071        transport = "stdio"
2072        command = "echo"
2073
2074        [auth]
2075        type = "jwt"
2076        issuer = "https://auth.example.com"
2077        audience = "mcp-proxy"
2078        jwks_uri = "https://auth.example.com/.well-known/jwks.json"
2079
2080        [[auth.roles]]
2081        name = "reader"
2082        allow_tools = ["echo/read"]
2083
2084        [[auth.roles]]
2085        name = "admin"
2086
2087        [auth.role_mapping]
2088        claim = "scope"
2089        mapping = { "mcp:read" = "reader", "mcp:admin" = "admin" }
2090        "#;
2091
2092        let config = ProxyConfig::parse(toml).unwrap();
2093        match &config.auth {
2094            Some(AuthConfig::Jwt {
2095                issuer,
2096                audience,
2097                jwks_uri,
2098                roles,
2099                role_mapping,
2100            }) => {
2101                assert_eq!(issuer, "https://auth.example.com");
2102                assert_eq!(audience, "mcp-proxy");
2103                assert_eq!(jwks_uri, "https://auth.example.com/.well-known/jwks.json");
2104                assert_eq!(roles.len(), 2);
2105                assert_eq!(roles[0].name, "reader");
2106                assert_eq!(roles[0].allow_tools, vec!["echo/read"]);
2107                let mapping = role_mapping.as_ref().unwrap();
2108                assert_eq!(mapping.claim, "scope");
2109                assert_eq!(mapping.mapping.get("mcp:read").unwrap(), "reader");
2110            }
2111            other => panic!("expected Jwt auth, got: {:?}", other),
2112        }
2113    }
2114
2115    // ========================================================================
2116    // Validation errors
2117    // ========================================================================
2118
2119    #[test]
2120    fn test_reject_no_backends() {
2121        let toml = r#"
2122        [proxy]
2123        name = "empty"
2124        [proxy.listen]
2125        "#;
2126
2127        let err = ProxyConfig::parse(toml).unwrap_err();
2128        assert!(
2129            format!("{err}").contains("at least one backend"),
2130            "unexpected error: {err}"
2131        );
2132    }
2133
2134    #[test]
2135    fn test_reject_stdio_without_command() {
2136        let toml = r#"
2137        [proxy]
2138        name = "bad"
2139        [proxy.listen]
2140
2141        [[backends]]
2142        name = "broken"
2143        transport = "stdio"
2144        "#;
2145
2146        let err = ProxyConfig::parse(toml).unwrap_err();
2147        assert!(
2148            format!("{err}").contains("stdio transport requires 'command'"),
2149            "unexpected error: {err}"
2150        );
2151    }
2152
2153    #[test]
2154    fn test_reject_http_without_url() {
2155        let toml = r#"
2156        [proxy]
2157        name = "bad"
2158        [proxy.listen]
2159
2160        [[backends]]
2161        name = "broken"
2162        transport = "http"
2163        "#;
2164
2165        let err = ProxyConfig::parse(toml).unwrap_err();
2166        assert!(
2167            format!("{err}").contains("http transport requires 'url'"),
2168            "unexpected error: {err}"
2169        );
2170    }
2171
2172    #[test]
2173    fn test_reject_invalid_circuit_breaker_threshold() {
2174        let toml = r#"
2175        [proxy]
2176        name = "bad"
2177        [proxy.listen]
2178
2179        [[backends]]
2180        name = "svc"
2181        transport = "stdio"
2182        command = "echo"
2183
2184        [backends.circuit_breaker]
2185        failure_rate_threshold = 1.5
2186        "#;
2187
2188        let err = ProxyConfig::parse(toml).unwrap_err();
2189        assert!(
2190            format!("{err}").contains("failure_rate_threshold must be in (0.0, 1.0]"),
2191            "unexpected error: {err}"
2192        );
2193    }
2194
2195    #[test]
2196    fn test_reject_zero_rate_limit() {
2197        let toml = r#"
2198        [proxy]
2199        name = "bad"
2200        [proxy.listen]
2201
2202        [[backends]]
2203        name = "svc"
2204        transport = "stdio"
2205        command = "echo"
2206
2207        [backends.rate_limit]
2208        requests = 0
2209        "#;
2210
2211        let err = ProxyConfig::parse(toml).unwrap_err();
2212        assert!(
2213            format!("{err}").contains("rate_limit.requests must be > 0"),
2214            "unexpected error: {err}"
2215        );
2216    }
2217
2218    #[test]
2219    fn test_reject_zero_concurrency() {
2220        let toml = r#"
2221        [proxy]
2222        name = "bad"
2223        [proxy.listen]
2224
2225        [[backends]]
2226        name = "svc"
2227        transport = "stdio"
2228        command = "echo"
2229
2230        [backends.concurrency]
2231        max_concurrent = 0
2232        "#;
2233
2234        let err = ProxyConfig::parse(toml).unwrap_err();
2235        assert!(
2236            format!("{err}").contains("concurrency.max_concurrent must be > 0"),
2237            "unexpected error: {err}"
2238        );
2239    }
2240
2241    #[test]
2242    fn test_reject_expose_and_hide_tools() {
2243        let toml = r#"
2244        [proxy]
2245        name = "bad"
2246        [proxy.listen]
2247
2248        [[backends]]
2249        name = "svc"
2250        transport = "stdio"
2251        command = "echo"
2252        expose_tools = ["read"]
2253        hide_tools = ["write"]
2254        "#;
2255
2256        let err = ProxyConfig::parse(toml).unwrap_err();
2257        assert!(
2258            format!("{err}").contains("cannot specify both expose_tools and hide_tools"),
2259            "unexpected error: {err}"
2260        );
2261    }
2262
2263    #[test]
2264    fn test_reject_expose_and_hide_resources() {
2265        let toml = r#"
2266        [proxy]
2267        name = "bad"
2268        [proxy.listen]
2269
2270        [[backends]]
2271        name = "svc"
2272        transport = "stdio"
2273        command = "echo"
2274        expose_resources = ["file:///a"]
2275        hide_resources = ["file:///b"]
2276        "#;
2277
2278        let err = ProxyConfig::parse(toml).unwrap_err();
2279        assert!(
2280            format!("{err}").contains("cannot specify both expose_resources and hide_resources"),
2281            "unexpected error: {err}"
2282        );
2283    }
2284
2285    #[test]
2286    fn test_reject_expose_and_hide_prompts() {
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        expose_prompts = ["help"]
2297        hide_prompts = ["admin"]
2298        "#;
2299
2300        let err = ProxyConfig::parse(toml).unwrap_err();
2301        assert!(
2302            format!("{err}").contains("cannot specify both expose_prompts and hide_prompts"),
2303            "unexpected error: {err}"
2304        );
2305    }
2306
2307    // ========================================================================
2308    // Env var resolution
2309    // ========================================================================
2310
2311    #[test]
2312    fn test_resolve_env_vars() {
2313        // SAFETY: test runs single-threaded, no other threads reading this var
2314        unsafe { std::env::set_var("MCP_GW_TEST_TOKEN", "secret-123") };
2315
2316        let toml = r#"
2317        [proxy]
2318        name = "env-test"
2319        [proxy.listen]
2320
2321        [[backends]]
2322        name = "svc"
2323        transport = "stdio"
2324        command = "echo"
2325
2326        [backends.env]
2327        API_TOKEN = "${MCP_GW_TEST_TOKEN}"
2328        STATIC_VAL = "unchanged"
2329        "#;
2330
2331        let mut config = ProxyConfig::parse(toml).unwrap();
2332        config.resolve_env_vars();
2333
2334        assert_eq!(
2335            config.backends[0].env.get("API_TOKEN").unwrap(),
2336            "secret-123"
2337        );
2338        assert_eq!(
2339            config.backends[0].env.get("STATIC_VAL").unwrap(),
2340            "unchanged"
2341        );
2342
2343        // SAFETY: same as above
2344        unsafe { std::env::remove_var("MCP_GW_TEST_TOKEN") };
2345    }
2346
2347    #[test]
2348    fn test_parse_bearer_token_and_forward_auth() {
2349        let toml = r#"
2350        [proxy]
2351        name = "token-gw"
2352        [proxy.listen]
2353
2354        [[backends]]
2355        name = "github"
2356        transport = "http"
2357        url = "http://localhost:3000"
2358        bearer_token = "ghp_abc123"
2359        forward_auth = true
2360
2361        [[backends]]
2362        name = "db"
2363        transport = "http"
2364        url = "http://localhost:5432"
2365        "#;
2366
2367        let config = ProxyConfig::parse(toml).unwrap();
2368        assert_eq!(
2369            config.backends[0].bearer_token.as_deref(),
2370            Some("ghp_abc123")
2371        );
2372        assert!(config.backends[0].forward_auth);
2373        assert!(config.backends[1].bearer_token.is_none());
2374        assert!(!config.backends[1].forward_auth);
2375    }
2376
2377    #[test]
2378    fn test_resolve_bearer_token_env_var() {
2379        unsafe { std::env::set_var("MCP_GW_TEST_BEARER", "resolved-token") };
2380
2381        let toml = r#"
2382        [proxy]
2383        name = "env-token"
2384        [proxy.listen]
2385
2386        [[backends]]
2387        name = "api"
2388        transport = "http"
2389        url = "http://localhost:3000"
2390        bearer_token = "${MCP_GW_TEST_BEARER}"
2391        "#;
2392
2393        let mut config = ProxyConfig::parse(toml).unwrap();
2394        config.resolve_env_vars();
2395
2396        assert_eq!(
2397            config.backends[0].bearer_token.as_deref(),
2398            Some("resolved-token")
2399        );
2400
2401        unsafe { std::env::remove_var("MCP_GW_TEST_BEARER") };
2402    }
2403
2404    #[test]
2405    fn test_parse_outlier_detection() {
2406        let toml = r#"
2407        [proxy]
2408        name = "od-gw"
2409        [proxy.listen]
2410
2411        [[backends]]
2412        name = "flaky"
2413        transport = "http"
2414        url = "http://localhost:8080"
2415
2416        [backends.outlier_detection]
2417        consecutive_errors = 3
2418        interval_seconds = 5
2419        base_ejection_seconds = 60
2420        max_ejection_percent = 25
2421        "#;
2422
2423        let config = ProxyConfig::parse(toml).unwrap();
2424        let od = config.backends[0]
2425            .outlier_detection
2426            .as_ref()
2427            .expect("should have outlier_detection");
2428        assert_eq!(od.consecutive_errors, 3);
2429        assert_eq!(od.interval_seconds, 5);
2430        assert_eq!(od.base_ejection_seconds, 60);
2431        assert_eq!(od.max_ejection_percent, 25);
2432    }
2433
2434    #[test]
2435    fn test_parse_outlier_detection_defaults() {
2436        let toml = r#"
2437        [proxy]
2438        name = "od-gw"
2439        [proxy.listen]
2440
2441        [[backends]]
2442        name = "flaky"
2443        transport = "http"
2444        url = "http://localhost:8080"
2445
2446        [backends.outlier_detection]
2447        "#;
2448
2449        let config = ProxyConfig::parse(toml).unwrap();
2450        let od = config.backends[0]
2451            .outlier_detection
2452            .as_ref()
2453            .expect("should have outlier_detection");
2454        assert_eq!(od.consecutive_errors, 5);
2455        assert_eq!(od.interval_seconds, 10);
2456        assert_eq!(od.base_ejection_seconds, 30);
2457        assert_eq!(od.max_ejection_percent, 50);
2458    }
2459
2460    #[test]
2461    fn test_parse_mirror_config() {
2462        let toml = r#"
2463        [proxy]
2464        name = "mirror-gw"
2465        [proxy.listen]
2466
2467        [[backends]]
2468        name = "api"
2469        transport = "http"
2470        url = "http://localhost:8080"
2471
2472        [[backends]]
2473        name = "api-v2"
2474        transport = "http"
2475        url = "http://localhost:8081"
2476        mirror_of = "api"
2477        mirror_percent = 10
2478        "#;
2479
2480        let config = ProxyConfig::parse(toml).unwrap();
2481        assert!(config.backends[0].mirror_of.is_none());
2482        assert_eq!(config.backends[1].mirror_of.as_deref(), Some("api"));
2483        assert_eq!(config.backends[1].mirror_percent, 10);
2484    }
2485
2486    #[test]
2487    fn test_mirror_percent_defaults_to_100() {
2488        let toml = r#"
2489        [proxy]
2490        name = "mirror-gw"
2491        [proxy.listen]
2492
2493        [[backends]]
2494        name = "api"
2495        transport = "http"
2496        url = "http://localhost:8080"
2497
2498        [[backends]]
2499        name = "api-v2"
2500        transport = "http"
2501        url = "http://localhost:8081"
2502        mirror_of = "api"
2503        "#;
2504
2505        let config = ProxyConfig::parse(toml).unwrap();
2506        assert_eq!(config.backends[1].mirror_percent, 100);
2507    }
2508
2509    #[test]
2510    fn test_reject_mirror_unknown_backend() {
2511        let toml = r#"
2512        [proxy]
2513        name = "bad"
2514        [proxy.listen]
2515
2516        [[backends]]
2517        name = "api-v2"
2518        transport = "http"
2519        url = "http://localhost:8081"
2520        mirror_of = "nonexistent"
2521        "#;
2522
2523        let err = ProxyConfig::parse(toml).unwrap_err();
2524        assert!(
2525            format!("{err}").contains("mirror_of references unknown backend"),
2526            "unexpected error: {err}"
2527        );
2528    }
2529
2530    #[test]
2531    fn test_reject_mirror_self() {
2532        let toml = r#"
2533        [proxy]
2534        name = "bad"
2535        [proxy.listen]
2536
2537        [[backends]]
2538        name = "api"
2539        transport = "http"
2540        url = "http://localhost:8080"
2541        mirror_of = "api"
2542        "#;
2543
2544        let err = ProxyConfig::parse(toml).unwrap_err();
2545        assert!(
2546            format!("{err}").contains("mirror_of cannot reference itself"),
2547            "unexpected error: {err}"
2548        );
2549    }
2550
2551    #[test]
2552    fn test_parse_hedging_config() {
2553        let toml = r#"
2554        [proxy]
2555        name = "hedge-gw"
2556        [proxy.listen]
2557
2558        [[backends]]
2559        name = "api"
2560        transport = "http"
2561        url = "http://localhost:8080"
2562
2563        [backends.hedging]
2564        delay_ms = 150
2565        max_hedges = 2
2566        "#;
2567
2568        let config = ProxyConfig::parse(toml).unwrap();
2569        let hedge = config.backends[0]
2570            .hedging
2571            .as_ref()
2572            .expect("should have hedging");
2573        assert_eq!(hedge.delay_ms, 150);
2574        assert_eq!(hedge.max_hedges, 2);
2575    }
2576
2577    #[test]
2578    fn test_parse_hedging_defaults() {
2579        let toml = r#"
2580        [proxy]
2581        name = "hedge-gw"
2582        [proxy.listen]
2583
2584        [[backends]]
2585        name = "api"
2586        transport = "http"
2587        url = "http://localhost:8080"
2588
2589        [backends.hedging]
2590        "#;
2591
2592        let config = ProxyConfig::parse(toml).unwrap();
2593        let hedge = config.backends[0]
2594            .hedging
2595            .as_ref()
2596            .expect("should have hedging");
2597        assert_eq!(hedge.delay_ms, 200);
2598        assert_eq!(hedge.max_hedges, 1);
2599    }
2600
2601    // ========================================================================
2602    // Capability filter building
2603    // ========================================================================
2604
2605    #[test]
2606    fn test_build_filter_allowlist() {
2607        let toml = r#"
2608        [proxy]
2609        name = "filter"
2610        [proxy.listen]
2611
2612        [[backends]]
2613        name = "svc"
2614        transport = "stdio"
2615        command = "echo"
2616        expose_tools = ["read", "list"]
2617        "#;
2618
2619        let config = ProxyConfig::parse(toml).unwrap();
2620        let filter = config.backends[0]
2621            .build_filter(&config.proxy.separator)
2622            .unwrap()
2623            .expect("should have filter");
2624        assert_eq!(filter.namespace, "svc/");
2625        assert!(filter.tool_filter.allows("read"));
2626        assert!(filter.tool_filter.allows("list"));
2627        assert!(!filter.tool_filter.allows("delete"));
2628    }
2629
2630    #[test]
2631    fn test_build_filter_denylist() {
2632        let toml = r#"
2633        [proxy]
2634        name = "filter"
2635        [proxy.listen]
2636
2637        [[backends]]
2638        name = "svc"
2639        transport = "stdio"
2640        command = "echo"
2641        hide_tools = ["delete", "write"]
2642        "#;
2643
2644        let config = ProxyConfig::parse(toml).unwrap();
2645        let filter = config.backends[0]
2646            .build_filter(&config.proxy.separator)
2647            .unwrap()
2648            .expect("should have filter");
2649        assert!(filter.tool_filter.allows("read"));
2650        assert!(!filter.tool_filter.allows("delete"));
2651        assert!(!filter.tool_filter.allows("write"));
2652    }
2653
2654    #[test]
2655    fn test_parse_inject_args() {
2656        let toml = r#"
2657        [proxy]
2658        name = "inject-gw"
2659        [proxy.listen]
2660
2661        [[backends]]
2662        name = "db"
2663        transport = "http"
2664        url = "http://localhost:8080"
2665
2666        [backends.default_args]
2667        timeout = 30
2668
2669        [[backends.inject_args]]
2670        tool = "query"
2671        args = { read_only = true, max_rows = 1000 }
2672
2673        [[backends.inject_args]]
2674        tool = "dangerous_op"
2675        args = { dry_run = true }
2676        overwrite = true
2677        "#;
2678
2679        let config = ProxyConfig::parse(toml).unwrap();
2680        let backend = &config.backends[0];
2681
2682        assert_eq!(backend.default_args.len(), 1);
2683        assert_eq!(backend.default_args["timeout"], 30);
2684
2685        assert_eq!(backend.inject_args.len(), 2);
2686        assert_eq!(backend.inject_args[0].tool, "query");
2687        assert_eq!(backend.inject_args[0].args["read_only"], true);
2688        assert_eq!(backend.inject_args[0].args["max_rows"], 1000);
2689        assert!(!backend.inject_args[0].overwrite);
2690
2691        assert_eq!(backend.inject_args[1].tool, "dangerous_op");
2692        assert_eq!(backend.inject_args[1].args["dry_run"], true);
2693        assert!(backend.inject_args[1].overwrite);
2694    }
2695
2696    #[test]
2697    fn test_parse_inject_args_defaults_to_empty() {
2698        let config = ProxyConfig::parse(minimal_config()).unwrap();
2699        assert!(config.backends[0].default_args.is_empty());
2700        assert!(config.backends[0].inject_args.is_empty());
2701    }
2702
2703    #[test]
2704    fn test_build_filter_none_when_no_filtering() {
2705        let config = ProxyConfig::parse(minimal_config()).unwrap();
2706        assert!(
2707            config.backends[0]
2708                .build_filter(&config.proxy.separator)
2709                .unwrap()
2710                .is_none()
2711        );
2712    }
2713
2714    #[test]
2715    fn test_validate_rejects_duplicate_backend_names() {
2716        let toml = r#"
2717        [proxy]
2718        name = "test"
2719        [proxy.listen]
2720
2721        [[backends]]
2722        name = "echo"
2723        transport = "stdio"
2724        command = "echo"
2725
2726        [[backends]]
2727        name = "echo"
2728        transport = "stdio"
2729        command = "cat"
2730        "#;
2731        let err = ProxyConfig::parse(toml).unwrap_err();
2732        assert!(
2733            err.to_string().contains("duplicate backend name"),
2734            "expected duplicate error, got: {}",
2735            err
2736        );
2737    }
2738
2739    #[test]
2740    fn test_validate_global_rate_limit_zero_requests() {
2741        let toml = r#"
2742        [proxy]
2743        name = "test"
2744        [proxy.listen]
2745        [proxy.rate_limit]
2746        requests = 0
2747
2748        [[backends]]
2749        name = "echo"
2750        transport = "stdio"
2751        command = "echo"
2752        "#;
2753        let err = ProxyConfig::parse(toml).unwrap_err();
2754        assert!(err.to_string().contains("requests must be > 0"));
2755    }
2756
2757    #[test]
2758    fn test_parse_global_rate_limit() {
2759        let toml = r#"
2760        [proxy]
2761        name = "test"
2762        [proxy.listen]
2763        [proxy.rate_limit]
2764        requests = 500
2765        period_seconds = 1
2766
2767        [[backends]]
2768        name = "echo"
2769        transport = "stdio"
2770        command = "echo"
2771        "#;
2772        let config = ProxyConfig::parse(toml).unwrap();
2773        let rl = config.proxy.rate_limit.unwrap();
2774        assert_eq!(rl.requests, 500);
2775        assert_eq!(rl.period_seconds, 1);
2776    }
2777
2778    #[test]
2779    fn test_name_filter_glob_wildcard() {
2780        let filter = NameFilter::allow_list(["*_file".to_string()]).unwrap();
2781        assert!(filter.allows("read_file"));
2782        assert!(filter.allows("write_file"));
2783        assert!(!filter.allows("query"));
2784        assert!(!filter.allows("file_read"));
2785    }
2786
2787    #[test]
2788    fn test_name_filter_glob_prefix() {
2789        let filter = NameFilter::allow_list(["list_*".to_string()]).unwrap();
2790        assert!(filter.allows("list_files"));
2791        assert!(filter.allows("list_users"));
2792        assert!(!filter.allows("get_files"));
2793    }
2794
2795    #[test]
2796    fn test_name_filter_glob_question_mark() {
2797        let filter = NameFilter::allow_list(["get_?".to_string()]).unwrap();
2798        assert!(filter.allows("get_a"));
2799        assert!(filter.allows("get_1"));
2800        assert!(!filter.allows("get_ab"));
2801        assert!(!filter.allows("get_"));
2802    }
2803
2804    #[test]
2805    fn test_name_filter_glob_deny_list() {
2806        let filter = NameFilter::deny_list(["*_delete*".to_string()]).unwrap();
2807        assert!(filter.allows("read_file"));
2808        assert!(filter.allows("create_issue"));
2809        assert!(!filter.allows("force_delete_all"));
2810        assert!(!filter.allows("soft_delete"));
2811    }
2812
2813    #[test]
2814    fn test_name_filter_glob_exact_match_still_works() {
2815        let filter = NameFilter::allow_list(["read_file".to_string()]).unwrap();
2816        assert!(filter.allows("read_file"));
2817        assert!(!filter.allows("write_file"));
2818    }
2819
2820    #[test]
2821    fn test_name_filter_glob_multiple_patterns() {
2822        let filter = NameFilter::allow_list(["read_*".to_string(), "list_*".to_string()]).unwrap();
2823        assert!(filter.allows("read_file"));
2824        assert!(filter.allows("list_users"));
2825        assert!(!filter.allows("delete_file"));
2826    }
2827
2828    #[test]
2829    fn test_name_filter_regex_allow_list() {
2830        let filter =
2831            NameFilter::allow_list(["re:^list_.*$".to_string(), "re:^get_\\w+$".to_string()])
2832                .unwrap();
2833        assert!(filter.allows("list_files"));
2834        assert!(filter.allows("list_users"));
2835        assert!(filter.allows("get_item"));
2836        assert!(!filter.allows("delete_file"));
2837        assert!(!filter.allows("create_issue"));
2838    }
2839
2840    #[test]
2841    fn test_name_filter_regex_deny_list() {
2842        let filter = NameFilter::deny_list(["re:^delete_".to_string()]).unwrap();
2843        assert!(filter.allows("read_file"));
2844        assert!(filter.allows("list_users"));
2845        assert!(!filter.allows("delete_file"));
2846        assert!(!filter.allows("delete_all"));
2847    }
2848
2849    #[test]
2850    fn test_name_filter_mixed_glob_and_regex() {
2851        let filter =
2852            NameFilter::allow_list(["read_*".to_string(), "re:^list_\\w+$".to_string()]).unwrap();
2853        assert!(filter.allows("read_file"));
2854        assert!(filter.allows("read_dir"));
2855        assert!(filter.allows("list_users"));
2856        assert!(!filter.allows("delete_file"));
2857    }
2858
2859    #[test]
2860    fn test_name_filter_regex_invalid_pattern() {
2861        let result = NameFilter::allow_list(["re:[invalid".to_string()]);
2862        assert!(result.is_err(), "invalid regex should produce an error");
2863    }
2864
2865    #[test]
2866    fn test_name_filter_regex_partial_match() {
2867        // Regex without anchors matches substrings
2868        let filter = NameFilter::allow_list(["re:list".to_string()]).unwrap();
2869        assert!(filter.allows("list_files"));
2870        assert!(filter.allows("my_list_tool"));
2871        assert!(!filter.allows("read_file"));
2872    }
2873
2874    #[test]
2875    fn test_config_parse_regex_filter() {
2876        let toml = r#"
2877        [proxy]
2878        name = "regex-gw"
2879        [proxy.listen]
2880
2881        [[backends]]
2882        name = "svc"
2883        transport = "stdio"
2884        command = "echo"
2885        expose_tools = ["*_issue", "re:^list_.*$"]
2886        "#;
2887
2888        let config = ProxyConfig::parse(toml).unwrap();
2889        let filter = config.backends[0]
2890            .build_filter(&config.proxy.separator)
2891            .unwrap()
2892            .expect("should have filter");
2893        assert!(filter.tool_filter.allows("create_issue"));
2894        assert!(filter.tool_filter.allows("list_files"));
2895        assert!(filter.tool_filter.allows("list_users"));
2896        assert!(!filter.tool_filter.allows("delete_file"));
2897    }
2898
2899    #[test]
2900    fn test_parse_param_overrides() {
2901        let toml = r#"
2902        [proxy]
2903        name = "override-gw"
2904        [proxy.listen]
2905
2906        [[backends]]
2907        name = "fs"
2908        transport = "http"
2909        url = "http://localhost:8080"
2910
2911        [[backends.param_overrides]]
2912        tool = "list_directory"
2913        hide = ["path"]
2914        rename = { recursive = "deep_search" }
2915
2916        [backends.param_overrides.defaults]
2917        path = "/home/docs"
2918        "#;
2919
2920        let config = ProxyConfig::parse(toml).unwrap();
2921        assert_eq!(config.backends[0].param_overrides.len(), 1);
2922        let po = &config.backends[0].param_overrides[0];
2923        assert_eq!(po.tool, "list_directory");
2924        assert_eq!(po.hide, vec!["path"]);
2925        assert_eq!(po.defaults.get("path").unwrap(), "/home/docs");
2926        assert_eq!(po.rename.get("recursive").unwrap(), "deep_search");
2927    }
2928
2929    #[test]
2930    fn test_reject_param_override_empty_tool() {
2931        let toml = r#"
2932        [proxy]
2933        name = "bad"
2934        [proxy.listen]
2935
2936        [[backends]]
2937        name = "fs"
2938        transport = "http"
2939        url = "http://localhost:8080"
2940
2941        [[backends.param_overrides]]
2942        tool = ""
2943        hide = ["path"]
2944        "#;
2945
2946        let err = ProxyConfig::parse(toml).unwrap_err();
2947        assert!(
2948            format!("{err}").contains("tool must not be empty"),
2949            "unexpected error: {err}"
2950        );
2951    }
2952
2953    #[test]
2954    fn test_reject_param_override_duplicate_tool() {
2955        let toml = r#"
2956        [proxy]
2957        name = "bad"
2958        [proxy.listen]
2959
2960        [[backends]]
2961        name = "fs"
2962        transport = "http"
2963        url = "http://localhost:8080"
2964
2965        [[backends.param_overrides]]
2966        tool = "list_directory"
2967        hide = ["path"]
2968
2969        [[backends.param_overrides]]
2970        tool = "list_directory"
2971        hide = ["pattern"]
2972        "#;
2973
2974        let err = ProxyConfig::parse(toml).unwrap_err();
2975        assert!(
2976            format!("{err}").contains("duplicate param_overrides"),
2977            "unexpected error: {err}"
2978        );
2979    }
2980
2981    #[test]
2982    fn test_reject_param_override_hide_and_rename_same_param() {
2983        let toml = r#"
2984        [proxy]
2985        name = "bad"
2986        [proxy.listen]
2987
2988        [[backends]]
2989        name = "fs"
2990        transport = "http"
2991        url = "http://localhost:8080"
2992
2993        [[backends.param_overrides]]
2994        tool = "list_directory"
2995        hide = ["path"]
2996        rename = { path = "dir" }
2997        "#;
2998
2999        let err = ProxyConfig::parse(toml).unwrap_err();
3000        assert!(
3001            format!("{err}").contains("cannot be both hidden and renamed"),
3002            "unexpected error: {err}"
3003        );
3004    }
3005
3006    #[test]
3007    fn test_reject_param_override_duplicate_rename_target() {
3008        let toml = r#"
3009        [proxy]
3010        name = "bad"
3011        [proxy.listen]
3012
3013        [[backends]]
3014        name = "fs"
3015        transport = "http"
3016        url = "http://localhost:8080"
3017
3018        [[backends.param_overrides]]
3019        tool = "list_directory"
3020        rename = { path = "location", dir = "location" }
3021        "#;
3022
3023        let err = ProxyConfig::parse(toml).unwrap_err();
3024        assert!(
3025            format!("{err}").contains("duplicate rename target"),
3026            "unexpected error: {err}"
3027        );
3028    }
3029
3030    #[test]
3031    fn test_cache_backend_defaults_to_memory() {
3032        let config = ProxyConfig::parse(minimal_config()).unwrap();
3033        assert_eq!(config.cache.backend, "memory");
3034        assert!(config.cache.url.is_none());
3035    }
3036
3037    #[test]
3038    fn test_cache_backend_redis_requires_url() {
3039        let toml = r#"
3040        [proxy]
3041        name = "test"
3042        [proxy.listen]
3043        [cache]
3044        backend = "redis"
3045
3046        [[backends]]
3047        name = "echo"
3048        transport = "stdio"
3049        command = "echo"
3050        "#;
3051        let err = ProxyConfig::parse(toml).unwrap_err();
3052        assert!(err.to_string().contains("cache.url is required"));
3053    }
3054
3055    #[test]
3056    fn test_cache_backend_unknown_rejected() {
3057        let toml = r#"
3058        [proxy]
3059        name = "test"
3060        [proxy.listen]
3061        [cache]
3062        backend = "memcached"
3063
3064        [[backends]]
3065        name = "echo"
3066        transport = "stdio"
3067        command = "echo"
3068        "#;
3069        let err = ProxyConfig::parse(toml).unwrap_err();
3070        assert!(err.to_string().contains("unknown cache backend"));
3071    }
3072
3073    #[test]
3074    fn test_cache_backend_redis_with_url() {
3075        let toml = r#"
3076        [proxy]
3077        name = "test"
3078        [proxy.listen]
3079        [cache]
3080        backend = "redis"
3081        url = "redis://localhost:6379"
3082        prefix = "myapp:"
3083
3084        [[backends]]
3085        name = "echo"
3086        transport = "stdio"
3087        command = "echo"
3088        "#;
3089        let config = ProxyConfig::parse(toml).unwrap();
3090        assert_eq!(config.cache.backend, "redis");
3091        assert_eq!(config.cache.url.as_deref(), Some("redis://localhost:6379"));
3092        assert_eq!(config.cache.prefix, "myapp:");
3093    }
3094
3095    #[test]
3096    fn test_parse_bearer_scoped_tokens() {
3097        let toml = r#"
3098        [proxy]
3099        name = "scoped"
3100        [proxy.listen]
3101
3102        [[backends]]
3103        name = "echo"
3104        transport = "stdio"
3105        command = "echo"
3106
3107        [auth]
3108        type = "bearer"
3109
3110        [[auth.scoped_tokens]]
3111        token = "frontend-token"
3112        allow_tools = ["echo/read_file"]
3113
3114        [[auth.scoped_tokens]]
3115        token = "admin-token"
3116        "#;
3117
3118        let config = ProxyConfig::parse(toml).unwrap();
3119        match &config.auth {
3120            Some(AuthConfig::Bearer {
3121                tokens,
3122                scoped_tokens,
3123            }) => {
3124                assert!(tokens.is_empty());
3125                assert_eq!(scoped_tokens.len(), 2);
3126                assert_eq!(scoped_tokens[0].token, "frontend-token");
3127                assert_eq!(scoped_tokens[0].allow_tools, vec!["echo/read_file"]);
3128                assert!(scoped_tokens[1].allow_tools.is_empty());
3129            }
3130            other => panic!("expected Bearer auth, got: {other:?}"),
3131        }
3132    }
3133
3134    #[test]
3135    fn test_parse_bearer_mixed_tokens() {
3136        let toml = r#"
3137        [proxy]
3138        name = "mixed"
3139        [proxy.listen]
3140
3141        [[backends]]
3142        name = "echo"
3143        transport = "stdio"
3144        command = "echo"
3145
3146        [auth]
3147        type = "bearer"
3148        tokens = ["simple-token"]
3149
3150        [[auth.scoped_tokens]]
3151        token = "scoped-token"
3152        deny_tools = ["echo/delete"]
3153        "#;
3154
3155        let config = ProxyConfig::parse(toml).unwrap();
3156        match &config.auth {
3157            Some(AuthConfig::Bearer {
3158                tokens,
3159                scoped_tokens,
3160            }) => {
3161                assert_eq!(tokens, &["simple-token"]);
3162                assert_eq!(scoped_tokens.len(), 1);
3163                assert_eq!(scoped_tokens[0].deny_tools, vec!["echo/delete"]);
3164            }
3165            other => panic!("expected Bearer auth, got: {other:?}"),
3166        }
3167    }
3168
3169    #[test]
3170    fn test_bearer_empty_tokens_rejected() {
3171        let toml = r#"
3172        [proxy]
3173        name = "empty"
3174        [proxy.listen]
3175
3176        [[backends]]
3177        name = "echo"
3178        transport = "stdio"
3179        command = "echo"
3180
3181        [auth]
3182        type = "bearer"
3183        "#;
3184
3185        let err = ProxyConfig::parse(toml).unwrap_err();
3186        assert!(
3187            err.to_string().contains("at least one token"),
3188            "unexpected error: {err}"
3189        );
3190    }
3191
3192    #[test]
3193    fn test_bearer_duplicate_across_lists_rejected() {
3194        let toml = r#"
3195        [proxy]
3196        name = "dup"
3197        [proxy.listen]
3198
3199        [[backends]]
3200        name = "echo"
3201        transport = "stdio"
3202        command = "echo"
3203
3204        [auth]
3205        type = "bearer"
3206        tokens = ["shared-token"]
3207
3208        [[auth.scoped_tokens]]
3209        token = "shared-token"
3210        allow_tools = ["echo/read"]
3211        "#;
3212
3213        let err = ProxyConfig::parse(toml).unwrap_err();
3214        assert!(
3215            err.to_string().contains("duplicate bearer token"),
3216            "unexpected error: {err}"
3217        );
3218    }
3219
3220    #[test]
3221    fn test_bearer_allow_and_deny_rejected() {
3222        let toml = r#"
3223        [proxy]
3224        name = "both"
3225        [proxy.listen]
3226
3227        [[backends]]
3228        name = "echo"
3229        transport = "stdio"
3230        command = "echo"
3231
3232        [auth]
3233        type = "bearer"
3234
3235        [[auth.scoped_tokens]]
3236        token = "conflict"
3237        allow_tools = ["echo/read"]
3238        deny_tools = ["echo/write"]
3239        "#;
3240
3241        let err = ProxyConfig::parse(toml).unwrap_err();
3242        assert!(
3243            err.to_string().contains("cannot specify both"),
3244            "unexpected error: {err}"
3245        );
3246    }
3247
3248    #[test]
3249    fn test_parse_websocket_transport() {
3250        let toml = r#"
3251        [proxy]
3252        name = "ws-proxy"
3253        [proxy.listen]
3254
3255        [[backends]]
3256        name = "ws-backend"
3257        transport = "websocket"
3258        url = "ws://localhost:9090/ws"
3259        "#;
3260
3261        let config = ProxyConfig::parse(toml).unwrap();
3262        assert!(matches!(
3263            config.backends[0].transport,
3264            TransportType::Websocket
3265        ));
3266        assert_eq!(
3267            config.backends[0].url.as_deref(),
3268            Some("ws://localhost:9090/ws")
3269        );
3270    }
3271
3272    #[test]
3273    fn test_websocket_transport_requires_url() {
3274        let toml = r#"
3275        [proxy]
3276        name = "ws-proxy"
3277        [proxy.listen]
3278
3279        [[backends]]
3280        name = "ws-backend"
3281        transport = "websocket"
3282        "#;
3283
3284        let err = ProxyConfig::parse(toml).unwrap_err();
3285        assert!(
3286            err.to_string()
3287                .contains("websocket transport requires 'url'"),
3288            "unexpected error: {err}"
3289        );
3290    }
3291
3292    #[test]
3293    fn test_websocket_with_bearer_token() {
3294        let toml = r#"
3295        [proxy]
3296        name = "ws-proxy"
3297        [proxy.listen]
3298
3299        [[backends]]
3300        name = "ws-backend"
3301        transport = "websocket"
3302        url = "wss://secure.example.com/mcp"
3303        bearer_token = "my-secret"
3304        "#;
3305
3306        let config = ProxyConfig::parse(toml).unwrap();
3307        assert_eq!(
3308            config.backends[0].bearer_token.as_deref(),
3309            Some("my-secret")
3310        );
3311    }
3312
3313    #[test]
3314    fn test_tool_discovery_defaults_false() {
3315        let config = ProxyConfig::parse(minimal_config()).unwrap();
3316        assert!(!config.proxy.tool_discovery);
3317    }
3318
3319    #[test]
3320    fn test_tool_discovery_enabled() {
3321        let toml = r#"
3322        [proxy]
3323        name = "discovery"
3324        tool_discovery = true
3325        [proxy.listen]
3326
3327        [[backends]]
3328        name = "echo"
3329        transport = "stdio"
3330        command = "echo"
3331        "#;
3332
3333        let config = ProxyConfig::parse(toml).unwrap();
3334        assert!(config.proxy.tool_discovery);
3335    }
3336
3337    #[test]
3338    fn test_parse_oauth_config() {
3339        let toml = r#"
3340        [proxy]
3341        name = "oauth-proxy"
3342        [proxy.listen]
3343
3344        [[backends]]
3345        name = "echo"
3346        transport = "stdio"
3347        command = "echo"
3348
3349        [auth]
3350        type = "oauth"
3351        issuer = "https://accounts.google.com"
3352        audience = "mcp-proxy"
3353        "#;
3354
3355        let config = ProxyConfig::parse(toml).unwrap();
3356        match &config.auth {
3357            Some(AuthConfig::OAuth {
3358                issuer,
3359                audience,
3360                token_validation,
3361                ..
3362            }) => {
3363                assert_eq!(issuer, "https://accounts.google.com");
3364                assert_eq!(audience, "mcp-proxy");
3365                assert_eq!(token_validation, &TokenValidationStrategy::Jwt);
3366            }
3367            other => panic!("expected OAuth auth, got: {other:?}"),
3368        }
3369    }
3370
3371    #[test]
3372    fn test_parse_oauth_with_introspection() {
3373        let toml = r#"
3374        [proxy]
3375        name = "oauth-proxy"
3376        [proxy.listen]
3377
3378        [[backends]]
3379        name = "echo"
3380        transport = "stdio"
3381        command = "echo"
3382
3383        [auth]
3384        type = "oauth"
3385        issuer = "https://auth.example.com"
3386        audience = "mcp-proxy"
3387        client_id = "my-client"
3388        client_secret = "my-secret"
3389        token_validation = "introspection"
3390        "#;
3391
3392        let config = ProxyConfig::parse(toml).unwrap();
3393        match &config.auth {
3394            Some(AuthConfig::OAuth {
3395                token_validation,
3396                client_id,
3397                client_secret,
3398                ..
3399            }) => {
3400                assert_eq!(token_validation, &TokenValidationStrategy::Introspection);
3401                assert_eq!(client_id.as_deref(), Some("my-client"));
3402                assert_eq!(client_secret.as_deref(), Some("my-secret"));
3403            }
3404            other => panic!("expected OAuth auth, got: {other:?}"),
3405        }
3406    }
3407
3408    #[test]
3409    fn test_oauth_introspection_requires_credentials() {
3410        let toml = r#"
3411        [proxy]
3412        name = "oauth-proxy"
3413        [proxy.listen]
3414
3415        [[backends]]
3416        name = "echo"
3417        transport = "stdio"
3418        command = "echo"
3419
3420        [auth]
3421        type = "oauth"
3422        issuer = "https://auth.example.com"
3423        audience = "mcp-proxy"
3424        token_validation = "introspection"
3425        "#;
3426
3427        let err = ProxyConfig::parse(toml).unwrap_err();
3428        assert!(
3429            err.to_string().contains("client_id"),
3430            "unexpected error: {err}"
3431        );
3432    }
3433
3434    #[test]
3435    fn test_parse_oauth_with_overrides() {
3436        let toml = r#"
3437        [proxy]
3438        name = "oauth-proxy"
3439        [proxy.listen]
3440
3441        [[backends]]
3442        name = "echo"
3443        transport = "stdio"
3444        command = "echo"
3445
3446        [auth]
3447        type = "oauth"
3448        issuer = "https://auth.example.com"
3449        audience = "mcp-proxy"
3450        jwks_uri = "https://auth.example.com/custom/jwks"
3451        introspection_endpoint = "https://auth.example.com/custom/introspect"
3452        client_id = "my-client"
3453        client_secret = "my-secret"
3454        token_validation = "both"
3455        required_scopes = ["read", "write"]
3456        "#;
3457
3458        let config = ProxyConfig::parse(toml).unwrap();
3459        match &config.auth {
3460            Some(AuthConfig::OAuth {
3461                jwks_uri,
3462                introspection_endpoint,
3463                token_validation,
3464                required_scopes,
3465                ..
3466            }) => {
3467                assert_eq!(
3468                    jwks_uri.as_deref(),
3469                    Some("https://auth.example.com/custom/jwks")
3470                );
3471                assert_eq!(
3472                    introspection_endpoint.as_deref(),
3473                    Some("https://auth.example.com/custom/introspect")
3474                );
3475                assert_eq!(token_validation, &TokenValidationStrategy::Both);
3476                assert_eq!(required_scopes, &["read", "write"]);
3477            }
3478            other => panic!("expected OAuth auth, got: {other:?}"),
3479        }
3480    }
3481
3482    #[test]
3483    fn test_check_env_vars_warns_on_unset() {
3484        let toml = r#"
3485        [proxy]
3486        name = "env-check"
3487        [proxy.listen]
3488
3489        [[backends]]
3490        name = "svc"
3491        transport = "stdio"
3492        command = "echo"
3493        bearer_token = "${TOTALLY_UNSET_VAR_1}"
3494
3495        [backends.env]
3496        API_KEY = "${TOTALLY_UNSET_VAR_2}"
3497        STATIC = "plain-value"
3498
3499        [auth]
3500        type = "bearer"
3501        tokens = ["${TOTALLY_UNSET_VAR_3}", "literal-token"]
3502
3503        [[auth.scoped_tokens]]
3504        token = "${TOTALLY_UNSET_VAR_4}"
3505        allow_tools = ["svc/echo"]
3506        "#;
3507
3508        let config = ProxyConfig::parse(toml).unwrap();
3509        let warnings = config.check_env_vars();
3510
3511        assert_eq!(warnings.len(), 4, "warnings: {warnings:?}");
3512        assert!(warnings[0].contains("TOTALLY_UNSET_VAR_1"));
3513        assert!(warnings[0].contains("bearer_token"));
3514        assert!(warnings[1].contains("TOTALLY_UNSET_VAR_2"));
3515        assert!(warnings[1].contains("env.API_KEY"));
3516        assert!(warnings[2].contains("TOTALLY_UNSET_VAR_3"));
3517        assert!(warnings[2].contains("tokens[0]"));
3518        assert!(warnings[3].contains("TOTALLY_UNSET_VAR_4"));
3519        assert!(warnings[3].contains("scoped_tokens[0]"));
3520    }
3521
3522    #[test]
3523    fn test_check_env_vars_no_warnings_when_set() {
3524        // SAFETY: test runs single-threaded
3525        unsafe { std::env::set_var("MCP_CHECK_TEST_VAR", "value") };
3526
3527        let toml = r#"
3528        [proxy]
3529        name = "env-check"
3530        [proxy.listen]
3531
3532        [[backends]]
3533        name = "svc"
3534        transport = "stdio"
3535        command = "echo"
3536        bearer_token = "${MCP_CHECK_TEST_VAR}"
3537        "#;
3538
3539        let config = ProxyConfig::parse(toml).unwrap();
3540        let warnings = config.check_env_vars();
3541        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3542
3543        // SAFETY: same as above
3544        unsafe { std::env::remove_var("MCP_CHECK_TEST_VAR") };
3545    }
3546
3547    #[test]
3548    fn test_check_env_vars_no_warnings_for_literals() {
3549        let toml = r#"
3550        [proxy]
3551        name = "env-check"
3552        [proxy.listen]
3553
3554        [[backends]]
3555        name = "svc"
3556        transport = "stdio"
3557        command = "echo"
3558        bearer_token = "literal-token"
3559        "#;
3560
3561        let config = ProxyConfig::parse(toml).unwrap();
3562        let warnings = config.check_env_vars();
3563        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
3564    }
3565
3566    #[test]
3567    fn test_check_env_vars_oauth_client_secret() {
3568        let toml = r#"
3569        [proxy]
3570        name = "oauth-check"
3571        [proxy.listen]
3572
3573        [[backends]]
3574        name = "svc"
3575        transport = "http"
3576        url = "http://localhost:3000"
3577
3578        [auth]
3579        type = "oauth"
3580        issuer = "https://auth.example.com"
3581        audience = "mcp-proxy"
3582        client_id = "my-client"
3583        client_secret = "${TOTALLY_UNSET_OAUTH_SECRET}"
3584        token_validation = "introspection"
3585        "#;
3586
3587        let config = ProxyConfig::parse(toml).unwrap();
3588        let warnings = config.check_env_vars();
3589        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
3590        assert!(warnings[0].contains("TOTALLY_UNSET_OAUTH_SECRET"));
3591        assert!(warnings[0].contains("client_secret"));
3592    }
3593
3594    #[cfg(feature = "yaml")]
3595    #[test]
3596    fn test_parse_yaml_config() {
3597        let yaml = r#"
3598proxy:
3599  name: yaml-proxy
3600  listen:
3601    host: "127.0.0.1"
3602    port: 8080
3603backends:
3604  - name: echo
3605    transport: stdio
3606    command: echo
3607"#;
3608        let config = ProxyConfig::parse_yaml(yaml).unwrap();
3609        assert_eq!(config.proxy.name, "yaml-proxy");
3610        assert_eq!(config.backends.len(), 1);
3611        assert_eq!(config.backends[0].name, "echo");
3612    }
3613
3614    #[cfg(feature = "yaml")]
3615    #[test]
3616    fn test_parse_yaml_with_auth() {
3617        let yaml = r#"
3618proxy:
3619  name: auth-proxy
3620  listen:
3621    host: "127.0.0.1"
3622    port: 9090
3623backends:
3624  - name: api
3625    transport: stdio
3626    command: echo
3627auth:
3628  type: bearer
3629  tokens:
3630    - token-1
3631    - token-2
3632"#;
3633        let config = ProxyConfig::parse_yaml(yaml).unwrap();
3634        match &config.auth {
3635            Some(AuthConfig::Bearer { tokens, .. }) => {
3636                assert_eq!(tokens, &["token-1", "token-2"]);
3637            }
3638            other => panic!("expected Bearer auth, got: {other:?}"),
3639        }
3640    }
3641
3642    #[cfg(feature = "yaml")]
3643    #[test]
3644    fn test_parse_yaml_with_middleware() {
3645        let yaml = r#"
3646proxy:
3647  name: mw-proxy
3648  listen:
3649    host: "127.0.0.1"
3650    port: 8080
3651backends:
3652  - name: api
3653    transport: stdio
3654    command: echo
3655    timeout:
3656      seconds: 30
3657    rate_limit:
3658      requests: 100
3659      period_seconds: 1
3660    expose_tools:
3661      - read_file
3662      - list_directory
3663"#;
3664        let config = ProxyConfig::parse_yaml(yaml).unwrap();
3665        assert_eq!(config.backends[0].timeout.as_ref().unwrap().seconds, 30);
3666        assert_eq!(
3667            config.backends[0].rate_limit.as_ref().unwrap().requests,
3668            100
3669        );
3670        assert_eq!(
3671            config.backends[0].expose_tools,
3672            vec!["read_file", "list_directory"]
3673        );
3674    }
3675
3676    #[test]
3677    fn test_from_mcp_json() {
3678        let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json");
3679        let project_dir = dir.join("my-project");
3680        std::fs::create_dir_all(&project_dir).unwrap();
3681
3682        let mcp_json_path = project_dir.join(".mcp.json");
3683        std::fs::write(
3684            &mcp_json_path,
3685            r#"{
3686                "mcpServers": {
3687                    "github": {
3688                        "command": "npx",
3689                        "args": ["-y", "@modelcontextprotocol/server-github"]
3690                    },
3691                    "api": {
3692                        "url": "http://localhost:9000"
3693                    }
3694                }
3695            }"#,
3696        )
3697        .unwrap();
3698
3699        let config = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap();
3700
3701        // Name derived from parent directory
3702        assert_eq!(config.proxy.name, "my-project");
3703        // Sensible defaults
3704        assert_eq!(config.proxy.listen.host, "127.0.0.1");
3705        assert_eq!(config.proxy.listen.port, 8080);
3706        assert_eq!(config.proxy.version, "0.1.0");
3707        assert_eq!(config.proxy.separator, "/");
3708        // No auth or middleware
3709        assert!(config.auth.is_none());
3710        assert!(config.composite_tools.is_empty());
3711        // Backends imported
3712        assert_eq!(config.backends.len(), 2);
3713        assert_eq!(config.backends[0].name, "api");
3714        assert_eq!(config.backends[1].name, "github");
3715
3716        std::fs::remove_dir_all(&dir).unwrap();
3717    }
3718
3719    #[test]
3720    fn test_from_mcp_json_empty_rejects() {
3721        let dir = std::env::temp_dir().join("mcp_proxy_test_from_mcp_json_empty");
3722        std::fs::create_dir_all(&dir).unwrap();
3723
3724        let mcp_json_path = dir.join(".mcp.json");
3725        std::fs::write(&mcp_json_path, r#"{ "mcpServers": {} }"#).unwrap();
3726
3727        let err = ProxyConfig::from_mcp_json(&mcp_json_path).unwrap_err();
3728        assert!(
3729            err.to_string().contains("at least one backend"),
3730            "unexpected error: {err}"
3731        );
3732
3733        std::fs::remove_dir_all(&dir).unwrap();
3734    }
3735
3736    #[test]
3737    fn test_priority_defaults_to_zero() {
3738        let toml = r#"
3739        [proxy]
3740        name = "test"
3741        [proxy.listen]
3742
3743        [[backends]]
3744        name = "api"
3745        transport = "stdio"
3746        command = "echo"
3747        "#;
3748
3749        let config = ProxyConfig::parse(toml).unwrap();
3750        assert_eq!(config.backends[0].priority, 0);
3751    }
3752
3753    #[test]
3754    fn test_priority_parsed_from_config() {
3755        let toml = r#"
3756        [proxy]
3757        name = "test"
3758        [proxy.listen]
3759
3760        [[backends]]
3761        name = "api"
3762        transport = "stdio"
3763        command = "echo"
3764
3765        [[backends]]
3766        name = "api-backup-1"
3767        transport = "stdio"
3768        command = "echo"
3769        failover_for = "api"
3770        priority = 10
3771
3772        [[backends]]
3773        name = "api-backup-2"
3774        transport = "stdio"
3775        command = "echo"
3776        failover_for = "api"
3777        priority = 5
3778        "#;
3779
3780        let config = ProxyConfig::parse(toml).unwrap();
3781        assert_eq!(config.backends[0].priority, 0);
3782        assert_eq!(config.backends[1].priority, 10);
3783        assert_eq!(config.backends[2].priority, 5);
3784    }
3785}