Skip to main content

rmcp_server_kit/
config.rs

1use std::{path::PathBuf, time::Duration};
2
3use serde::Deserialize;
4
5use crate::{
6    bounded_limiter::KeyEvictionPolicy,
7    error::RmcpServerKitError,
8    transport::{McpServerConfig, SecurityHeadersConfig},
9};
10
11#[cfg(test)]
12const SERVER_CONFIG_BRIDGED_FIELDS: &[&str] = &[
13    "listen_addr",
14    "listen_port",
15    "tls_cert_path",
16    "tls_key_path",
17    "tls_handshake_timeout",
18    "max_concurrent_tls_handshakes",
19    "shutdown_timeout",
20    "request_timeout",
21    "allowed_origins",
22    "tool_rate_limit",
23    "tool_rate_limit_burst",
24    "extra_route_rate_limit",
25    "extra_route_rate_limit_burst",
26    "extra_route_rate_limit_exempt_paths",
27    "key_eviction_policy",
28    "trusted_proxies",
29    "trusted_forwarder_max_entries",
30    "forwarded_header",
31    "session_idle_timeout",
32    "sse_keep_alive",
33    "public_url",
34    "compression_enabled",
35    "compression_min_size",
36    "max_concurrent_requests",
37    "admin_enabled",
38    "admin_role",
39    "auth",
40    "max_request_body",
41    "expose_build_metadata",
42    "security_headers",
43];
44
45#[cfg(test)]
46const SERVER_CONFIG_NOT_BRIDGED_FIELDS: &[&str] = &["stdio_enabled"];
47
48#[cfg(test)]
49const MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS: &[&str] = &[
50    "name",
51    "version",
52    "rbac",
53    "readiness_check",
54    "extra_router",
55    "on_reload_ready",
56    "metrics_enabled",
57    "metrics_bind",
58];
59
60#[cfg(test)]
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62enum SharedCheck {
63    AdminAuth,
64    TlsPairing,
65    MtlsRequiresTls,
66}
67
68/// One environment override applied to a configuration struct.
69///
70/// Secret-typed targets redact their value by setting [`Self::value`] to
71/// `None`; non-secret targets carry the parsed string value that was applied.
72#[derive(Debug, Clone, PartialEq, Eq)]
73#[non_exhaustive]
74pub struct EnvOverride {
75    /// Environment variable name that supplied the override.
76    pub env_var: String,
77    /// Dotted TOML path that was overridden, such as `server.listen_port`.
78    pub target_field: String,
79    /// Source of the override value.
80    pub source: EnvOverrideSource,
81    /// Applied non-secret value, or `None` for secret-typed targets.
82    pub value: Option<String>,
83}
84
85/// Source kind for an applied environment override.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87#[non_exhaustive]
88pub enum EnvOverrideSource {
89    /// Read directly from an environment variable.
90    Env,
91    /// Read from the file named by a `_FILE`-suffixed environment variable.
92    File,
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96#[non_exhaustive]
97#[cfg(test)]
98pub(crate) struct EnvOverrideSpec {
99    pub(crate) env_var: &'static str,
100    pub(crate) target_field: &'static str,
101    pub(crate) value_type: &'static str,
102    pub(crate) required_feature: Option<&'static str>,
103    pub(crate) redacted: bool,
104}
105
106#[cfg(test)]
107pub(crate) const ENV_OVERRIDE_SPECS: &[EnvOverrideSpec] = &[
108    EnvOverrideSpec {
109        env_var: "RMCP_SERVER_KIT__SERVER__LISTEN_ADDR",
110        target_field: "server.listen_addr",
111        value_type: "String",
112        required_feature: None,
113        redacted: false,
114    },
115    EnvOverrideSpec {
116        env_var: "RMCP_SERVER_KIT__SERVER__LISTEN_PORT",
117        target_field: "server.listen_port",
118        value_type: "u16",
119        required_feature: None,
120        redacted: false,
121    },
122    EnvOverrideSpec {
123        env_var: "RMCP_SERVER_KIT__SERVER__PUBLIC_URL",
124        target_field: "server.public_url",
125        value_type: "String",
126        required_feature: None,
127        redacted: false,
128    },
129    EnvOverrideSpec {
130        env_var: "RMCP_SERVER_KIT__SERVER__TLS_CERT_PATH",
131        target_field: "server.tls_cert_path",
132        value_type: "Path",
133        required_feature: None,
134        redacted: false,
135    },
136    EnvOverrideSpec {
137        env_var: "RMCP_SERVER_KIT__SERVER__TLS_KEY_PATH",
138        target_field: "server.tls_key_path",
139        value_type: "Path",
140        required_feature: None,
141        redacted: false,
142    },
143    EnvOverrideSpec {
144        env_var: "RMCP_SERVER_KIT__SERVER__ADMIN_ENABLED",
145        target_field: "server.admin_enabled",
146        value_type: "bool",
147        required_feature: None,
148        redacted: false,
149    },
150    EnvOverrideSpec {
151        env_var: "RMCP_SERVER_KIT__SERVER__KEY_EVICTION_POLICY",
152        target_field: "server.key_eviction_policy",
153        value_type: "KeyEvictionPolicy",
154        required_feature: None,
155        redacted: false,
156    },
157    EnvOverrideSpec {
158        env_var: "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ISSUER",
159        target_field: "server.auth.oauth.issuer",
160        value_type: "String",
161        required_feature: Some("oauth"),
162        redacted: false,
163    },
164    EnvOverrideSpec {
165        env_var: "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__AUDIENCE",
166        target_field: "server.auth.oauth.audience",
167        value_type: "String",
168        required_feature: Some("oauth"),
169        redacted: false,
170    },
171    EnvOverrideSpec {
172        env_var: "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__JWKS_URI",
173        target_field: "server.auth.oauth.jwks_uri",
174        value_type: "String",
175        required_feature: Some("oauth"),
176        redacted: false,
177    },
178    EnvOverrideSpec {
179        env_var: "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ALLOWED_ALGORITHMS",
180        target_field: "server.auth.oauth.allowed_algorithms",
181        value_type: "comma-separated algorithm list",
182        required_feature: Some("oauth"),
183        redacted: false,
184    },
185    EnvOverrideSpec {
186        env_var: "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__PROXY__STRIP_RESOURCE_PARAM",
187        target_field: "server.auth.oauth.proxy.strip_resource_param",
188        value_type: "bool",
189        required_feature: Some("oauth"),
190        redacted: false,
191    },
192    EnvOverrideSpec {
193        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__LOG_FORMAT",
194        target_field: "observability.log_format",
195        value_type: "String",
196        required_feature: None,
197        redacted: false,
198    },
199    EnvOverrideSpec {
200        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__METRICS_ENABLED",
201        target_field: "observability.metrics_enabled",
202        value_type: "bool",
203        required_feature: None,
204        redacted: false,
205    },
206    EnvOverrideSpec {
207        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__METRICS_BIND",
208        target_field: "observability.metrics_bind",
209        value_type: "String",
210        required_feature: None,
211        redacted: false,
212    },
213    EnvOverrideSpec {
214        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__LOG_PLAINTEXT_OAUTH_TOKENS",
215        target_field: "observability.log_plaintext_oauth_tokens",
216        value_type: "bool",
217        required_feature: None,
218        redacted: false,
219    },
220    EnvOverrideSpec {
221        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__LOG_OAUTH_CLAIM_VALUES",
222        target_field: "observability.log_oauth_claim_values",
223        value_type: "bool",
224        required_feature: None,
225        redacted: false,
226    },
227    EnvOverrideSpec {
228        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__LOG_TOOL_CALL_ARGUMENTS",
229        target_field: "observability.log_tool_call_arguments",
230        value_type: "bool",
231        required_feature: None,
232        redacted: false,
233    },
234    EnvOverrideSpec {
235        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__LOG_UPSTREAM_ERROR_BODIES",
236        target_field: "observability.log_upstream_error_bodies",
237        value_type: "bool",
238        required_feature: None,
239        redacted: false,
240    },
241    EnvOverrideSpec {
242        env_var: "RMCP_SERVER_KIT__RBAC__REDACTION_SALT",
243        target_field: "rbac.redaction_salt",
244        value_type: "SecretString",
245        required_feature: None,
246        redacted: true,
247    },
248    EnvOverrideSpec {
249        env_var: "RMCP_SERVER_KIT__RBAC__REDACTION_SALT_FILE",
250        target_field: "rbac.redaction_salt",
251        value_type: "Path",
252        required_feature: None,
253        redacted: true,
254    },
255];
256
257pub(crate) const SERVER_LISTEN_ADDR_ENV: &str = "RMCP_SERVER_KIT__SERVER__LISTEN_ADDR";
258pub(crate) const SERVER_LISTEN_PORT_ENV: &str = "RMCP_SERVER_KIT__SERVER__LISTEN_PORT";
259pub(crate) const SERVER_PUBLIC_URL_ENV: &str = "RMCP_SERVER_KIT__SERVER__PUBLIC_URL";
260pub(crate) const SERVER_TLS_CERT_PATH_ENV: &str = "RMCP_SERVER_KIT__SERVER__TLS_CERT_PATH";
261pub(crate) const SERVER_TLS_KEY_PATH_ENV: &str = "RMCP_SERVER_KIT__SERVER__TLS_KEY_PATH";
262pub(crate) const SERVER_ADMIN_ENABLED_ENV: &str = "RMCP_SERVER_KIT__SERVER__ADMIN_ENABLED";
263pub(crate) const SERVER_KEY_EVICTION_POLICY_ENV: &str =
264    "RMCP_SERVER_KIT__SERVER__KEY_EVICTION_POLICY";
265pub(crate) const SERVER_OAUTH_ISSUER_ENV: &str = "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ISSUER";
266pub(crate) const SERVER_OAUTH_AUDIENCE_ENV: &str = "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__AUDIENCE";
267pub(crate) const SERVER_OAUTH_JWKS_URI_ENV: &str = "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__JWKS_URI";
268pub(crate) const SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV: &str =
269    "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__PROXY__STRIP_RESOURCE_PARAM";
270pub(crate) const SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV: &str =
271    "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ALLOWED_ALGORITHMS";
272pub(crate) const OBSERVABILITY_LOG_FORMAT_ENV: &str = "RMCP_SERVER_KIT__OBSERVABILITY__LOG_FORMAT";
273pub(crate) const OBSERVABILITY_METRICS_ENABLED_ENV: &str =
274    "RMCP_SERVER_KIT__OBSERVABILITY__METRICS_ENABLED";
275pub(crate) const OBSERVABILITY_METRICS_BIND_ENV: &str =
276    "RMCP_SERVER_KIT__OBSERVABILITY__METRICS_BIND";
277pub(crate) const OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV: &str =
278    "RMCP_SERVER_KIT__OBSERVABILITY__LOG_PLAINTEXT_OAUTH_TOKENS";
279pub(crate) const OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV: &str =
280    "RMCP_SERVER_KIT__OBSERVABILITY__LOG_OAUTH_CLAIM_VALUES";
281pub(crate) const OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV: &str =
282    "RMCP_SERVER_KIT__OBSERVABILITY__LOG_TOOL_CALL_ARGUMENTS";
283pub(crate) const OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV: &str =
284    "RMCP_SERVER_KIT__OBSERVABILITY__LOG_UPSTREAM_ERROR_BODIES";
285pub(crate) const RBAC_REDACTION_SALT_ENV: &str = "RMCP_SERVER_KIT__RBAC__REDACTION_SALT";
286pub(crate) const RBAC_REDACTION_SALT_FILE_ENV: &str = "RMCP_SERVER_KIT__RBAC__REDACTION_SALT_FILE";
287
288/// Server listener configuration (reusable across MCP projects).
289#[derive(Deserialize)]
290#[serde(deny_unknown_fields)]
291#[allow(
292    clippy::struct_excessive_bools,
293    reason = "server configuration is a flat TOML schema with independent boolean feature flags"
294)]
295#[non_exhaustive]
296pub struct ServerConfig {
297    /// Listen address (IP or hostname). Default: `127.0.0.1`.
298    #[serde(default = "default_listen_addr")]
299    pub listen_addr: String,
300    /// Listen TCP port. Default: `8443`.
301    #[serde(default = "default_listen_port")]
302    pub listen_port: u16,
303    /// Path to the TLS certificate (PEM). Required for TLS/mTLS.
304    pub tls_cert_path: Option<PathBuf>,
305    /// Path to the TLS private key (PEM). Required for TLS/mTLS.
306    pub tls_key_path: Option<PathBuf>,
307    /// Per-handshake deadline on the TLS accept path, parsed via
308    /// `humantime`. Idle or slow-loris connections are dropped once it
309    /// elapses. Startup-only (not hot-reloadable); ignored unless TLS is
310    /// configured. Default: `10s`.
311    #[serde(default = "default_tls_handshake_timeout")]
312    pub tls_handshake_timeout: String,
313    /// Cap on concurrently in-flight TLS handshakes. At saturation the
314    /// acceptor stops pulling new connections from the kernel backlog
315    /// (backpressure). Startup-only (not hot-reloadable); ignored unless
316    /// TLS is configured. Default: `256`.
317    #[serde(default = "default_max_concurrent_tls_handshakes")]
318    pub max_concurrent_tls_handshakes: usize,
319    /// Graceful shutdown timeout, parsed via `humantime`.
320    #[serde(default = "default_shutdown_timeout")]
321    pub shutdown_timeout: String,
322    /// Per-request timeout, parsed via `humantime`.
323    #[serde(default = "default_request_timeout")]
324    pub request_timeout: String,
325    /// Maximum request body size in bytes. Default: 1 MiB.
326    #[serde(default = "default_max_request_body")]
327    pub max_request_body: usize,
328    /// Allowed Origin header values for DNS rebinding protection (MCP spec).
329    /// Requests with an Origin not in this list are rejected with 403.
330    /// Requests without an Origin header are always allowed (non-browser).
331    #[serde(default)]
332    pub allowed_origins: Vec<String>,
333    /// Allow the stdio transport subcommand. Disabled by default because
334    /// stdio mode bypasses auth, RBAC, TLS, and Origin validation.
335    #[serde(default)]
336    pub stdio_enabled: bool,
337    /// Maximum tool invocations per source IP per minute.
338    /// When set, enforced by the RBAC middleware on `tools/call` requests.
339    /// Protects against both abuse and runaway LLM loops.
340    pub tool_rate_limit: Option<u32>,
341    /// Burst capacity for the tool rate limiter (bucket size; sustained
342    /// rate stays `tool_rate_limit`). Requires `tool_rate_limit`; must
343    /// be greater than zero.
344    pub tool_rate_limit_burst: Option<u32>,
345    /// Maximum requests per source IP per minute on application routes
346    /// merged via `McpServerConfig::with_extra_router` (which bypass
347    /// auth/RBAC). Opt-in; must be greater than zero when set.
348    /// Keyed by the direct socket peer — no `X-Forwarded-For`
349    /// interpretation. Startup-only.
350    pub extra_route_rate_limit: Option<u32>,
351    /// Burst capacity for the extra-route rate limiter (bucket size;
352    /// sustained rate stays `extra_route_rate_limit`). Requires
353    /// `extra_route_rate_limit`; must be greater than zero.
354    pub extra_route_rate_limit_burst: Option<u32>,
355    /// Exact-match request paths exempt from the extra-route rate
356    /// limiter. Raw string comparison against the request path — no
357    /// globs, no normalization; fail-closed (anything not listed stays
358    /// limited). Requires `extra_route_rate_limit`; entries must be
359    /// non-empty and start with `/`. Startup-only.
360    #[serde(default)]
361    pub extra_route_rate_limit_exempt_paths: Vec<String>,
362    /// Full-table policy for per-IP rate limiters. Default: `evict_lru`.
363    #[serde(default)]
364    pub key_eviction_policy: KeyEvictionPolicy,
365    /// Trusted reverse-proxy networks (CIDRs or bare IPs) for
366    /// trusted-forwarder mode. Empty (default) = off. When the direct
367    /// peer is inside one of these networks, the client IP is resolved
368    /// from the forwarding header (rightmost-untrusted walk) and all
369    /// per-IP rate limiters key by it. Startup-only.
370    #[serde(default)]
371    pub trusted_proxies: Vec<String>,
372    /// Maximum forwarding-chain entries scanned per request in
373    /// trusted-forwarder mode. Longer chains are treated as a header bomb
374    /// and resolution falls back to the direct peer. Default `16`, valid
375    /// range `1..=64`. Startup-only.
376    #[serde(default = "default_trusted_forwarder_max_entries")]
377    pub trusted_forwarder_max_entries: usize,
378    /// Which forwarding header trusted-forwarder mode reads:
379    /// `"x-forwarded-for"` (default when unset) or `"forwarded"`
380    /// (RFC 7239). Requires `trusted_proxies` to be nonempty.
381    pub forwarded_header: Option<crate::transport::ForwardedHeaderMode>,
382    /// Idle timeout for MCP sessions. Sessions with no activity for this
383    /// duration are closed automatically. Default: 20 minutes.
384    #[serde(default = "default_session_idle_timeout")]
385    pub session_idle_timeout: String,
386    /// Interval for SSE keep-alive pings sent to the client. Prevents
387    /// proxies and load balancers from killing idle connections.
388    /// Default: 15 seconds.
389    #[serde(default = "default_sse_keep_alive")]
390    pub sse_keep_alive: String,
391    /// Externally reachable base URL (e.g. `https://mcp.example.com`).
392    /// When set, OAuth metadata endpoints advertise this URL instead of
393    /// the listen address. Required when the server binds to `0.0.0.0`
394    /// behind a reverse proxy or inside a container.
395    pub public_url: Option<String>,
396    /// Enable gzip/br response compression for MCP responses.
397    #[serde(default)]
398    pub compression_enabled: bool,
399    /// Minimum response size (bytes) before compression kicks in.
400    /// Only used when `compression_enabled` is true. Default: 1024.
401    #[serde(default = "default_compression_min_size")]
402    pub compression_min_size: u16,
403    /// Global cap on in-flight HTTP requests. When reached, excess
404    /// requests receive 503 Service Unavailable (via load shedding).
405    pub max_concurrent_requests: Option<usize>,
406    /// Enable `/admin/*` diagnostic endpoints.
407    #[serde(default)]
408    pub admin_enabled: bool,
409    /// RBAC role required to access admin endpoints.
410    #[serde(default = "default_admin_role")]
411    pub admin_role: String,
412    /// Authentication configuration (API keys, mTLS, OAuth).
413    pub auth: Option<crate::auth::AuthConfig>,
414    /// Expose build metadata on the unauthenticated `/version` endpoint.
415    #[serde(default = "default_expose_build_metadata")]
416    pub expose_build_metadata: bool,
417    /// Per-header OWASP security-header overrides.
418    #[serde(default = "default_security_headers")]
419    pub security_headers: SecurityHeadersConfig,
420}
421
422/// Hand-written so `tls_key_path` never reaches a log.
423///
424/// SECURITY: a derived `Debug` renders the private-key path verbatim, and the
425/// whole config is easy to log accidentally (`tracing::debug!(?config)`, a
426/// panic message, an error chain). Presence is still reported so diagnostics
427/// remain useful; only the location is withheld.
428///
429/// Every field is listed deliberately rather than using
430/// `finish_non_exhaustive`, and `server_config_debug_lists_every_field` fails
431/// if a field is added here without being rendered.
432impl std::fmt::Debug for ServerConfig {
433    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
434        f.debug_struct("ServerConfig")
435            .field("listen_addr", &self.listen_addr)
436            .field("listen_port", &self.listen_port)
437            .field("tls_cert_path", &self.tls_cert_path)
438            .field(
439                "tls_key_path",
440                &self.tls_key_path.as_ref().map(|_| "[REDACTED]"),
441            )
442            .field("tls_handshake_timeout", &self.tls_handshake_timeout)
443            .field(
444                "max_concurrent_tls_handshakes",
445                &self.max_concurrent_tls_handshakes,
446            )
447            .field("shutdown_timeout", &self.shutdown_timeout)
448            .field("request_timeout", &self.request_timeout)
449            .field("max_request_body", &self.max_request_body)
450            .field("allowed_origins", &self.allowed_origins)
451            .field("stdio_enabled", &self.stdio_enabled)
452            .field("tool_rate_limit", &self.tool_rate_limit)
453            .field("tool_rate_limit_burst", &self.tool_rate_limit_burst)
454            .field("extra_route_rate_limit", &self.extra_route_rate_limit)
455            .field(
456                "extra_route_rate_limit_burst",
457                &self.extra_route_rate_limit_burst,
458            )
459            .field(
460                "extra_route_rate_limit_exempt_paths",
461                &self.extra_route_rate_limit_exempt_paths,
462            )
463            .field("key_eviction_policy", &self.key_eviction_policy)
464            .field("trusted_proxies", &self.trusted_proxies)
465            .field(
466                "trusted_forwarder_max_entries",
467                &self.trusted_forwarder_max_entries,
468            )
469            .field("forwarded_header", &self.forwarded_header)
470            .field("session_idle_timeout", &self.session_idle_timeout)
471            .field("sse_keep_alive", &self.sse_keep_alive)
472            .field("public_url", &self.public_url)
473            .field("compression_enabled", &self.compression_enabled)
474            .field("compression_min_size", &self.compression_min_size)
475            .field("max_concurrent_requests", &self.max_concurrent_requests)
476            .field("admin_enabled", &self.admin_enabled)
477            .field("admin_role", &self.admin_role)
478            .field("auth", &self.auth)
479            .field("expose_build_metadata", &self.expose_build_metadata)
480            .field("security_headers", &self.security_headers)
481            .finish()
482    }
483}
484
485impl Default for ServerConfig {
486    fn default() -> Self {
487        Self {
488            listen_addr: default_listen_addr(),
489            listen_port: default_listen_port(),
490            tls_cert_path: None,
491            tls_key_path: None,
492            tls_handshake_timeout: default_tls_handshake_timeout(),
493            max_concurrent_tls_handshakes: default_max_concurrent_tls_handshakes(),
494            shutdown_timeout: default_shutdown_timeout(),
495            request_timeout: default_request_timeout(),
496            max_request_body: default_max_request_body(),
497            allowed_origins: Vec::new(),
498            stdio_enabled: false,
499            tool_rate_limit: None,
500            tool_rate_limit_burst: None,
501            extra_route_rate_limit: None,
502            extra_route_rate_limit_burst: None,
503            extra_route_rate_limit_exempt_paths: Vec::new(),
504            key_eviction_policy: KeyEvictionPolicy::default(),
505            trusted_proxies: Vec::new(),
506            trusted_forwarder_max_entries: default_trusted_forwarder_max_entries(),
507            forwarded_header: None,
508            session_idle_timeout: default_session_idle_timeout(),
509            sse_keep_alive: default_sse_keep_alive(),
510            public_url: None,
511            compression_enabled: false,
512            compression_min_size: default_compression_min_size(),
513            max_concurrent_requests: None,
514            admin_enabled: false,
515            admin_role: default_admin_role(),
516            auth: None,
517            expose_build_metadata: default_expose_build_metadata(),
518            security_headers: default_security_headers(),
519        }
520    }
521}
522
523impl ServerConfig {
524    /// Applies `RMCP_SERVER_KIT__SERVER__*` environment overrides onto this config.
525    ///
526    /// Includes the nested OAuth variables under
527    /// `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__*`. This method is opt-in:
528    /// constructors, validators, and server startup do not call it.
529    ///
530    /// # Errors
531    ///
532    /// Returns [`RmcpServerKitError::Config`] when an override cannot be parsed, when an
533    /// OAuth override lacks a declared `[server.auth.oauth]` parent, or when an
534    /// OAuth override is used in a build without the `oauth` feature.
535    ///
536    /// # Examples
537    ///
538    /// The full config-file pipeline lives in
539    /// [`examples/config_file_server.rs`](https://github.com/andrico21/rmcp-server-kit/blob/main/examples/config_file_server.rs).
540    ///
541    /// ```no_run
542    /// use rmcp_server_kit::config::ServerConfig;
543    ///
544    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
545    /// let mut server = ServerConfig::default();
546    /// // Do not set process env in doctests: rustdoc examples share a process.
547    /// let report = server.apply_env_overrides()?;
548    /// let _applied_fields: Vec<&str> = report
549    ///     .iter()
550    ///     .map(|entry| entry.target_field.as_str())
551    ///     .collect();
552    /// # Ok(())
553    /// # }
554    /// ```
555    pub fn apply_env_overrides(&mut self) -> Result<Vec<EnvOverride>, RmcpServerKitError> {
556        let mut applied = Vec::new();
557        apply_string_env(
558            SERVER_LISTEN_ADDR_ENV,
559            "server.listen_addr",
560            &mut self.listen_addr,
561            &mut applied,
562        )?;
563        if let Some(raw) = read_env(SERVER_LISTEN_PORT_ENV)? {
564            self.listen_port = parse_env_value(SERVER_LISTEN_PORT_ENV, &raw, "u16")?;
565            applied.push(env_report(
566                SERVER_LISTEN_PORT_ENV,
567                "server.listen_port",
568                raw,
569            ));
570        }
571        apply_optional_string_env(
572            SERVER_PUBLIC_URL_ENV,
573            "server.public_url",
574            &mut self.public_url,
575            &mut applied,
576        )?;
577        apply_optional_path_env(
578            SERVER_TLS_CERT_PATH_ENV,
579            "server.tls_cert_path",
580            &mut self.tls_cert_path,
581            &mut applied,
582        )?;
583        apply_optional_path_env(
584            SERVER_TLS_KEY_PATH_ENV,
585            "server.tls_key_path",
586            &mut self.tls_key_path,
587            &mut applied,
588        )?;
589        if let Some(raw) = read_env(SERVER_ADMIN_ENABLED_ENV)? {
590            self.admin_enabled = parse_env_bool(SERVER_ADMIN_ENABLED_ENV, &raw)?;
591            applied.push(env_report(
592                SERVER_ADMIN_ENABLED_ENV,
593                "server.admin_enabled",
594                raw,
595            ));
596        }
597        if let Some(raw) = read_env(SERVER_KEY_EVICTION_POLICY_ENV)? {
598            self.key_eviction_policy =
599                parse_env_value(SERVER_KEY_EVICTION_POLICY_ENV, &raw, "KeyEvictionPolicy")?;
600            applied.push(env_report(
601                SERVER_KEY_EVICTION_POLICY_ENV,
602                "server.key_eviction_policy",
603                raw,
604            ));
605        }
606        let oauth_env = OAuthEnvOverrides::read()?;
607        #[cfg(feature = "oauth")]
608        self.apply_oauth_env_overrides(oauth_env, &mut applied)?;
609        #[cfg(not(feature = "oauth"))]
610        reject_oauth_env_overrides(&oauth_env)?;
611        Ok(applied)
612    }
613
614    #[cfg(feature = "oauth")]
615    fn apply_oauth_env_overrides(
616        &mut self,
617        oauth_env: OAuthEnvOverrides,
618        applied: &mut Vec<EnvOverride>,
619    ) -> Result<(), RmcpServerKitError> {
620        if !oauth_env.is_set() {
621            return Ok(());
622        }
623
624        let Some(auth) = self.auth.as_mut() else {
625            let var = oauth_env.first_set_var();
626            return Err(RmcpServerKitError::Config(format!(
627                "{var} requires declaring [server.auth.oauth] before applying env overrides"
628            )));
629        };
630        let Some(oauth) = auth.oauth.as_mut() else {
631            let var = oauth_env.first_set_var();
632            return Err(RmcpServerKitError::Config(format!(
633                "{var} requires declaring [server.auth.oauth] before applying env overrides"
634            )));
635        };
636        if let Some(raw) = oauth_env.issuer {
637            applied.push(env_report(
638                SERVER_OAUTH_ISSUER_ENV,
639                "server.auth.oauth.issuer",
640                raw.clone(),
641            ));
642            oauth.issuer = raw;
643        }
644        if let Some(raw) = oauth_env.audience {
645            applied.push(env_report(
646                SERVER_OAUTH_AUDIENCE_ENV,
647                "server.auth.oauth.audience",
648                raw.clone(),
649            ));
650            oauth.audience = raw;
651        }
652        if let Some(raw) = oauth_env.jwks_uri {
653            applied.push(env_report(
654                SERVER_OAUTH_JWKS_URI_ENV,
655                "server.auth.oauth.jwks_uri",
656                raw.clone(),
657            ));
658            oauth.jwks_uri = raw;
659        }
660        if let Some(raw) = oauth_env.allowed_algorithms {
661            // First list-valued env override: split on `,`, trim, and drop
662            // empty segments so `RS256, ES256` and `RS256,,ES256` both work.
663            let names: Vec<String> = raw
664                .split(',')
665                .map(str::trim)
666                .filter(|part| !part.is_empty())
667                .map(ToOwned::to_owned)
668                .collect();
669            // Resolve eagerly so an unusable value is reported against the
670            // env var that set it, rather than surfacing later as an opaque
671            // `oauth.allowed_algorithms` config error.
672            crate::oauth::resolve_allowed_algorithms(Some(&names)).map_err(|err| {
673                RmcpServerKitError::Config(format!("{SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV}: {err}"))
674            })?;
675            applied.push(env_report(
676                SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV,
677                "server.auth.oauth.allowed_algorithms",
678                raw,
679            ));
680            oauth.allowed_algorithms = Some(names);
681        }
682        if let Some(raw) = oauth_env.proxy_strip_resource_param {
683            let value = parse_env_bool(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, &raw)?;
684            // Fail closed, mirroring the parent-table rule above: this variable
685            // can only populate a field on an existing proxy, never create one,
686            // because `authorize_url`/`token_url`/`client_id` have no env source.
687            let Some(proxy) = oauth.proxy.as_mut() else {
688                return Err(RmcpServerKitError::Config(format!(
689                    "{SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV} requires declaring \
690                     [server.auth.oauth.proxy] before applying env overrides"
691                )));
692            };
693            applied.push(env_report(
694                SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV,
695                "server.auth.oauth.proxy.strip_resource_param",
696                raw,
697            ));
698            proxy.strip_resource_param = value;
699        }
700        Ok(())
701    }
702
703    /// Apply this TOML server schema to a programmatic MCP server base.
704    ///
705    /// Replacement semantics are used for every bridgeable transport field:
706    /// `None` and `false` values in TOML clear the corresponding value from
707    /// `base`. Only runtime-only fields such as `name`, `version`, RBAC,
708    /// readiness callbacks, extra routers, reload callbacks, and metrics
709    /// listener settings are preserved from `base`.
710    ///
711    /// Chain application-code builder overrides after this method when those
712    /// overrides should take precedence over TOML. This method is side-effect
713    /// free and never reads process environment variables.
714    ///
715    /// # Errors
716    ///
717    /// Returns [`RmcpServerKitError::Config`] when a duration string cannot be parsed.
718    ///
719    /// # Examples
720    ///
721    /// The full config-file pipeline lives in
722    /// [`examples/config_file_server.rs`](https://github.com/andrico21/rmcp-server-kit/blob/main/examples/config_file_server.rs).
723    ///
724    /// ```
725    /// use rmcp_server_kit::config::{ServerConfig, validate_server_config};
726    /// use rmcp_server_kit::transport::McpServerConfig;
727    ///
728    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
729    /// let server = ServerConfig::default();
730    /// validate_server_config(&server)?;
731    /// let config = server.apply_to_mcp_config(McpServerConfig::new(
732    ///     "placeholder:0",
733    ///     "my-server",
734    ///     "0.1.0",
735    /// ))?;
736    /// let _validated = config.validate()?;
737    /// # Ok(())
738    /// # }
739    /// ```
740    pub fn apply_to_mcp_config(
741        &self,
742        base: McpServerConfig,
743    ) -> Result<McpServerConfig, RmcpServerKitError> {
744        let config = base
745            .with_bind_addr(format!("{}:{}", self.listen_addr, self.listen_port))
746            .with_tls_paths(self.tls_cert_path.clone(), self.tls_key_path.clone())
747            .with_optional_auth(self.auth.clone())
748            .with_max_request_body(self.max_request_body)
749            .with_request_timeout(parse_duration_field(
750                "server.request_timeout",
751                &self.request_timeout,
752            )?)
753            .with_shutdown_timeout(parse_duration_field(
754                "server.shutdown_timeout",
755                &self.shutdown_timeout,
756            )?)
757            .with_session_idle_timeout(parse_duration_field(
758                "server.session_idle_timeout",
759                &self.session_idle_timeout,
760            )?)
761            .with_sse_keep_alive(parse_duration_field(
762                "server.sse_keep_alive",
763                &self.sse_keep_alive,
764            )?)
765            .with_tls_handshake_timeout(parse_duration_field(
766                "server.tls_handshake_timeout",
767                &self.tls_handshake_timeout,
768            )?)
769            .with_max_concurrent_tls_handshakes(self.max_concurrent_tls_handshakes)
770            .with_allowed_origins(self.allowed_origins.iter().map(String::as_str))
771            .with_extra_route_rate_limit_exempt_paths(
772                self.extra_route_rate_limit_exempt_paths
773                    .iter()
774                    .map(String::as_str),
775            )
776            .with_trusted_proxies(self.trusted_proxies.iter().map(String::as_str))
777            .with_trusted_forwarder_max_entries(self.trusted_forwarder_max_entries)
778            .with_optional_tool_rate_limit(self.tool_rate_limit)
779            .with_optional_tool_rate_limit_burst(self.tool_rate_limit_burst)
780            .with_optional_extra_route_rate_limit(self.extra_route_rate_limit)
781            .with_optional_extra_route_rate_limit_burst(self.extra_route_rate_limit_burst)
782            .with_key_eviction_policy(self.key_eviction_policy)
783            .with_optional_forwarded_header(self.forwarded_header)
784            .with_optional_public_url(self.public_url.clone())
785            .with_compression_enabled(self.compression_enabled)
786            .with_compression_min_size(self.compression_min_size)
787            .with_optional_max_concurrent_requests(self.max_concurrent_requests)
788            .with_admin_enabled(self.admin_enabled)
789            .with_admin_role(&self.admin_role)
790            .with_expose_build_metadata(self.expose_build_metadata)
791            .with_security_headers(self.security_headers.clone());
792
793        Ok(config)
794    }
795}
796
797impl ObservabilityConfig {
798    /// Applies `RMCP_SERVER_KIT__OBSERVABILITY__*` environment overrides.
799    ///
800    /// This method is opt-in and only mutates this struct; it does not update
801    /// tracing subscribers or server metrics configuration by itself.
802    ///
803    /// # Errors
804    ///
805    /// Returns [`RmcpServerKitError::Config`] when a boolean override cannot be parsed.
806    ///
807    /// # Examples
808    ///
809    /// The full config-file pipeline lives in
810    /// [`examples/config_file_server.rs`](https://github.com/andrico21/rmcp-server-kit/blob/main/examples/config_file_server.rs).
811    ///
812    /// ```no_run
813    /// use rmcp_server_kit::config::ObservabilityConfig;
814    ///
815    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
816    /// let mut observability = ObservabilityConfig::default();
817    /// // Do not set process env in doctests: rustdoc examples share a process.
818    /// let report = observability.apply_env_overrides()?;
819    /// let _report_shape: Vec<(&str, &str, Option<&str>)> = report
820    ///     .iter()
821    ///     .map(|entry| {
822    ///         (
823    ///             entry.env_var.as_str(),
824    ///             entry.target_field.as_str(),
825    ///             entry.value.as_deref(),
826    ///         )
827    ///     })
828    ///     .collect();
829    /// # Ok(())
830    /// # }
831    /// ```
832    pub fn apply_env_overrides(&mut self) -> Result<Vec<EnvOverride>, RmcpServerKitError> {
833        let mut applied = Vec::new();
834        apply_string_env(
835            OBSERVABILITY_LOG_FORMAT_ENV,
836            "observability.log_format",
837            &mut self.log_format,
838            &mut applied,
839        )?;
840        if let Some(raw) = read_env(OBSERVABILITY_METRICS_ENABLED_ENV)? {
841            self.metrics_enabled = parse_env_bool(OBSERVABILITY_METRICS_ENABLED_ENV, &raw)?;
842            applied.push(env_report(
843                OBSERVABILITY_METRICS_ENABLED_ENV,
844                "observability.metrics_enabled",
845                raw,
846            ));
847        }
848        if let Some(raw) = read_env(OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV)? {
849            self.log_plaintext_oauth_tokens =
850                parse_env_bool(OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV, &raw)?;
851            applied.push(env_report(
852                OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV,
853                "observability.log_plaintext_oauth_tokens",
854                raw,
855            ));
856        }
857        if let Some(raw) = read_env(OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV)? {
858            self.log_oauth_claim_values =
859                parse_env_bool(OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV, &raw)?;
860            applied.push(env_report(
861                OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV,
862                "observability.log_oauth_claim_values",
863                raw,
864            ));
865        }
866        if let Some(raw) = read_env(OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV)? {
867            self.log_tool_call_arguments =
868                parse_env_bool(OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV, &raw)?;
869            applied.push(env_report(
870                OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV,
871                "observability.log_tool_call_arguments",
872                raw,
873            ));
874        }
875        if let Some(raw) = read_env(OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV)? {
876            self.log_upstream_error_bodies =
877                parse_env_bool(OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV, &raw)?;
878            applied.push(env_report(
879                OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV,
880                "observability.log_upstream_error_bodies",
881                raw,
882            ));
883        }
884        apply_string_env(
885            OBSERVABILITY_METRICS_BIND_ENV,
886            "observability.metrics_bind",
887            &mut self.metrics_bind,
888            &mut applied,
889        )?;
890        Ok(applied)
891    }
892}
893
894pub(crate) fn read_env(var: &str) -> Result<Option<String>, RmcpServerKitError> {
895    match std::env::var(var) {
896        Ok(value) => Ok(Some(value)),
897        Err(std::env::VarError::NotPresent) => Ok(None),
898        Err(std::env::VarError::NotUnicode(_)) => Err(RmcpServerKitError::Config(format!(
899            "{var} must contain valid UTF-8"
900        ))),
901    }
902}
903
904fn env_report(env_var: &str, target_field: &str, value: String) -> EnvOverride {
905    EnvOverride {
906        env_var: env_var.to_owned(),
907        target_field: target_field.to_owned(),
908        source: EnvOverrideSource::Env,
909        value: Some(value),
910    }
911}
912
913pub(crate) fn secret_env_report(
914    env_var: &str,
915    target_field: &str,
916    source: EnvOverrideSource,
917) -> EnvOverride {
918    EnvOverride {
919        env_var: env_var.to_owned(),
920        target_field: target_field.to_owned(),
921        source,
922        value: None,
923    }
924}
925
926fn parse_env_value<T>(env_var: &str, raw: &str, expected: &str) -> Result<T, RmcpServerKitError>
927where
928    T: std::str::FromStr,
929{
930    raw.parse::<T>().map_err(|_| {
931        RmcpServerKitError::Config(format!("invalid value for {env_var}: expected {expected}"))
932    })
933}
934
935pub(crate) fn parse_env_bool(env_var: &str, raw: &str) -> Result<bool, RmcpServerKitError> {
936    parse_env_value(env_var, raw, "bool")
937}
938
939fn apply_string_env(
940    env_var: &str,
941    target_field: &str,
942    target: &mut String,
943    applied: &mut Vec<EnvOverride>,
944) -> Result<(), RmcpServerKitError> {
945    if let Some(raw) = read_env(env_var)? {
946        applied.push(env_report(env_var, target_field, raw.clone()));
947        *target = raw;
948    }
949    Ok(())
950}
951
952fn apply_optional_string_env(
953    env_var: &str,
954    target_field: &str,
955    target: &mut Option<String>,
956    applied: &mut Vec<EnvOverride>,
957) -> Result<(), RmcpServerKitError> {
958    if let Some(raw) = read_env(env_var)? {
959        *target = Some(raw.clone());
960        applied.push(env_report(env_var, target_field, raw));
961    }
962    Ok(())
963}
964
965fn apply_optional_path_env(
966    env_var: &str,
967    target_field: &str,
968    target: &mut Option<PathBuf>,
969    applied: &mut Vec<EnvOverride>,
970) -> Result<(), RmcpServerKitError> {
971    if let Some(raw) = read_env(env_var)? {
972        *target = Some(PathBuf::from(&raw));
973        applied.push(env_report(env_var, target_field, raw));
974    }
975    Ok(())
976}
977
978struct OAuthEnvOverrides {
979    issuer: Option<String>,
980    audience: Option<String>,
981    jwks_uri: Option<String>,
982    allowed_algorithms: Option<String>,
983    proxy_strip_resource_param: Option<String>,
984}
985
986impl OAuthEnvOverrides {
987    fn read() -> Result<Self, RmcpServerKitError> {
988        Ok(Self {
989            issuer: read_env(SERVER_OAUTH_ISSUER_ENV)?,
990            audience: read_env(SERVER_OAUTH_AUDIENCE_ENV)?,
991            jwks_uri: read_env(SERVER_OAUTH_JWKS_URI_ENV)?,
992            allowed_algorithms: read_env(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV)?,
993            proxy_strip_resource_param: read_env(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV)?,
994        })
995    }
996
997    fn is_set(&self) -> bool {
998        self.issuer.is_some()
999            || self.audience.is_some()
1000            || self.jwks_uri.is_some()
1001            || self.allowed_algorithms.is_some()
1002            || self.proxy_strip_resource_param.is_some()
1003    }
1004
1005    fn first_set_var(&self) -> &'static str {
1006        first_set_oauth_env(
1007            self.issuer.as_deref(),
1008            self.audience.as_deref(),
1009            self.jwks_uri.as_deref(),
1010            self.allowed_algorithms.as_deref(),
1011            self.proxy_strip_resource_param.as_deref(),
1012        )
1013    }
1014}
1015
1016const _OBSERVABILITY_CONFIG_DOC_ANCHOR: &str = "ObservabilityConfig";
1017
1018#[cfg(not(feature = "oauth"))]
1019fn reject_oauth_env_overrides(oauth_env: &OAuthEnvOverrides) -> Result<(), RmcpServerKitError> {
1020    if oauth_env.is_set() {
1021        let var = oauth_env.first_set_var();
1022        Err(RmcpServerKitError::Config(format!(
1023            "{var} requires the `oauth` feature"
1024        )))
1025    } else {
1026        Ok(())
1027    }
1028}
1029
1030fn first_set_oauth_env(
1031    issuer: Option<&str>,
1032    audience: Option<&str>,
1033    jwks_uri: Option<&str>,
1034    allowed_algorithms: Option<&str>,
1035    proxy_strip_resource_param: Option<&str>,
1036) -> &'static str {
1037    if issuer.is_some() {
1038        SERVER_OAUTH_ISSUER_ENV
1039    } else if audience.is_some() {
1040        SERVER_OAUTH_AUDIENCE_ENV
1041    } else if jwks_uri.is_some() {
1042        SERVER_OAUTH_JWKS_URI_ENV
1043    } else if allowed_algorithms.is_some() {
1044        SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV
1045    } else if proxy_strip_resource_param.is_some() {
1046        SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV
1047    } else {
1048        SERVER_OAUTH_ISSUER_ENV
1049    }
1050}
1051
1052fn parse_duration_field(field: &str, value: &str) -> Result<Duration, RmcpServerKitError> {
1053    humantime::parse_duration(value).map_err(|error| {
1054        RmcpServerKitError::Config(format!("invalid duration for {field}: {value:?}: {error}"))
1055    })
1056}
1057
1058/// Observability settings (reusable across MCP projects).
1059#[derive(Deserialize)]
1060#[serde(deny_unknown_fields)]
1061#[allow(
1062    clippy::struct_excessive_bools,
1063    reason = "observability configuration is a flat TOML schema with independent boolean feature flags"
1064)]
1065#[non_exhaustive]
1066pub struct ObservabilityConfig {
1067    /// `tracing` log level / env filter string (e.g. `info,rmcp_server_kit=debug`).
1068    #[serde(default = "default_log_level")]
1069    pub log_level: String,
1070    /// Log output format: `json`, `pretty`, or `text` (default: `pretty`).
1071    #[serde(default = "default_log_format")]
1072    pub log_format: String,
1073    /// Optional path to an append-only audit log file.
1074    pub audit_log_path: Option<PathBuf>,
1075    /// Emit inbound HTTP request headers at DEBUG level in transport logs.
1076    /// Sensitive headers remain redacted when enabled.
1077    #[serde(default)]
1078    pub log_request_headers: bool,
1079    /// Enable the Prometheus metrics endpoint.
1080    #[serde(default)]
1081    pub metrics_enabled: bool,
1082    /// Bind address for the Prometheus metrics listener.
1083    #[serde(default = "default_metrics_bind")]
1084    pub metrics_bind: String,
1085    /// Log OAuth access tokens in plaintext. Defaults to redacted; enabling
1086    /// writes secrets to logs and is for local debugging only. Process-wide,
1087    /// not per-server.
1088    #[serde(default)]
1089    pub log_plaintext_oauth_tokens: bool,
1090    /// Log OAuth claim values in plaintext. Defaults to redacted; enabling
1091    /// writes secrets to logs and is for local debugging only. Process-wide,
1092    /// not per-server.
1093    #[serde(default)]
1094    pub log_oauth_claim_values: bool,
1095    /// Log tool-call arguments and identity fields in plaintext. Defaults to
1096    /// redacted; enabling writes secrets to logs and is for local debugging
1097    /// only. Process-wide, not per-server.
1098    #[serde(default)]
1099    pub log_tool_call_arguments: bool,
1100    /// Log the `error_description` an authorization server returns on a failed
1101    /// RFC 8693 token exchange. Defaults to redacted; the value is free-form
1102    /// upstream text that may reflect request parameters back. Process-wide,
1103    /// not per-server.
1104    #[serde(default)]
1105    pub log_upstream_error_bodies: bool,
1106}
1107
1108/// Hand-written so `audit_log_path` never reaches a log.
1109///
1110/// SECURITY: the audit log's location is operational metadata an attacker can
1111/// use to find or tamper with the audit trail. Presence is still reported;
1112/// only the path is withheld. `observability_config_debug_lists_every_field`
1113/// fails if a field is added without being rendered here.
1114impl std::fmt::Debug for ObservabilityConfig {
1115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1116        f.debug_struct("ObservabilityConfig")
1117            .field("log_level", &self.log_level)
1118            .field("log_format", &self.log_format)
1119            .field(
1120                "audit_log_path",
1121                &self.audit_log_path.as_ref().map(|_| "[REDACTED]"),
1122            )
1123            .field("log_request_headers", &self.log_request_headers)
1124            .field("metrics_enabled", &self.metrics_enabled)
1125            .field("metrics_bind", &self.metrics_bind)
1126            .field(
1127                "log_plaintext_oauth_tokens",
1128                &self.log_plaintext_oauth_tokens,
1129            )
1130            .field("log_oauth_claim_values", &self.log_oauth_claim_values)
1131            .field("log_tool_call_arguments", &self.log_tool_call_arguments)
1132            .field("log_upstream_error_bodies", &self.log_upstream_error_bodies)
1133            .finish()
1134    }
1135}
1136
1137impl Default for ObservabilityConfig {
1138    fn default() -> Self {
1139        Self {
1140            log_level: default_log_level(),
1141            log_format: default_log_format(),
1142            audit_log_path: None,
1143            log_request_headers: false,
1144            metrics_enabled: false,
1145            metrics_bind: default_metrics_bind(),
1146            log_plaintext_oauth_tokens: false,
1147            log_oauth_claim_values: false,
1148            log_tool_call_arguments: false,
1149            log_upstream_error_bodies: false,
1150        }
1151    }
1152}
1153
1154/// A violation of an invariant that BOTH config validators must enforce.
1155///
1156/// The variants exist so the two validators cannot drift in *ordering* while
1157/// still reporting their own historical wording: `McpServerConfig::check`
1158/// distinguishes which TLS half is missing, whereas `validate_server_config`
1159/// emits one combined message. Callers map variants to their own text.
1160pub(crate) enum SharedConfigViolation {
1161    /// `admin_enabled` without an enabled auth config.
1162    AdminRequiresAuth,
1163    /// `tls_cert_path` set, `tls_key_path` missing.
1164    TlsCertWithoutKey,
1165    /// `tls_key_path` set, `tls_cert_path` missing.
1166    TlsKeyWithoutCert,
1167    /// `auth.mtls` configured on a listener without both TLS halves.
1168    MtlsRequiresTls,
1169}
1170
1171/// Evaluate the three invariants shared by both validators, in the one order
1172/// both must report.
1173///
1174/// Scope is deliberately limited to these three. Everything else each
1175/// validator checks (TOML-only parsing, timeouts, OAuth, security headers,
1176/// env overrides, bridge behaviour) stays where it is: those inputs are not
1177/// common to both types, and folding them in here would change validation
1178/// behaviour that no test currently pins.
1179#[allow(
1180    clippy::fn_params_excessive_bools,
1181    reason = "these are the five independent predicates both validators evaluate; a params struct would carry the same five bools and only relocate the lint"
1182)]
1183pub(crate) fn check_shared_config_invariants(
1184    admin_enabled: bool,
1185    auth_enabled: bool,
1186    has_tls_cert: bool,
1187    has_tls_key: bool,
1188    has_mtls: bool,
1189) -> Result<(), SharedConfigViolation> {
1190    if admin_enabled && !auth_enabled {
1191        return Err(SharedConfigViolation::AdminRequiresAuth);
1192    }
1193    match (has_tls_cert, has_tls_key) {
1194        (true, false) => return Err(SharedConfigViolation::TlsCertWithoutKey),
1195        (false, true) => return Err(SharedConfigViolation::TlsKeyWithoutCert),
1196        _ => {}
1197    }
1198    if has_mtls && !(has_tls_cert && has_tls_key) {
1199        return Err(SharedConfigViolation::MtlsRequiresTls);
1200    }
1201    Ok(())
1202}
1203
1204/// Validate the generic server config fields.
1205///
1206/// # Errors
1207///
1208/// Returns `RmcpServerKitError::Config` on invalid values.
1209pub fn validate_server_config(server: &ServerConfig) -> crate::error::Result<()> {
1210    use crate::error::RmcpServerKitError;
1211
1212    if server.listen_port == 0 {
1213        return Err(RmcpServerKitError::Config(
1214            "listen_port must be nonzero".into(),
1215        ));
1216    }
1217
1218    // These three checks are delegated to `check_shared_config_invariants` so
1219    // this validator and `McpServerConfig::check` cannot drift in ordering: a
1220    // config invalid in more than one of these ways reports the same first
1221    // error whichever validator a consumer reaches for. Wording stays local
1222    // because the two types report the TLS pairing failure differently.
1223    // Checks outside this group are not ordered against the builder: the two
1224    // types accept different inputs (`listen_port` has no builder analog),
1225    // so full first-error parity is neither achievable nor claimed.
1226    if let Err(violation) = check_shared_config_invariants(
1227        server.admin_enabled,
1228        server.auth.as_ref().is_some_and(|a| a.enabled),
1229        server.tls_cert_path.is_some(),
1230        server.tls_key_path.is_some(),
1231        server.auth.as_ref().is_some_and(|a| a.mtls.is_some()),
1232    ) {
1233        return Err(RmcpServerKitError::Config(
1234            match violation {
1235                SharedConfigViolation::AdminRequiresAuth => {
1236                    "admin_enabled=true requires auth to be configured and enabled"
1237                }
1238                SharedConfigViolation::TlsCertWithoutKey
1239                | SharedConfigViolation::TlsKeyWithoutCert => {
1240                    "tls_cert_path and tls_key_path must both be set or both omitted"
1241                }
1242                // A consumer calling only `validate_server_config` on TOML
1243                // would otherwise be told the config is valid while
1244                // client-certificate authentication is silently inert: a
1245                // plaintext listener never performs a handshake and so never
1246                // extracts an identity.
1247                SharedConfigViolation::MtlsRequiresTls => {
1248                    "auth.mtls requires TLS: set both tls_cert_path and tls_key_path \
1249                     (mTLS client certificates cannot be verified on a plaintext listener)"
1250                }
1251            }
1252            .into(),
1253        ));
1254    }
1255
1256    if server.max_concurrent_requests == Some(0) {
1257        return Err(RmcpServerKitError::Config(
1258            "max_concurrent_requests must be nonzero when set".into(),
1259        ));
1260    }
1261
1262    if server.extra_route_rate_limit == Some(0) {
1263        return Err(RmcpServerKitError::Config(
1264            "server.extra_route_rate_limit must be greater than zero".into(),
1265        ));
1266    }
1267
1268    validate_rate_limit_knobs(server)?;
1269    validate_mtls_knobs(server)?;
1270    validate_trusted_forwarder_config(server)?;
1271
1272    if server.admin_enabled && server.admin_role.trim().is_empty() {
1273        return Err(RmcpServerKitError::Config(
1274            "admin_role must not be empty".into(),
1275        ));
1276    }
1277
1278    for (field, value) in [
1279        ("server.shutdown_timeout", server.shutdown_timeout.as_str()),
1280        ("server.request_timeout", server.request_timeout.as_str()),
1281        (
1282            "server.session_idle_timeout",
1283            server.session_idle_timeout.as_str(),
1284        ),
1285        ("server.sse_keep_alive", server.sse_keep_alive.as_str()),
1286        (
1287            "server.tls_handshake_timeout",
1288            server.tls_handshake_timeout.as_str(),
1289        ),
1290    ] {
1291        if humantime::parse_duration(value).is_err() {
1292            return Err(RmcpServerKitError::Config(format!(
1293                "invalid duration for {field}: {value:?}"
1294            )));
1295        }
1296    }
1297
1298    // The handshake deadline must be a positive duration: a zero value
1299    // would reap every TLS handshake before it could complete. Mirrors
1300    // check #11 in `McpServerConfig::check`.
1301    if humantime::parse_duration(&server.tls_handshake_timeout).is_ok_and(|d| d == Duration::ZERO) {
1302        return Err(RmcpServerKitError::Config(
1303            "server.tls_handshake_timeout must be greater than zero".into(),
1304        ));
1305    }
1306
1307    // A zero-permit handshake semaphore would never admit a handshake,
1308    // deadlocking the TLS accept path. Mirrors check #12 in
1309    // `McpServerConfig::check`.
1310    if server.max_concurrent_tls_handshakes == 0 {
1311        return Err(RmcpServerKitError::Config(
1312            "server.max_concurrent_tls_handshakes must be greater than zero".into(),
1313        ));
1314    }
1315
1316    Ok(())
1317}
1318
1319/// Validate the rate-limit burst knobs of a TOML [`ServerConfig`]: zero
1320/// bursts and orphan bursts fail fast (mirrors `McpServerConfig::check`;
1321/// the auth bursts have no orphan rule — their base rates always resolve).
1322fn validate_rate_limit_knobs(server: &ServerConfig) -> crate::error::Result<()> {
1323    use crate::error::RmcpServerKitError;
1324
1325    if server.tool_rate_limit_burst == Some(0) {
1326        return Err(RmcpServerKitError::Config(
1327            "server.tool_rate_limit_burst must be greater than zero".into(),
1328        ));
1329    }
1330    if server.extra_route_rate_limit_burst == Some(0) {
1331        return Err(RmcpServerKitError::Config(
1332            "server.extra_route_rate_limit_burst must be greater than zero".into(),
1333        ));
1334    }
1335    if server.tool_rate_limit_burst.is_some() && server.tool_rate_limit.is_none() {
1336        return Err(RmcpServerKitError::Config(
1337            "server.tool_rate_limit_burst requires server.tool_rate_limit".into(),
1338        ));
1339    }
1340    if server.extra_route_rate_limit_burst.is_some() && server.extra_route_rate_limit.is_none() {
1341        return Err(RmcpServerKitError::Config(
1342            "server.extra_route_rate_limit_burst requires server.extra_route_rate_limit".into(),
1343        ));
1344    }
1345    if !server.extra_route_rate_limit_exempt_paths.is_empty()
1346        && server.extra_route_rate_limit.is_none()
1347    {
1348        return Err(RmcpServerKitError::Config(
1349            "server.extra_route_rate_limit_exempt_paths requires server.extra_route_rate_limit"
1350                .into(),
1351        ));
1352    }
1353    for path in &server.extra_route_rate_limit_exempt_paths {
1354        if path.is_empty() || !path.starts_with('/') {
1355            return Err(RmcpServerKitError::Config(format!(
1356                "server.extra_route_rate_limit_exempt_paths entries must be non-empty and start with '/': {path:?}"
1357            )));
1358        }
1359    }
1360    if let Some(auth) = server.auth.as_ref() {
1361        auth.check_oauth_feature()?;
1362    }
1363    if let Some(rl) = server.auth.as_ref().and_then(|a| a.rate_limit.as_ref()) {
1364        (rl.max_attempts_per_minute != 0).ok_or_else(|| {
1365            RmcpServerKitError::Config(
1366                "auth.rate_limit.max_attempts_per_minute must be nonzero".into(),
1367            )
1368        })?;
1369        if rl.burst == Some(0) {
1370            return Err(RmcpServerKitError::Config(
1371                "auth.rate_limit.burst must be greater than zero".into(),
1372            ));
1373        }
1374        if rl.pre_auth_burst == Some(0) {
1375            return Err(RmcpServerKitError::Config(
1376                "auth.rate_limit.pre_auth_burst must be greater than zero".into(),
1377            ));
1378        }
1379        // `0` here does not mean "unlimited" -- `build_pre_auth_limiter`
1380        // falls back to DEFAULT_PRE_AUTH_RATE, so a typo silently *raises*
1381        // the pre-auth quota (e.g. 1/min + 0 yields 300/min, not 10/min)
1382        // and weakens the gate that shields Argon2 from CPU-spray.
1383        (rl.pre_auth_max_per_minute != Some(0)).ok_or_else(|| {
1384            RmcpServerKitError::Config(
1385                "auth.rate_limit.pre_auth_max_per_minute must be nonzero when set".into(),
1386            )
1387        })?;
1388    }
1389    Ok(())
1390}
1391
1392fn validate_mtls_knobs(server: &ServerConfig) -> crate::error::Result<()> {
1393    use crate::error::RmcpServerKitError;
1394
1395    if let Some(mtls) = server.auth.as_ref().and_then(|a| a.mtls.as_ref()) {
1396        (mtls.crl_max_concurrent_fetches != 0).ok_or_else(|| {
1397            RmcpServerKitError::Config(
1398                "auth.mtls.crl_max_concurrent_fetches must be nonzero".into(),
1399            )
1400        })?;
1401        (mtls.crl_discovery_rate_per_min != 0).ok_or_else(|| {
1402            RmcpServerKitError::Config(
1403                "auth.mtls.crl_discovery_rate_per_min must be nonzero".into(),
1404            )
1405        })?;
1406        (mtls.crl_max_host_semaphores != 0).ok_or_else(|| {
1407            RmcpServerKitError::Config("auth.mtls.crl_max_host_semaphores must be nonzero".into())
1408        })?;
1409        (mtls.crl_max_seen_urls != 0).ok_or_else(|| {
1410            RmcpServerKitError::Config("auth.mtls.crl_max_seen_urls must be nonzero".into())
1411        })?;
1412        (mtls.crl_max_cache_entries != 0).ok_or_else(|| {
1413            RmcpServerKitError::Config("auth.mtls.crl_max_cache_entries must be nonzero".into())
1414        })?;
1415        // `0` rejects every non-empty CRL body at the streaming cap, so CRL
1416        // fetching never succeeds. Under the default `crl_deny_on_unavailable
1417        // = true` that fails every CDP-bearing handshake rather than loudly
1418        // reporting the misconfiguration.
1419        (mtls.crl_max_response_bytes != 0).ok_or_else(|| {
1420            RmcpServerKitError::Config("auth.mtls.crl_max_response_bytes must be nonzero".into())
1421        })?;
1422    }
1423    Ok(())
1424}
1425
1426/// Validate the trusted-forwarder knobs of a TOML [`ServerConfig`]
1427/// (mirrors `McpServerConfig::check_trusted_forwarder`).
1428fn validate_trusted_forwarder_config(server: &ServerConfig) -> crate::error::Result<()> {
1429    use crate::error::RmcpServerKitError;
1430
1431    for entry in &server.trusted_proxies {
1432        crate::transport::validate_trusted_proxy_entry(entry)
1433            .map_err(RmcpServerKitError::Config)?;
1434    }
1435    if server.forwarded_header.is_some() && server.trusted_proxies.is_empty() {
1436        return Err(RmcpServerKitError::Config(
1437            "server.forwarded_header requires server.trusted_proxies to be nonempty".into(),
1438        ));
1439    }
1440    if server.trusted_forwarder_max_entries == 0
1441        || server.trusted_forwarder_max_entries > crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES
1442    {
1443        return Err(RmcpServerKitError::Config(format!(
1444            "server.trusted_forwarder_max_entries must be in 1..={}, got {}",
1445            crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES,
1446            server.trusted_forwarder_max_entries
1447        )));
1448    }
1449    Ok(())
1450}
1451
1452/// Validate observability config fields.
1453///
1454/// # Errors
1455///
1456/// Returns `RmcpServerKitError::Config` on invalid values.
1457pub fn validate_observability_config(obs: &ObservabilityConfig) -> crate::error::Result<()> {
1458    use tracing_subscriber::EnvFilter;
1459
1460    use crate::error::RmcpServerKitError;
1461
1462    if EnvFilter::try_new(&obs.log_level).is_err() {
1463        return Err(RmcpServerKitError::Config(format!(
1464            "invalid log_level: {:?} (expected a valid tracing filter directive, e.g. \"info\", \"debug,hyper=warn\")",
1465            obs.log_level
1466        )));
1467    }
1468    let valid_formats = ["json", "pretty", "text"];
1469    if !valid_formats.contains(&obs.log_format.as_str()) {
1470        return Err(RmcpServerKitError::Config(format!(
1471            "invalid log_format: {:?} (expected one of: {valid_formats:?})",
1472            obs.log_format
1473        )));
1474    }
1475
1476    Ok(())
1477}
1478
1479// - Default value functions -
1480
1481fn default_listen_addr() -> String {
1482    "127.0.0.1".into()
1483}
1484fn default_listen_port() -> u16 {
1485    8443
1486}
1487fn default_shutdown_timeout() -> String {
1488    "30s".into()
1489}
1490fn default_request_timeout() -> String {
1491    "120s".into()
1492}
1493const fn default_max_request_body() -> usize {
1494    1024 * 1024
1495}
1496const fn default_trusted_forwarder_max_entries() -> usize {
1497    crate::forwarded::MAX_SCANNED_ENTRIES
1498}
1499const fn default_expose_build_metadata() -> bool {
1500    false
1501}
1502fn default_security_headers() -> SecurityHeadersConfig {
1503    SecurityHeadersConfig::default()
1504}
1505fn default_log_level() -> String {
1506    "info,rmcp=warn".into()
1507}
1508fn default_log_format() -> String {
1509    "pretty".into()
1510}
1511fn default_metrics_bind() -> String {
1512    "127.0.0.1:9090".into()
1513}
1514fn default_session_idle_timeout() -> String {
1515    "20m".into()
1516}
1517fn default_tls_handshake_timeout() -> String {
1518    "10s".into()
1519}
1520const fn default_max_concurrent_tls_handshakes() -> usize {
1521    256
1522}
1523fn default_admin_role() -> String {
1524    "admin".into()
1525}
1526fn default_compression_min_size() -> u16 {
1527    1024
1528}
1529fn default_sse_keep_alive() -> String {
1530    "15s".into()
1531}
1532
1533#[cfg(test)]
1534mod tests {
1535    #![allow(
1536        clippy::unwrap_used,
1537        clippy::expect_used,
1538        clippy::panic,
1539        clippy::indexing_slicing,
1540        clippy::unwrap_in_result,
1541        clippy::print_stdout,
1542        clippy::print_stderr,
1543        deprecated,
1544        reason = "test-only relaxations; production code uses ? and tracing"
1545    )]
1546    use std::{collections::HashSet, time::Duration};
1547
1548    use super::*;
1549    use crate::transport::McpServerConfig;
1550
1551    #[derive(Debug, Deserialize)]
1552    #[serde(deny_unknown_fields)]
1553    struct RootConfig {
1554        server: ServerConfig,
1555    }
1556
1557    fn server_from_root_toml(toml: &str) -> ServerConfig {
1558        toml::from_str::<RootConfig>(toml).unwrap().server
1559    }
1560
1561    // -- ServerConfig defaults --
1562
1563    #[test]
1564    fn server_config_defaults() {
1565        let cfg = ServerConfig::default();
1566        assert_eq!(cfg.listen_addr, "127.0.0.1");
1567        assert_eq!(cfg.listen_port, 8443);
1568        assert!(cfg.tls_cert_path.is_none());
1569        assert!(cfg.tls_key_path.is_none());
1570        assert_eq!(cfg.shutdown_timeout, "30s");
1571        assert_eq!(cfg.request_timeout, "120s");
1572        assert!(cfg.allowed_origins.is_empty());
1573        assert!(!cfg.stdio_enabled);
1574        assert!(cfg.tool_rate_limit.is_none());
1575        assert_eq!(cfg.key_eviction_policy, KeyEvictionPolicy::EvictLru);
1576        assert_eq!(cfg.session_idle_timeout, "20m");
1577        assert_eq!(cfg.sse_keep_alive, "15s");
1578        assert!(cfg.public_url.is_none());
1579    }
1580
1581    #[test]
1582    fn observability_config_defaults() {
1583        let cfg = ObservabilityConfig::default();
1584        assert_eq!(cfg.log_level, "info,rmcp=warn");
1585        assert_eq!(cfg.log_format, "pretty");
1586        assert!(cfg.audit_log_path.is_none());
1587        assert!(!cfg.log_request_headers);
1588        assert!(!cfg.metrics_enabled);
1589        assert_eq!(cfg.metrics_bind, "127.0.0.1:9090");
1590        assert!(!cfg.log_plaintext_oauth_tokens);
1591        assert!(!cfg.log_oauth_claim_values);
1592        assert!(!cfg.log_tool_call_arguments);
1593    }
1594
1595    // -- validate_server_config --
1596
1597    #[test]
1598    fn valid_server_config_passes() {
1599        let cfg = ServerConfig::default();
1600        assert!(validate_server_config(&cfg).is_ok());
1601    }
1602
1603    #[test]
1604    fn admin_auth_check_precedes_tls_and_mtls_like_the_builder() {
1605        // A config invalid in all three ordered ways must report the same
1606        // first error here as `McpServerConfig::check` does, otherwise the
1607        // TOML and builder paths disagree about what is wrong.
1608        let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
1609        auth.enabled = false;
1610        auth.mtls = Some(valid_mtls_config());
1611        let cfg = ServerConfig {
1612            admin_enabled: true,
1613            auth: Some(auth),
1614            tls_cert_path: None,
1615            tls_key_path: None,
1616            ..ServerConfig::default()
1617        };
1618        let err = validate_server_config(&cfg).unwrap_err().to_string();
1619        assert!(
1620            err.contains("admin_enabled=true requires auth"),
1621            "admin/auth must fire before TLS and mTLS checks; got {err}"
1622        );
1623    }
1624
1625    fn classify_shared_check(err: RmcpServerKitError) -> SharedCheck {
1626        match err {
1627            RmcpServerKitError::Config(msg) => {
1628                if msg.contains("admin_enabled=true requires auth") {
1629                    SharedCheck::AdminAuth
1630                } else if msg.contains("must both be set or both omitted")
1631                    || msg.contains("tls_cert_path is set but tls_key_path is missing")
1632                    || msg.contains("tls_key_path is set but tls_cert_path is missing")
1633                {
1634                    SharedCheck::TlsPairing
1635                } else if msg.contains("auth.mtls requires TLS") {
1636                    SharedCheck::MtlsRequiresTls
1637                } else {
1638                    panic!("unclassified shared-check config error: {msg}");
1639                }
1640            }
1641            RmcpServerKitError::Auth(msg) => {
1642                panic!("expected Config error, got Auth({msg})");
1643            }
1644            RmcpServerKitError::Rbac(msg) => {
1645                panic!("expected Config error, got Rbac({msg})");
1646            }
1647            RmcpServerKitError::RateLimited(msg) => {
1648                panic!("expected Config error, got RateLimited({msg})");
1649            }
1650            RmcpServerKitError::RateLimitedFor {
1651                message,
1652                retry_after,
1653            } => {
1654                panic!("expected Config error, got RateLimitedFor({message}, {retry_after:?})");
1655            }
1656            RmcpServerKitError::Io(error) => {
1657                panic!("expected Config error, got Io({error})");
1658            }
1659            RmcpServerKitError::Json(error) => {
1660                panic!("expected Config error, got Json({error})");
1661            }
1662            RmcpServerKitError::Toml(error) => {
1663                panic!("expected Config error, got Toml({error})");
1664            }
1665            RmcpServerKitError::Tls(msg) => {
1666                panic!("expected Config error, got Tls({msg})");
1667            }
1668            RmcpServerKitError::Startup(msg) => {
1669                panic!("expected Config error, got Startup({msg})");
1670            }
1671            RmcpServerKitError::Internal(msg) => {
1672                panic!("expected Config error, got Internal({msg})");
1673            }
1674            #[cfg(feature = "metrics")]
1675            RmcpServerKitError::Metrics(msg) => {
1676                panic!("expected Config error, got Metrics({msg})");
1677            }
1678        }
1679    }
1680
1681    #[derive(Debug, Clone, Copy)]
1682    enum AdminSetting {
1683        Valid,
1684        EnabledWithDisabledAuth,
1685    }
1686
1687    #[derive(Debug, Clone, Copy)]
1688    enum TlsSetting {
1689        Absent,
1690        CertOnly,
1691        KeyOnly,
1692    }
1693
1694    #[derive(Debug, Clone, Copy)]
1695    enum MtlsSetting {
1696        Absent,
1697        WithoutTls,
1698        WithoutTlsAndInvalidCapacity,
1699    }
1700
1701    #[derive(Debug)]
1702    struct SharedCheckCase {
1703        name: &'static str,
1704        admin: AdminSetting,
1705        tls_variants: &'static [TlsSetting],
1706        mtls: MtlsSetting,
1707        expected: SharedCheck,
1708    }
1709
1710    const ABSENT_TLS: &[TlsSetting] = &[TlsSetting::Absent];
1711    const BOTH_PARTIAL_TLS_DIRECTIONS: &[TlsSetting] = &[TlsSetting::CertOnly, TlsSetting::KeyOnly];
1712
1713    #[test]
1714    fn toml_and_builder_validators_report_the_expected_shared_check_order() {
1715        let cases = [
1716            SharedCheckCase {
1717                name: "case 1: admin/auth dependency only",
1718                admin: AdminSetting::EnabledWithDisabledAuth,
1719                tls_variants: ABSENT_TLS,
1720                mtls: MtlsSetting::Absent,
1721                expected: SharedCheck::AdminAuth,
1722            },
1723            SharedCheckCase {
1724                name: "case 2: TLS pairing only",
1725                admin: AdminSetting::Valid,
1726                tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1727                mtls: MtlsSetting::Absent,
1728                expected: SharedCheck::TlsPairing,
1729            },
1730            SharedCheckCase {
1731                name: "case 3: mTLS without TLS only",
1732                admin: AdminSetting::Valid,
1733                tls_variants: ABSENT_TLS,
1734                mtls: MtlsSetting::WithoutTls,
1735                expected: SharedCheck::MtlsRequiresTls,
1736            },
1737            SharedCheckCase {
1738                name: "case 4: admin/auth dependency before TLS pairing",
1739                admin: AdminSetting::EnabledWithDisabledAuth,
1740                tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1741                mtls: MtlsSetting::Absent,
1742                expected: SharedCheck::AdminAuth,
1743            },
1744            SharedCheckCase {
1745                name: "case 5: admin/auth dependency before mTLS without TLS",
1746                admin: AdminSetting::EnabledWithDisabledAuth,
1747                tls_variants: ABSENT_TLS,
1748                mtls: MtlsSetting::WithoutTls,
1749                expected: SharedCheck::AdminAuth,
1750            },
1751            SharedCheckCase {
1752                name: "case 6: TLS pairing before mTLS without TLS",
1753                admin: AdminSetting::Valid,
1754                tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1755                mtls: MtlsSetting::WithoutTls,
1756                expected: SharedCheck::TlsPairing,
1757            },
1758            SharedCheckCase {
1759                name: "case 7: admin/auth dependency before TLS pairing and mTLS without TLS",
1760                admin: AdminSetting::EnabledWithDisabledAuth,
1761                tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1762                mtls: MtlsSetting::WithoutTls,
1763                expected: SharedCheck::AdminAuth,
1764            },
1765            SharedCheckCase {
1766                name: "case 8: mTLS without TLS before mTLS capacity knobs",
1767                admin: AdminSetting::Valid,
1768                tls_variants: ABSENT_TLS,
1769                mtls: MtlsSetting::WithoutTlsAndInvalidCapacity,
1770                expected: SharedCheck::MtlsRequiresTls,
1771            },
1772        ];
1773
1774        for case in cases {
1775            for tls in case.tls_variants {
1776                let config = shared_check_config(case.admin, *tls, case.mtls);
1777
1778                let toml_class = classify_toml_validator_error(&config);
1779                assert_eq!(
1780                    toml_class, case.expected,
1781                    "{} with {:?} must fail TOML validation at {:?}",
1782                    case.name, tls, case.expected
1783                );
1784
1785                let builder_class = classify_builder_validator_error(&config);
1786                assert_eq!(
1787                    builder_class, case.expected,
1788                    "{} with {:?} must fail builder validation at {:?}",
1789                    case.name, tls, case.expected
1790                );
1791            }
1792        }
1793    }
1794
1795    fn classify_toml_validator_error(config: &ServerConfig) -> SharedCheck {
1796        let err = validate_server_config(config).expect_err("config must fail TOML validation");
1797        classify_shared_check(err)
1798    }
1799
1800    fn classify_builder_validator_error(config: &ServerConfig) -> SharedCheck {
1801        let builder_config = config
1802            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:1", "t", "0.0.0"))
1803            .expect("valid durations must bridge into McpServerConfig");
1804        let err = builder_config
1805            .validate()
1806            .expect_err("config must fail builder validation");
1807        classify_shared_check(err)
1808    }
1809
1810    fn shared_check_config(
1811        admin: AdminSetting,
1812        tls: TlsSetting,
1813        mtls: MtlsSetting,
1814    ) -> ServerConfig {
1815        let mut config = ServerConfig::default();
1816        apply_admin_setting(&mut config, admin);
1817        apply_tls_setting(&mut config, tls);
1818        apply_mtls_setting(&mut config, admin, mtls);
1819        config
1820    }
1821
1822    fn apply_admin_setting(config: &mut ServerConfig, admin: AdminSetting) {
1823        match admin {
1824            AdminSetting::Valid => {}
1825            AdminSetting::EnabledWithDisabledAuth => {
1826                config.admin_enabled = true;
1827                let auth = config
1828                    .auth
1829                    .get_or_insert_with(|| crate::auth::AuthConfig::with_keys(vec![]));
1830                auth.enabled = false;
1831            }
1832        }
1833    }
1834
1835    fn apply_tls_setting(config: &mut ServerConfig, tls: TlsSetting) {
1836        match tls {
1837            TlsSetting::Absent => {}
1838            TlsSetting::CertOnly => {
1839                config.tls_cert_path = Some("/tmp/cert.pem".into());
1840            }
1841            TlsSetting::KeyOnly => {
1842                config.tls_key_path = Some("/tmp/key.pem".into());
1843            }
1844        }
1845    }
1846
1847    fn apply_mtls_setting(config: &mut ServerConfig, admin: AdminSetting, mtls: MtlsSetting) {
1848        match mtls {
1849            MtlsSetting::Absent => {}
1850            MtlsSetting::WithoutTls => {
1851                let enabled = matches!(admin, AdminSetting::Valid);
1852                let auth = config
1853                    .auth
1854                    .get_or_insert_with(|| crate::auth::AuthConfig::with_keys(vec![]));
1855                auth.enabled = enabled;
1856                auth.mtls = Some(valid_mtls_config());
1857            }
1858            MtlsSetting::WithoutTlsAndInvalidCapacity => {
1859                let auth = config
1860                    .auth
1861                    .get_or_insert_with(|| crate::auth::AuthConfig::with_keys(vec![]));
1862                auth.enabled = true;
1863                let mut mtls_config = valid_mtls_config();
1864                mtls_config.crl_max_concurrent_fetches = 0;
1865                auth.mtls = Some(mtls_config);
1866            }
1867        }
1868    }
1869
1870    #[test]
1871    fn mtls_without_tls_rejected() {
1872        let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
1873        auth.mtls = Some(valid_mtls_config());
1874        let cfg = ServerConfig {
1875            auth: Some(auth),
1876            tls_cert_path: None,
1877            tls_key_path: None,
1878            ..ServerConfig::default()
1879        };
1880        let err = validate_server_config(&cfg).unwrap_err();
1881        let msg = err.to_string();
1882        assert!(
1883            msg.contains("tls_cert_path") && msg.contains("tls_key_path"),
1884            "{msg}"
1885        );
1886    }
1887
1888    #[test]
1889    fn mtls_with_tls_accepted() {
1890        let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
1891        auth.mtls = Some(valid_mtls_config());
1892        let cfg = ServerConfig {
1893            auth: Some(auth),
1894            tls_cert_path: Some("cert.pem".into()),
1895            tls_key_path: Some("key.pem".into()),
1896            ..ServerConfig::default()
1897        };
1898        assert!(validate_server_config(&cfg).is_ok());
1899    }
1900
1901    #[test]
1902    fn zero_port_rejected() {
1903        let cfg = ServerConfig {
1904            listen_port: 0,
1905            ..ServerConfig::default()
1906        };
1907        let err = validate_server_config(&cfg).unwrap_err();
1908        assert!(err.to_string().contains("listen_port"));
1909    }
1910
1911    #[test]
1912    fn zero_extra_route_rate_limit_rejected() {
1913        let cfg = ServerConfig {
1914            extra_route_rate_limit: Some(0),
1915            ..ServerConfig::default()
1916        };
1917        let err = validate_server_config(&cfg).unwrap_err();
1918        assert!(err.to_string().contains("extra_route_rate_limit"));
1919    }
1920
1921    #[test]
1922    fn zero_burst_knobs_rejected() {
1923        let cfg = ServerConfig {
1924            tool_rate_limit: Some(10),
1925            tool_rate_limit_burst: Some(0),
1926            ..ServerConfig::default()
1927        };
1928        let err = validate_server_config(&cfg).unwrap_err();
1929        assert!(err.to_string().contains("tool_rate_limit_burst"));
1930
1931        let cfg = ServerConfig {
1932            extra_route_rate_limit: Some(10),
1933            extra_route_rate_limit_burst: Some(0),
1934            ..ServerConfig::default()
1935        };
1936        let err = validate_server_config(&cfg).unwrap_err();
1937        assert!(err.to_string().contains("extra_route_rate_limit_burst"));
1938    }
1939
1940    #[test]
1941    fn orphan_burst_knobs_rejected() {
1942        let cfg = ServerConfig {
1943            tool_rate_limit_burst: Some(5),
1944            ..ServerConfig::default()
1945        };
1946        let err = validate_server_config(&cfg).unwrap_err();
1947        assert!(err.to_string().contains("requires server.tool_rate_limit"));
1948
1949        let cfg = ServerConfig {
1950            extra_route_rate_limit_burst: Some(5),
1951            ..ServerConfig::default()
1952        };
1953        let err = validate_server_config(&cfg).unwrap_err();
1954        assert!(
1955            err.to_string()
1956                .contains("requires server.extra_route_rate_limit")
1957        );
1958    }
1959
1960    #[test]
1961    fn exempt_paths_toml_roundtrip_and_validation() {
1962        let cfg: ServerConfig = toml::from_str(
1963            r#"
1964                extra_route_rate_limit = 60
1965                extra_route_rate_limit_exempt_paths = ["/.well-known/oauth-authorization-server"]
1966            "#,
1967        )
1968        .unwrap();
1969        assert_eq!(
1970            cfg.extra_route_rate_limit_exempt_paths,
1971            vec!["/.well-known/oauth-authorization-server".to_owned()]
1972        );
1973        assert!(validate_server_config(&cfg).is_ok());
1974    }
1975
1976    #[test]
1977    fn orphan_exempt_paths_rejected() {
1978        let cfg = ServerConfig {
1979            extra_route_rate_limit_exempt_paths: vec!["/ok".into()],
1980            ..ServerConfig::default()
1981        };
1982        let err = validate_server_config(&cfg).unwrap_err();
1983        assert!(
1984            err.to_string()
1985                .contains("requires server.extra_route_rate_limit")
1986        );
1987    }
1988
1989    #[test]
1990    fn malformed_exempt_paths_rejected() {
1991        for bad in ["", "no-slash"] {
1992            let cfg = ServerConfig {
1993                extra_route_rate_limit: Some(10),
1994                extra_route_rate_limit_exempt_paths: vec![bad.into()],
1995                ..ServerConfig::default()
1996            };
1997            let err = validate_server_config(&cfg).unwrap_err();
1998            assert!(
1999                err.to_string()
2000                    .contains("must be non-empty and start with '/'"),
2001                "entry {bad:?}: {err}"
2002            );
2003        }
2004    }
2005
2006    #[test]
2007    fn bad_trusted_proxy_entry_rejected() {
2008        let cfg = ServerConfig {
2009            trusted_proxies: vec!["not-a-cidr".into()],
2010            ..ServerConfig::default()
2011        };
2012        let err = validate_server_config(&cfg).unwrap_err();
2013        assert!(err.to_string().contains("trusted_proxies"));
2014    }
2015
2016    #[test]
2017    fn zero_prefix_trusted_proxy_rejected() {
2018        for entry in ["0.0.0.0/0", "::/0"] {
2019            let cfg = ServerConfig {
2020                trusted_proxies: vec![entry.into()],
2021                ..ServerConfig::default()
2022            };
2023            let err = validate_server_config(&cfg).unwrap_err();
2024            assert!(
2025                err.to_string().contains("prefix length 0"),
2026                "entry {entry:?}: {err}"
2027            );
2028        }
2029    }
2030
2031    #[test]
2032    fn toml_trusted_forwarder_max_entries_bounds_are_enforced() {
2033        let parse = |v: usize| -> crate::error::Result<()> {
2034            let cfg: ServerConfig =
2035                toml::from_str(&format!("trusted_forwarder_max_entries = {v}")).unwrap();
2036            validate_server_config(&cfg)
2037        };
2038        assert!(parse(0).is_err());
2039        assert!(parse(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES + 1).is_err());
2040        assert!(parse(1).is_ok());
2041        assert!(parse(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES).is_ok());
2042    }
2043
2044    #[test]
2045    fn toml_trusted_forwarder_max_entries_defaults_and_bridges() {
2046        let cfg: ServerConfig = toml::from_str("").unwrap();
2047        assert_eq!(
2048            cfg.trusted_forwarder_max_entries,
2049            crate::forwarded::MAX_SCANNED_ENTRIES
2050        );
2051        let base = crate::transport::McpServerConfig::new("127.0.0.1:8080", "t", "0");
2052        let src: ServerConfig =
2053            toml::from_str("trusted_forwarder_max_entries = 32").expect("parses");
2054        let bridged = src.apply_to_mcp_config(base).expect("bridges");
2055        assert_eq!(bridged.trusted_forwarder_max_entries, 32);
2056    }
2057
2058    #[test]
2059    fn cidr_and_bare_ip_proxy_entries_accepted() {
2060        let cfg = ServerConfig {
2061            trusted_proxies: vec!["10.0.0.0/8".into(), "192.0.2.1".into()],
2062            ..ServerConfig::default()
2063        };
2064        assert!(validate_server_config(&cfg).is_ok());
2065    }
2066
2067    #[test]
2068    fn forwarded_header_without_proxies_rejected() {
2069        let cfg = ServerConfig {
2070            forwarded_header: Some(crate::transport::ForwardedHeaderMode::Forwarded),
2071            ..ServerConfig::default()
2072        };
2073        let err = validate_server_config(&cfg).unwrap_err();
2074        assert!(err.to_string().contains("requires server.trusted_proxies"));
2075    }
2076
2077    #[test]
2078    fn zero_auth_bursts_rejected() {
2079        let auth = crate::auth::AuthConfig::with_keys(vec![])
2080            .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_burst(0));
2081        let cfg = ServerConfig {
2082            auth: Some(auth),
2083            ..ServerConfig::default()
2084        };
2085        let err = validate_server_config(&cfg).unwrap_err();
2086        assert!(err.to_string().contains("rate_limit.burst"));
2087
2088        let auth = crate::auth::AuthConfig::with_keys(vec![])
2089            .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(0));
2090        let cfg = ServerConfig {
2091            auth: Some(auth),
2092            ..ServerConfig::default()
2093        };
2094        let err = validate_server_config(&cfg).unwrap_err();
2095        assert!(err.to_string().contains("pre_auth_burst"));
2096    }
2097
2098    fn valid_mtls_config() -> crate::auth::MtlsConfig {
2099        crate::auth::MtlsConfig {
2100            ca_cert_path: "memory://ca.pem".into(),
2101            required: true,
2102            default_role: "viewer".into(),
2103            crl_enabled: true,
2104            crl_refresh_interval: None,
2105            crl_fetch_timeout: Duration::from_secs(30),
2106            crl_stale_grace: Duration::from_secs(24 * 60 * 60),
2107            crl_deny_on_unavailable: false,
2108            crl_end_entity_only: false,
2109            crl_allow_http: true,
2110            crl_enforce_expiration: true,
2111            crl_max_concurrent_fetches: 4,
2112            crl_max_response_bytes: 5 * 1024 * 1024,
2113            crl_discovery_rate_per_min: 60,
2114            crl_max_host_semaphores: 1024,
2115            crl_max_seen_urls: 4096,
2116            crl_max_cache_entries: 1024,
2117        }
2118    }
2119
2120    fn assert_config_nonzero_error(err: RmcpServerKitError, field: &str) {
2121        let RmcpServerKitError::Config(msg) = err else {
2122            panic!("expected Config error for {field}");
2123        };
2124        assert!(
2125            msg.contains(field) && msg.contains("must be nonzero"),
2126            "error must name {field} and say must be nonzero; got {msg:?}"
2127        );
2128    }
2129
2130    fn server_config_with_mtls(mtls: crate::auth::MtlsConfig) -> ServerConfig {
2131        ServerConfig {
2132            auth: Some(crate::auth::AuthConfig {
2133                enabled: true,
2134                api_keys: Vec::new(),
2135                mtls: Some(mtls),
2136                rate_limit: None,
2137                #[cfg(feature = "oauth")]
2138                oauth: None,
2139                #[cfg(not(feature = "oauth"))]
2140                oauth: None,
2141            }),
2142            // mTLS requires TLS, and that check runs before the capacity
2143            // knobs. Without these paths every caller of this helper would
2144            // fail on the TLS pairing error and never reach what it asserts.
2145            tls_cert_path: Some("cert.pem".into()),
2146            tls_key_path: Some("key.pem".into()),
2147            ..ServerConfig::default()
2148        }
2149    }
2150
2151    #[test]
2152    fn rejects_zero_crl_max_cache_entries() {
2153        let mut mtls = valid_mtls_config();
2154        mtls.crl_max_cache_entries = 0;
2155        let err = validate_server_config(&server_config_with_mtls(mtls))
2156            .expect_err("zero crl_max_cache_entries must be rejected");
2157        assert_config_nonzero_error(err, "auth.mtls.crl_max_cache_entries");
2158    }
2159
2160    #[test]
2161    fn rejects_zero_crl_max_concurrent_fetches() {
2162        let mut mtls = valid_mtls_config();
2163        mtls.crl_max_concurrent_fetches = 0;
2164        let err = validate_server_config(&server_config_with_mtls(mtls))
2165            .expect_err("zero crl_max_concurrent_fetches must be rejected");
2166        assert_config_nonzero_error(err, "auth.mtls.crl_max_concurrent_fetches");
2167    }
2168
2169    #[test]
2170    fn rejects_zero_crl_discovery_rate_per_min() {
2171        let mut mtls = valid_mtls_config();
2172        mtls.crl_discovery_rate_per_min = 0;
2173        let err = validate_server_config(&server_config_with_mtls(mtls))
2174            .expect_err("zero crl_discovery_rate_per_min must be rejected");
2175        assert_config_nonzero_error(err, "auth.mtls.crl_discovery_rate_per_min");
2176    }
2177
2178    #[test]
2179    fn rejects_zero_crl_max_host_semaphores() {
2180        let mut mtls = valid_mtls_config();
2181        mtls.crl_max_host_semaphores = 0;
2182        let err = validate_server_config(&server_config_with_mtls(mtls))
2183            .expect_err("zero crl_max_host_semaphores must be rejected");
2184        assert_config_nonzero_error(err, "auth.mtls.crl_max_host_semaphores");
2185    }
2186
2187    #[test]
2188    fn rejects_zero_crl_max_seen_urls() {
2189        let mut mtls = valid_mtls_config();
2190        mtls.crl_max_seen_urls = 0;
2191        let err = validate_server_config(&server_config_with_mtls(mtls))
2192            .expect_err("zero crl_max_seen_urls must be rejected");
2193        assert_config_nonzero_error(err, "auth.mtls.crl_max_seen_urls");
2194    }
2195
2196    #[test]
2197    fn rejects_zero_crl_max_response_bytes() {
2198        let mut mtls = valid_mtls_config();
2199        mtls.crl_max_response_bytes = 0;
2200        let err = validate_server_config(&server_config_with_mtls(mtls))
2201            .expect_err("zero crl_max_response_bytes must be rejected");
2202        assert_config_nonzero_error(err, "auth.mtls.crl_max_response_bytes");
2203    }
2204
2205    #[test]
2206    fn rejects_zero_auth_rate_limit() {
2207        let auth = crate::auth::AuthConfig::with_keys(vec![])
2208            .with_rate_limit(crate::auth::RateLimitConfig::new(0));
2209        let cfg = ServerConfig {
2210            auth: Some(auth),
2211            ..ServerConfig::default()
2212        };
2213        let err = validate_server_config(&cfg).expect_err("zero auth rate limit must be rejected");
2214        assert_config_nonzero_error(err, "auth.rate_limit.max_attempts_per_minute");
2215    }
2216
2217    #[test]
2218    fn rejects_zero_pre_auth_max_per_minute() {
2219        // Regression guard: `0` is NOT "unlimited" here. The limiter builder
2220        // falls back to DEFAULT_PRE_AUTH_RATE, so accepting `0` would raise
2221        // the pre-auth quota instead of tightening it.
2222        let mut rl = crate::auth::RateLimitConfig::new(30);
2223        rl.pre_auth_max_per_minute = Some(0);
2224        let cfg = ServerConfig {
2225            auth: Some(crate::auth::AuthConfig::with_keys(vec![]).with_rate_limit(rl)),
2226            ..ServerConfig::default()
2227        };
2228        let err = validate_server_config(&cfg)
2229            .expect_err("zero pre_auth_max_per_minute must be rejected");
2230        assert_config_nonzero_error(err, "auth.rate_limit.pre_auth_max_per_minute");
2231    }
2232
2233    #[test]
2234    fn tls_cert_without_key_rejected() {
2235        let cfg = ServerConfig {
2236            tls_cert_path: Some("/tmp/cert.pem".into()),
2237            ..ServerConfig::default()
2238        };
2239        let err = validate_server_config(&cfg).unwrap_err();
2240        assert!(err.to_string().contains("tls_cert_path"));
2241    }
2242
2243    #[test]
2244    fn tls_key_without_cert_rejected() {
2245        let cfg = ServerConfig {
2246            tls_key_path: Some("/tmp/key.pem".into()),
2247            ..ServerConfig::default()
2248        };
2249        let err = validate_server_config(&cfg).unwrap_err();
2250        assert!(err.to_string().contains("tls_cert_path"));
2251    }
2252
2253    #[test]
2254    fn tls_both_set_passes() {
2255        let cfg = ServerConfig {
2256            tls_cert_path: Some("/tmp/cert.pem".into()),
2257            tls_key_path: Some("/tmp/key.pem".into()),
2258            ..ServerConfig::default()
2259        };
2260        assert!(validate_server_config(&cfg).is_ok());
2261    }
2262
2263    #[test]
2264    fn invalid_tls_handshake_timeout_rejected() {
2265        let cfg = ServerConfig {
2266            tls_handshake_timeout: "not-a-duration".into(),
2267            ..ServerConfig::default()
2268        };
2269        let err = validate_server_config(&cfg).unwrap_err();
2270        assert!(err.to_string().contains("tls_handshake_timeout"));
2271    }
2272
2273    #[test]
2274    fn zero_tls_handshake_timeout_rejected() {
2275        let cfg = ServerConfig {
2276            tls_handshake_timeout: "0s".into(),
2277            ..ServerConfig::default()
2278        };
2279        let err = validate_server_config(&cfg).unwrap_err();
2280        assert!(err.to_string().contains("tls_handshake_timeout"));
2281    }
2282
2283    #[test]
2284    fn zero_max_concurrent_tls_handshakes_rejected() {
2285        let cfg = ServerConfig {
2286            max_concurrent_tls_handshakes: 0,
2287            ..ServerConfig::default()
2288        };
2289        let err = validate_server_config(&cfg).unwrap_err();
2290        assert!(err.to_string().contains("max_concurrent_tls_handshakes"));
2291    }
2292
2293    #[test]
2294    fn invalid_shutdown_timeout_rejected() {
2295        let cfg = ServerConfig {
2296            shutdown_timeout: "not-a-duration".into(),
2297            ..ServerConfig::default()
2298        };
2299        let err = validate_server_config(&cfg).unwrap_err();
2300        assert!(err.to_string().contains("shutdown_timeout"));
2301    }
2302
2303    #[test]
2304    fn invalid_request_timeout_rejected() {
2305        let cfg = ServerConfig {
2306            request_timeout: "xyz".into(),
2307            ..ServerConfig::default()
2308        };
2309        let err = validate_server_config(&cfg).unwrap_err();
2310        assert!(err.to_string().contains("request_timeout"));
2311    }
2312
2313    // -- validate_observability_config --
2314
2315    #[test]
2316    fn valid_observability_config_passes() {
2317        let cfg = ObservabilityConfig::default();
2318        assert!(validate_observability_config(&cfg).is_ok());
2319    }
2320
2321    #[test]
2322    fn invalid_log_level_rejected() {
2323        let cfg = ObservabilityConfig {
2324            log_level: "[invalid".into(),
2325            ..ObservabilityConfig::default()
2326        };
2327        let err = validate_observability_config(&cfg).unwrap_err();
2328        assert!(err.to_string().contains("log_level"));
2329    }
2330
2331    #[test]
2332    fn invalid_log_format_rejected() {
2333        let cfg = ObservabilityConfig {
2334            log_format: "yaml".into(),
2335            ..ObservabilityConfig::default()
2336        };
2337        let err = validate_observability_config(&cfg).unwrap_err();
2338        assert!(err.to_string().contains("log_format"));
2339    }
2340
2341    #[test]
2342    fn all_valid_log_levels_accepted() {
2343        for level in &[
2344            "trace",
2345            "debug",
2346            "info",
2347            "warn",
2348            "error",
2349            "info,rmcp=warn",
2350            "debug,hyper=error",
2351        ] {
2352            let cfg = ObservabilityConfig {
2353                log_level: (*level).into(),
2354                ..ObservabilityConfig::default()
2355            };
2356            assert!(
2357                validate_observability_config(&cfg).is_ok(),
2358                "level {level} should be valid"
2359            );
2360        }
2361    }
2362
2363    #[test]
2364    fn all_log_formats_accepted() {
2365        for fmt in &["json", "pretty", "text"] {
2366            let cfg = ObservabilityConfig {
2367                log_format: (*fmt).into(),
2368                ..ObservabilityConfig::default()
2369            };
2370            assert!(
2371                validate_observability_config(&cfg).is_ok(),
2372                "format {fmt} should be valid"
2373            );
2374        }
2375    }
2376
2377    // -- serde deserialization --
2378
2379    #[test]
2380    fn server_config_deserialize_defaults() {
2381        let cfg: ServerConfig = toml::from_str("").unwrap();
2382        assert_eq!(cfg.listen_port, 8443);
2383        assert_eq!(cfg.listen_addr, "127.0.0.1");
2384        assert_eq!(cfg.tls_handshake_timeout, "10s");
2385        assert_eq!(cfg.max_concurrent_tls_handshakes, 256);
2386    }
2387
2388    #[test]
2389    fn t1_existing_server_example_deserializes_with_new_defaults() {
2390        let server = server_from_root_toml(
2391            r#"
2392                [server]
2393                listen_addr = "0.0.0.0"
2394                listen_port = 8443
2395                tls_cert_path = "/etc/certs/server.crt"
2396                tls_key_path = "/etc/certs/server.key"
2397                shutdown_timeout = "30s"
2398                request_timeout = "120s"
2399                allowed_origins = ["http://localhost:3000", "https://myapp.example.com"]
2400                tool_rate_limit = 120
2401            "#,
2402        );
2403
2404        assert_eq!(server.max_request_body, 1024 * 1024);
2405        assert!(!server.expose_build_metadata);
2406        assert_eq!(server.security_headers, SecurityHeadersConfig::default());
2407    }
2408
2409    #[test]
2410    fn t2_default_bridge_is_no_op_for_mcp_defaults() {
2411        let actual = ServerConfig::default()
2412            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2413            .unwrap();
2414        let expected = McpServerConfig::new("127.0.0.1:8443", "t", "0.0.0");
2415
2416        assert_default_bridge_core_fields(&actual, &expected);
2417        assert_default_bridge_limit_fields(&actual, &expected);
2418        assert_default_bridge_metadata_fields(&actual, &expected);
2419    }
2420
2421    fn assert_default_bridge_core_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
2422        assert_eq!(actual.bind_addr, expected.bind_addr);
2423        assert_eq!(actual.tls_cert_path, expected.tls_cert_path);
2424        assert_eq!(actual.tls_key_path, expected.tls_key_path);
2425        assert!(actual.auth.is_none());
2426        assert_eq!(actual.allowed_origins, expected.allowed_origins);
2427        assert_eq!(actual.trusted_proxies, expected.trusted_proxies);
2428        assert_eq!(actual.forwarded_header, expected.forwarded_header);
2429        assert_eq!(actual.public_url, expected.public_url);
2430        assert_eq!(actual.name, expected.name);
2431        assert_eq!(actual.version, expected.version);
2432    }
2433
2434    fn assert_default_bridge_limit_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
2435        assert_eq!(actual.tool_rate_limit, expected.tool_rate_limit);
2436        assert_eq!(actual.tool_rate_limit_burst, expected.tool_rate_limit_burst);
2437        assert_eq!(
2438            actual.extra_route_rate_limit,
2439            expected.extra_route_rate_limit
2440        );
2441        assert_eq!(
2442            actual.extra_route_rate_limit_burst,
2443            expected.extra_route_rate_limit_burst
2444        );
2445        assert_eq!(
2446            actual.extra_route_rate_limit_exempt_paths,
2447            expected.extra_route_rate_limit_exempt_paths
2448        );
2449        assert_eq!(actual.key_eviction_policy, expected.key_eviction_policy);
2450        assert_eq!(actual.max_request_body, expected.max_request_body);
2451        assert_eq!(
2452            actual.max_concurrent_requests,
2453            expected.max_concurrent_requests
2454        );
2455    }
2456
2457    fn assert_default_bridge_metadata_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
2458        assert_eq!(actual.session_idle_timeout, expected.session_idle_timeout);
2459        assert_eq!(actual.sse_keep_alive, expected.sse_keep_alive);
2460        assert_eq!(actual.request_timeout, expected.request_timeout);
2461        assert_eq!(actual.shutdown_timeout, expected.shutdown_timeout);
2462        assert_eq!(actual.tls_handshake_timeout, expected.tls_handshake_timeout);
2463        assert_eq!(
2464            actual.max_concurrent_tls_handshakes,
2465            expected.max_concurrent_tls_handshakes
2466        );
2467        assert_eq!(actual.compression_enabled, expected.compression_enabled);
2468        assert_eq!(actual.compression_min_size, expected.compression_min_size);
2469        assert_eq!(actual.admin_enabled, expected.admin_enabled);
2470        assert_eq!(actual.admin_role, expected.admin_role);
2471        assert_eq!(actual.expose_build_metadata, expected.expose_build_metadata);
2472        assert_eq!(actual.security_headers, expected.security_headers);
2473    }
2474
2475    #[test]
2476    fn t5_hsts_preload_from_toml_rejected_by_mcp_validate() {
2477        let cfg = server_from_root_toml(
2478            r#"
2479                [server.security_headers]
2480                strict_transport_security = "max-age=1; preload"
2481            "#,
2482        );
2483        let mcp = cfg
2484            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2485            .unwrap();
2486
2487        let err = mcp.validate().unwrap_err();
2488        let msg = err.to_string();
2489        assert!(msg.contains("preload"), "error must mention preload: {msg}");
2490    }
2491
2492    #[test]
2493    fn t6_bad_security_header_from_toml_rejected_by_mcp_validate() {
2494        let cfg = server_from_root_toml(
2495            r#"
2496                [server.security_headers]
2497                content_security_policy = "bad\nvalue"
2498            "#,
2499        );
2500        let mcp = cfg
2501            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2502            .unwrap();
2503
2504        let err = mcp.validate().unwrap_err();
2505        let msg = err.to_string();
2506        assert!(
2507            msg.contains("invalid security_headers.content_security_policy"),
2508            "error must name invalid header field: {msg}"
2509        );
2510    }
2511
2512    #[test]
2513    fn t7_zero_max_request_body_rejected_by_mcp_validate() {
2514        let cfg: ServerConfig = toml::from_str("max_request_body = 0").unwrap();
2515        let mcp = cfg
2516            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2517            .unwrap();
2518
2519        let err = mcp.validate().unwrap_err();
2520        assert!(
2521            err.to_string()
2522                .contains("max_request_body must be greater than zero")
2523        );
2524    }
2525
2526    #[test]
2527    fn t9_unknown_security_header_key_is_rejected() {
2528        let err = toml::from_str::<RootConfig>(
2529            r#"
2530                [server.security_headers]
2531                typo_content_security_policy = "default-src 'self'"
2532            "#,
2533        )
2534        .unwrap_err();
2535
2536        let msg = err.to_string();
2537        assert!(
2538            msg.contains("typo_content_security_policy"),
2539            "error must name the offending key: {msg}"
2540        );
2541    }
2542
2543    #[test]
2544    fn unknown_server_config_key_is_rejected() {
2545        let err = toml::from_str::<ServerConfig>(
2546            r#"
2547                tls_keypath = "/etc/certs/server.key"
2548            "#,
2549        )
2550        .unwrap_err();
2551
2552        let msg = err.to_string();
2553        assert!(
2554            msg.contains("tls_keypath"),
2555            "error must name the offending key: {msg}"
2556        );
2557    }
2558
2559    #[cfg(not(feature = "oauth"))]
2560    #[test]
2561    fn oauth_table_without_oauth_feature_is_rejected_with_actionable_message() {
2562        // `deny_unknown_fields` on `AuthConfig` would otherwise surface this as
2563        // `unknown field \`oauth\``, which never mentions the cargo feature.
2564        // Failing closed matters: silently dropping the table starts a server
2565        // whose config says OAuth is on while no token validation is compiled in.
2566        let server = toml::from_str::<ServerConfig>(
2567            r#"
2568                listen_port = 8080
2569
2570                [auth]
2571                enabled = true
2572
2573                [auth.oauth]
2574                issuer = "https://auth.example.com"
2575            "#,
2576        )
2577        .expect("[auth.oauth] must parse so validation can produce the real message");
2578
2579        let msg = validate_server_config(&server)
2580            .expect_err("auth.oauth without the oauth feature must be rejected")
2581            .to_string();
2582
2583        assert!(
2584            msg.contains("oauth") && msg.contains("--features oauth"),
2585            "error must name the missing cargo feature and how to fix it: {msg}"
2586        );
2587    }
2588
2589    #[test]
2590    fn all_twelve_security_header_keys_deserialize_from_server_toml() {
2591        let cfg = server_from_root_toml(
2592            r#"
2593                [server.security_headers]
2594                content_security_policy = "csp"
2595                strict_transport_security = "max-age=1"
2596                cross_origin_embedder_policy = "coep"
2597                cross_origin_resource_policy = "corp"
2598                cross_origin_opener_policy = "coop"
2599                permissions_policy = "permissions"
2600                referrer_policy = "referrer"
2601                x_frame_options = "frame"
2602                cache_control = "cache"
2603                x_content_type_options = "content-type"
2604                x_dns_prefetch_control = "dns"
2605                x_permitted_cross_domain_policies = "cross-domain"
2606            "#,
2607        );
2608
2609        let headers = cfg.security_headers;
2610        assert_eq!(headers.content_security_policy.as_deref(), Some("csp"));
2611        assert_eq!(
2612            headers.strict_transport_security.as_deref(),
2613            Some("max-age=1")
2614        );
2615        assert_eq!(
2616            headers.cross_origin_embedder_policy.as_deref(),
2617            Some("coep")
2618        );
2619        assert_eq!(
2620            headers.cross_origin_resource_policy.as_deref(),
2621            Some("corp")
2622        );
2623        assert_eq!(headers.cross_origin_opener_policy.as_deref(), Some("coop"));
2624        assert_eq!(headers.permissions_policy.as_deref(), Some("permissions"));
2625        assert_eq!(headers.referrer_policy.as_deref(), Some("referrer"));
2626        assert_eq!(headers.x_frame_options.as_deref(), Some("frame"));
2627        assert_eq!(headers.cache_control.as_deref(), Some("cache"));
2628        assert_eq!(
2629            headers.x_content_type_options.as_deref(),
2630            Some("content-type")
2631        );
2632        assert_eq!(headers.x_dns_prefetch_control.as_deref(), Some("dns"));
2633        assert_eq!(
2634            headers.x_permitted_cross_domain_policies.as_deref(),
2635            Some("cross-domain")
2636        );
2637    }
2638
2639    /// Extract the `pub` field names of a struct from this file's own source.
2640    fn struct_pub_fields(marker: &str) -> Vec<String> {
2641        let source = include_str!("config.rs").replace("\r\n", "\n");
2642        let (_, after) = source
2643            .split_once(marker)
2644            .unwrap_or_else(|| panic!("struct start marker {marker:?} not found"));
2645        let (body, _) = after
2646            .split_once("\n}\n")
2647            .expect("struct end marker not found");
2648        body.lines()
2649            .filter_map(|line| {
2650                line.trim()
2651                    .strip_prefix("pub ")
2652                    .and_then(|rest| rest.split_once(':').map(|(name, _)| name.trim().to_owned()))
2653            })
2654            .collect()
2655    }
2656
2657    /// Config fields deliberately NOT exposed as environment overrides.
2658    ///
2659    /// Hand-maintained on purpose: adding a field to `ServerConfig` or
2660    /// `ObservabilityConfig` must be a conscious decision to expose it or not,
2661    /// and `every_config_field_is_env_overridable_or_excluded` fails until the
2662    /// field appears in `ENV_OVERRIDE_SPECS` or here. Without this list a new
2663    /// field silently defaults to "no override" with nothing to notice it.
2664    const ENV_OVERRIDE_EXCLUDED_FIELDS: &[&str] = &[
2665        // Structured / nested values with no single-scalar env representation.
2666        "server.allowed_origins",
2667        "server.extra_route_rate_limit_exempt_paths",
2668        "server.trusted_proxies",
2669        "server.auth",
2670        "server.security_headers",
2671        // Tuning knobs intentionally file-only: changing them per-process via
2672        // the environment invites drift between replicas of the same service.
2673        "server.tls_handshake_timeout",
2674        "server.max_concurrent_tls_handshakes",
2675        "server.shutdown_timeout",
2676        "server.request_timeout",
2677        "server.max_request_body",
2678        "server.stdio_enabled",
2679        "server.tool_rate_limit",
2680        "server.tool_rate_limit_burst",
2681        "server.extra_route_rate_limit",
2682        "server.extra_route_rate_limit_burst",
2683        "server.trusted_forwarder_max_entries",
2684        "server.forwarded_header",
2685        "server.session_idle_timeout",
2686        "server.sse_keep_alive",
2687        "server.compression_enabled",
2688        "server.compression_min_size",
2689        "server.max_concurrent_requests",
2690        "server.admin_role",
2691        "server.expose_build_metadata",
2692        // `log_level` is already controlled by RUST_LOG; a second env source
2693        // would give two switches for one behaviour.
2694        "observability.log_level",
2695        "observability.audit_log_path",
2696        "observability.log_request_headers",
2697    ];
2698
2699    #[test]
2700    fn every_config_field_is_env_overridable_or_excluded() {
2701        for (marker, prefix) in [
2702            ("pub struct ServerConfig {", "server"),
2703            ("pub struct ObservabilityConfig {", "observability"),
2704        ] {
2705            for field in struct_pub_fields(marker) {
2706                let target = format!("{prefix}.{field}");
2707                let overridable = ENV_OVERRIDE_SPECS
2708                    .iter()
2709                    .any(|spec| spec.target_field == target);
2710                let excluded = ENV_OVERRIDE_EXCLUDED_FIELDS.contains(&target.as_str());
2711                assert!(
2712                    overridable || excluded,
2713                    "`{target}` is neither env-overridable nor listed in \
2714                     ENV_OVERRIDE_EXCLUDED_FIELDS; classify it deliberately"
2715                );
2716                assert!(
2717                    !(overridable && excluded),
2718                    "`{target}` is both env-overridable and excluded; remove one"
2719                );
2720            }
2721        }
2722    }
2723
2724    #[test]
2725    fn shared_invariants_report_a_fixed_precedence() {
2726        // All three violated at once: both validators must surface the same
2727        // one first, which is the drift this helper exists to prevent.
2728        assert!(matches!(
2729            check_shared_config_invariants(true, false, true, false, true),
2730            Err(SharedConfigViolation::AdminRequiresAuth)
2731        ));
2732        // Admin satisfied: TLS pairing outranks mTLS-requires-TLS.
2733        assert!(matches!(
2734            check_shared_config_invariants(false, true, true, false, true),
2735            Err(SharedConfigViolation::TlsCertWithoutKey)
2736        ));
2737        assert!(matches!(
2738            check_shared_config_invariants(false, true, false, true, true),
2739            Err(SharedConfigViolation::TlsKeyWithoutCert)
2740        ));
2741        // Pairing satisfied (neither half set), mTLS still unsatisfiable.
2742        assert!(matches!(
2743            check_shared_config_invariants(false, true, false, false, true),
2744            Err(SharedConfigViolation::MtlsRequiresTls)
2745        ));
2746        // Fully valid combinations.
2747        check_shared_config_invariants(true, true, true, true, true)
2748            .unwrap_or_else(|_| panic!("admin+auth with full TLS and mTLS must be valid"));
2749        check_shared_config_invariants(false, false, false, false, false)
2750            .unwrap_or_else(|_| panic!("an empty config must be valid"));
2751    }
2752
2753    #[test]
2754    fn toml_validator_surfaces_the_shared_precedence() {
2755        let server = ServerConfig {
2756            admin_enabled: true,
2757            tls_cert_path: Some(PathBuf::from("/etc/certs/server.crt")),
2758            ..Default::default()
2759        };
2760
2761        let err = validate_server_config(&server)
2762            .expect_err("admin without auth must fail")
2763            .to_string();
2764        assert!(
2765            err.contains("admin_enabled=true requires auth"),
2766            "admin must be reported before the TLS pairing failure; got {err:?}"
2767        );
2768    }
2769
2770    #[test]
2771    fn server_config_debug_redacts_tls_key_path() {
2772        let cfg = ServerConfig {
2773            tls_cert_path: Some(PathBuf::from("/etc/certs/server.crt")),
2774            tls_key_path: Some(PathBuf::from("/etc/secrets/server.key")),
2775            ..Default::default()
2776        };
2777
2778        let rendered = format!("{cfg:?}");
2779        assert!(
2780            !rendered.contains("server.key") && !rendered.contains("/etc/secrets"),
2781            "the private-key path must never render; got {rendered}"
2782        );
2783        assert!(
2784            rendered.contains("tls_key_path: Some(\"[REDACTED]\")"),
2785            "presence must still be reported for diagnostics; got {rendered}"
2786        );
2787        assert!(
2788            rendered.contains("server.crt"),
2789            "the certificate path is not secret and must remain visible"
2790        );
2791    }
2792
2793    #[test]
2794    fn observability_config_debug_redacts_audit_log_path() {
2795        let cfg = ObservabilityConfig {
2796            audit_log_path: Some(PathBuf::from("/var/log/rmcp/audit.log")),
2797            ..Default::default()
2798        };
2799
2800        let rendered = format!("{cfg:?}");
2801        assert!(
2802            !rendered.contains("audit.log") && !rendered.contains("/var/log"),
2803            "the audit log location must never render; got {rendered}"
2804        );
2805        assert!(rendered.contains("audit_log_path: Some(\"[REDACTED]\")"));
2806    }
2807
2808    #[test]
2809    fn server_config_debug_lists_every_field() {
2810        let rendered = format!("{:?}", ServerConfig::default());
2811        for field in struct_pub_fields("pub struct ServerConfig {") {
2812            assert!(
2813                rendered.contains(&format!("{field}:")),
2814                "hand-written Debug omits `{field}`; add it (redacted if sensitive)"
2815            );
2816        }
2817    }
2818
2819    #[test]
2820    fn observability_config_debug_lists_every_field() {
2821        let rendered = format!("{:?}", ObservabilityConfig::default());
2822        for field in struct_pub_fields("pub struct ObservabilityConfig {") {
2823            assert!(
2824                rendered.contains(&format!("{field}:")),
2825                "hand-written Debug omits `{field}`; add it (redacted if sensitive)"
2826            );
2827        }
2828    }
2829
2830    #[test]
2831    fn t10_every_server_config_field_is_classified_for_bridge() {
2832        let source = include_str!("config.rs").replace("\r\n", "\n");
2833        let (_, after_struct_start) = source
2834            .split_once("pub struct ServerConfig {")
2835            .expect("ServerConfig struct start marker");
2836        let (struct_body, _) = after_struct_start
2837            .split_once("\n}\n\nimpl ServerConfig")
2838            .expect("ServerConfig struct end marker");
2839        let actual_fields: HashSet<&str> = struct_body
2840            .lines()
2841            .filter_map(|line| {
2842                line.trim()
2843                    .strip_prefix("pub ")
2844                    .and_then(|rest| rest.split_once(':').map(|(name, _)| name.trim()))
2845            })
2846            .collect();
2847        let bridged_fields: HashSet<&str> = SERVER_CONFIG_BRIDGED_FIELDS.iter().copied().collect();
2848        let not_bridged_fields: HashSet<&str> =
2849            SERVER_CONFIG_NOT_BRIDGED_FIELDS.iter().copied().collect();
2850        let runtime_only_fields: HashSet<&str> = MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS
2851            .iter()
2852            .copied()
2853            .collect();
2854        let classified_fields: HashSet<&str> =
2855            bridged_fields.union(&not_bridged_fields).copied().collect();
2856
2857        assert_eq!(actual_fields, classified_fields);
2858        assert!(bridged_fields.is_disjoint(&not_bridged_fields));
2859        assert!(runtime_only_fields.is_disjoint(&actual_fields));
2860        assert!(SERVER_CONFIG_NOT_BRIDGED_FIELDS.contains(&"stdio_enabled"));
2861        assert!(MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS.contains(&"rbac"));
2862        assert!(MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS.contains(&"metrics_bind"));
2863    }
2864
2865    #[test]
2866    fn replacement_semantics_clear_base_option_and_false_bool_fields() {
2867        let (_token, hash) = crate::auth::generate_api_key().unwrap();
2868        let base = McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
2869            .with_tls("/tmp/base.crt", "/tmp/base.key")
2870            .with_auth(crate::auth::AuthConfig::with_keys(vec![
2871                crate::auth::ApiKeyEntry::new("base-key", hash, "admin"),
2872            ]))
2873            .with_tool_rate_limit(10)
2874            .with_tool_rate_limit_burst(20)
2875            .with_extra_route_rate_limit(30)
2876            .with_extra_route_rate_limit_burst(40)
2877            .with_trusted_proxies(["127.0.0.1/32"])
2878            .with_forwarded_header(crate::transport::ForwardedHeaderMode::Forwarded)
2879            .with_public_url("https://base.example")
2880            .enable_compression(512)
2881            .with_max_concurrent_requests(99)
2882            .enable_admin("admin")
2883            .expose_build_metadata();
2884
2885        let actual = ServerConfig::default().apply_to_mcp_config(base).unwrap();
2886
2887        assert!(actual.tls_cert_path.is_none());
2888        assert!(actual.tls_key_path.is_none());
2889        assert!(actual.auth.is_none());
2890        assert!(actual.tool_rate_limit.is_none());
2891        assert!(actual.tool_rate_limit_burst.is_none());
2892        assert!(actual.extra_route_rate_limit.is_none());
2893        assert!(actual.extra_route_rate_limit_burst.is_none());
2894        assert_eq!(actual.key_eviction_policy, KeyEvictionPolicy::EvictLru);
2895        assert!(actual.forwarded_header.is_none());
2896        assert!(actual.public_url.is_none());
2897        assert!(!actual.compression_enabled);
2898        assert_eq!(actual.compression_min_size, 1024);
2899        assert!(actual.max_concurrent_requests.is_none());
2900        assert!(!actual.admin_enabled);
2901        assert_eq!(actual.admin_role, "admin");
2902        assert!(!actual.expose_build_metadata);
2903    }
2904
2905    #[test]
2906    fn partial_tls_toml_does_not_inherit_base_key() {
2907        let cfg = ServerConfig {
2908            tls_cert_path: Some("/tmp/toml.crt".into()),
2909            tls_key_path: None,
2910            ..ServerConfig::default()
2911        };
2912        let mcp = cfg
2913            .apply_to_mcp_config(
2914                McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
2915                    .with_tls("/tmp/base.crt", "/tmp/base.key"),
2916            )
2917            .unwrap();
2918
2919        assert_eq!(mcp.tls_cert_path, Some(PathBuf::from("/tmp/toml.crt")));
2920        assert!(mcp.tls_key_path.is_none());
2921        let err = mcp.validate().unwrap_err();
2922        assert!(err.to_string().contains("tls_key_path"));
2923    }
2924
2925    #[test]
2926    fn partial_tls_toml_does_not_inherit_base_cert() {
2927        let cfg = ServerConfig {
2928            tls_cert_path: None,
2929            tls_key_path: Some("/tmp/toml.key".into()),
2930            ..ServerConfig::default()
2931        };
2932        let mcp = cfg
2933            .apply_to_mcp_config(
2934                McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
2935                    .with_tls("/tmp/base.crt", "/tmp/base.key"),
2936            )
2937            .unwrap();
2938
2939        assert!(mcp.tls_cert_path.is_none());
2940        assert_eq!(mcp.tls_key_path, Some(PathBuf::from("/tmp/toml.key")));
2941        let err = mcp.validate().unwrap_err();
2942        assert!(err.to_string().contains("tls_cert_path"));
2943    }
2944
2945    #[test]
2946    fn t11_bridge_maps_bind_addr_and_request_timeout() {
2947        let cfg: ServerConfig = toml::from_str(
2948            r#"
2949                listen_addr = "127.0.0.2"
2950                listen_port = 9000
2951                request_timeout = "5s"
2952            "#,
2953        )
2954        .unwrap();
2955
2956        let mcp = cfg
2957            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2958            .unwrap();
2959
2960        assert_eq!(mcp.bind_addr, "127.0.0.2:9000");
2961        assert_eq!(mcp.request_timeout, Duration::from_secs(5));
2962    }
2963
2964    #[test]
2965    fn key_eviction_policy_toml_defaults_and_overrides() {
2966        let default_cfg: ServerConfig = toml::from_str("").unwrap();
2967        assert_eq!(default_cfg.key_eviction_policy, KeyEvictionPolicy::EvictLru);
2968
2969        let reject_new: ServerConfig = toml::from_str(r#"key_eviction_policy = "reject_new""#)
2970            .expect("reject_new policy parses");
2971        assert_eq!(reject_new.key_eviction_policy, KeyEvictionPolicy::RejectNew);
2972        let bridged = reject_new
2973            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2974            .unwrap();
2975        assert_eq!(bridged.key_eviction_policy, KeyEvictionPolicy::RejectNew);
2976    }
2977
2978    #[test]
2979    fn t12_bridge_rejects_invalid_request_timeout() {
2980        let cfg: ServerConfig = toml::from_str(r#"request_timeout = "not-a-duration""#).unwrap();
2981
2982        let Err(err) = cfg.apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2983        else {
2984            panic!("invalid request_timeout must fail");
2985        };
2986
2987        assert!(err.to_string().contains("request_timeout"));
2988    }
2989
2990    #[test]
2991    fn observability_config_deserialize_defaults() {
2992        let cfg: ObservabilityConfig = toml::from_str("").unwrap();
2993        assert_eq!(cfg.log_level, "info,rmcp=warn");
2994        assert_eq!(cfg.log_format, "pretty");
2995        assert!(!cfg.log_request_headers);
2996        assert!(!cfg.metrics_enabled);
2997        assert!(!cfg.log_plaintext_oauth_tokens);
2998        assert!(!cfg.log_oauth_claim_values);
2999        assert!(!cfg.log_tool_call_arguments);
3000    }
3001
3002    #[test]
3003    fn observability_diagnostic_knobs_deserialize_true() {
3004        let cfg: ObservabilityConfig = toml::from_str(
3005            r"
3006                log_plaintext_oauth_tokens = true
3007                log_oauth_claim_values = true
3008                log_tool_call_arguments = true
3009            ",
3010        )
3011        .unwrap();
3012
3013        assert!(cfg.log_plaintext_oauth_tokens);
3014        assert!(cfg.log_oauth_claim_values);
3015        assert!(cfg.log_tool_call_arguments);
3016    }
3017
3018    fn all_env_vars() -> Vec<&'static str> {
3019        ENV_OVERRIDE_SPECS.iter().map(|spec| spec.env_var).collect()
3020    }
3021
3022    fn with_env_vars<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
3023        let mut all = all_env_vars()
3024            .into_iter()
3025            .map(|var| (var, None::<&str>))
3026            .collect::<Vec<_>>();
3027        all.extend(vars.iter().copied());
3028        temp_env::with_vars(all, f)
3029    }
3030
3031    #[test]
3032    fn e1_server_env_overrides_absent_keeps_defaults() {
3033        with_env_vars(&[], || {
3034            let mut cfg = ServerConfig::default();
3035            let report = cfg.apply_env_overrides().unwrap();
3036            assert!(report.is_empty());
3037            assert_eq!(cfg.listen_addr, "127.0.0.1");
3038            assert_eq!(cfg.listen_port, 8443);
3039            assert!(cfg.tls_cert_path.is_none());
3040            assert!(cfg.tls_key_path.is_none());
3041            assert!(cfg.public_url.is_none());
3042            assert!(!cfg.admin_enabled);
3043            assert!(cfg.auth.is_none());
3044        });
3045    }
3046
3047    #[test]
3048    fn e2_listen_port_env_override_applies_and_reports() {
3049        with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("9000"))], || {
3050            let mut cfg = ServerConfig::default();
3051            let report = cfg.apply_env_overrides().unwrap();
3052            assert_eq!(cfg.listen_port, 9000);
3053            assert_eq!(report.len(), 1);
3054            assert_eq!(report[0].env_var, SERVER_LISTEN_PORT_ENV);
3055            assert_eq!(report[0].target_field, "server.listen_port");
3056            assert_eq!(report[0].source, EnvOverrideSource::Env);
3057            assert_eq!(report[0].value.as_deref(), Some("9000"));
3058        });
3059    }
3060
3061    #[test]
3062    fn e3_bad_listen_port_env_fails_closed() {
3063        with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("not-a-number"))], || {
3064            let mut cfg = ServerConfig::default();
3065            let err = cfg.apply_env_overrides().unwrap_err();
3066            let msg = err.to_string();
3067            assert!(msg.contains(SERVER_LISTEN_PORT_ENV));
3068            assert!(msg.contains("u16"));
3069        });
3070    }
3071
3072    #[test]
3073    fn e4_oauth_env_without_auth_parent_fails_closed() {
3074        with_env_vars(&[(SERVER_OAUTH_ISSUER_ENV, Some("https://idp/"))], || {
3075            let mut cfg = ServerConfig::default();
3076            let err = cfg.apply_env_overrides().unwrap_err();
3077            let msg = err.to_string();
3078            assert!(msg.contains(SERVER_OAUTH_ISSUER_ENV));
3079            #[cfg(feature = "oauth")]
3080            assert!(msg.contains("[server.auth.oauth]"));
3081            #[cfg(not(feature = "oauth"))]
3082            assert!(msg.contains("oauth` feature"));
3083        });
3084    }
3085
3086    #[cfg(feature = "oauth")]
3087    #[test]
3088    fn e5_oauth_env_populates_declared_parent_and_validates() {
3089        with_env_vars(
3090            &[
3091                (SERVER_OAUTH_ISSUER_ENV, Some("https://idp.example/")),
3092                (SERVER_OAUTH_AUDIENCE_ENV, Some("mcp")),
3093                (
3094                    SERVER_OAUTH_JWKS_URI_ENV,
3095                    Some("https://idp.example/.well-known/jwks.json"),
3096                ),
3097            ],
3098            || {
3099                let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3100                auth.oauth = Some(crate::oauth::OAuthConfig {
3101                    role_claim: Some("roles".into()),
3102                    ..crate::oauth::OAuthConfig::default()
3103                });
3104                let mut cfg = ServerConfig {
3105                    auth: Some(auth),
3106                    ..ServerConfig::default()
3107                };
3108
3109                let report = cfg.apply_env_overrides().unwrap();
3110                let oauth = cfg
3111                    .auth
3112                    .as_ref()
3113                    .and_then(|auth| auth.oauth.as_ref())
3114                    .unwrap();
3115                assert_eq!(oauth.issuer, "https://idp.example/");
3116                assert_eq!(oauth.audience, "mcp");
3117                assert_eq!(oauth.jwks_uri, "https://idp.example/.well-known/jwks.json");
3118                assert!(oauth.validate().is_ok());
3119                assert_eq!(report.len(), 3);
3120            },
3121        );
3122    }
3123
3124    #[cfg(feature = "oauth")]
3125    #[test]
3126    fn e5b_oauth_env_missing_audience_fails_validate() {
3127        with_env_vars(
3128            &[
3129                (SERVER_OAUTH_ISSUER_ENV, Some("https://idp.example/")),
3130                (
3131                    SERVER_OAUTH_JWKS_URI_ENV,
3132                    Some("https://idp.example/.well-known/jwks.json"),
3133                ),
3134            ],
3135            || {
3136                let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3137                auth.oauth = Some(crate::oauth::OAuthConfig {
3138                    role_claim: Some("roles".into()),
3139                    ..crate::oauth::OAuthConfig::default()
3140                });
3141                let mut cfg = ServerConfig {
3142                    auth: Some(auth),
3143                    ..ServerConfig::default()
3144                };
3145
3146                cfg.apply_env_overrides().unwrap();
3147                let oauth = cfg
3148                    .auth
3149                    .as_ref()
3150                    .and_then(|auth| auth.oauth.as_ref())
3151                    .unwrap();
3152                let err = oauth.validate().unwrap_err();
3153                assert!(err.to_string().contains("oauth.audience must not be empty"));
3154            },
3155        );
3156    }
3157
3158    #[cfg(feature = "oauth")]
3159    #[test]
3160    fn e5c_oauth_proxy_env_applies_to_declared_proxy() {
3161        with_env_vars(
3162            &[(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, Some("true"))],
3163            || {
3164                let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3165                auth.oauth = Some(crate::oauth::OAuthConfig {
3166                    proxy: Some(
3167                        crate::oauth::OAuthProxyConfig::builder(
3168                            "https://idp.example/authorize",
3169                            "https://idp.example/token",
3170                            "mcp",
3171                        )
3172                        .build(),
3173                    ),
3174                    ..crate::oauth::OAuthConfig::default()
3175                });
3176                let mut cfg = ServerConfig {
3177                    auth: Some(auth),
3178                    ..ServerConfig::default()
3179                };
3180
3181                let report = cfg.apply_env_overrides().unwrap();
3182                let proxy = cfg
3183                    .auth
3184                    .as_ref()
3185                    .and_then(|auth| auth.oauth.as_ref())
3186                    .and_then(|oauth| oauth.proxy.as_ref())
3187                    .unwrap();
3188                assert!(proxy.strip_resource_param);
3189                assert_eq!(report.len(), 1);
3190                assert_eq!(
3191                    report[0].env_var,
3192                    SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV
3193                );
3194            },
3195        );
3196    }
3197
3198    #[cfg(feature = "oauth")]
3199    #[test]
3200    fn e5d_oauth_proxy_env_without_declared_proxy_fails_closed() {
3201        // The var can only populate a field on an existing proxy: the three
3202        // required proxy fields have no env source, so creating one here would
3203        // yield a half-configured proxy.
3204        with_env_vars(
3205            &[(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, Some("true"))],
3206            || {
3207                let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3208                auth.oauth = Some(crate::oauth::OAuthConfig::default());
3209                let mut cfg = ServerConfig {
3210                    auth: Some(auth),
3211                    ..ServerConfig::default()
3212                };
3213
3214                let err = cfg.apply_env_overrides().unwrap_err();
3215                let msg = err.to_string();
3216                assert!(msg.contains(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV));
3217                assert!(msg.contains("[server.auth.oauth.proxy]"));
3218            },
3219        );
3220    }
3221
3222    #[cfg(feature = "oauth")]
3223    #[test]
3224    fn e5e_oauth_proxy_env_rejects_non_bool() {
3225        with_env_vars(
3226            &[(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, Some("maybe"))],
3227            || {
3228                let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3229                auth.oauth = Some(crate::oauth::OAuthConfig {
3230                    proxy: Some(
3231                        crate::oauth::OAuthProxyConfig::builder(
3232                            "https://idp.example/authorize",
3233                            "https://idp.example/token",
3234                            "mcp",
3235                        )
3236                        .build(),
3237                    ),
3238                    ..crate::oauth::OAuthConfig::default()
3239                });
3240                let mut cfg = ServerConfig {
3241                    auth: Some(auth),
3242                    ..ServerConfig::default()
3243                };
3244
3245                let msg = cfg.apply_env_overrides().unwrap_err().to_string();
3246                assert!(msg.contains(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV));
3247                assert!(msg.contains("bool"));
3248            },
3249        );
3250    }
3251
3252    #[cfg(feature = "oauth")]
3253    #[test]
3254    fn e5f_oauth_allowed_algorithms_env_parses_comma_separated_list() {
3255        with_env_vars(
3256            &[(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV, Some("RS256, ES384"))],
3257            || {
3258                let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3259                auth.oauth = Some(crate::oauth::OAuthConfig::default());
3260                let mut cfg = ServerConfig {
3261                    auth: Some(auth),
3262                    ..ServerConfig::default()
3263                };
3264
3265                let report = cfg.apply_env_overrides().unwrap();
3266                let oauth = cfg
3267                    .auth
3268                    .as_ref()
3269                    .and_then(|auth| auth.oauth.as_ref())
3270                    .unwrap();
3271                assert_eq!(
3272                    oauth.allowed_algorithms.as_deref(),
3273                    Some(["RS256".to_owned(), "ES384".to_owned()].as_slice())
3274                );
3275                assert_eq!(report.len(), 1);
3276            },
3277        );
3278    }
3279
3280    #[cfg(feature = "oauth")]
3281    #[test]
3282    fn e5g_oauth_allowed_algorithms_env_rejects_non_narrowing_value() {
3283        // SECURITY: the env path must enforce the same narrow-only rule as
3284        // TOML, and the error must name the variable that caused it.
3285        with_env_vars(
3286            &[(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV, Some("HS256"))],
3287            || {
3288                let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3289                auth.oauth = Some(crate::oauth::OAuthConfig::default());
3290                let mut cfg = ServerConfig {
3291                    auth: Some(auth),
3292                    ..ServerConfig::default()
3293                };
3294
3295                let msg = cfg.apply_env_overrides().unwrap_err().to_string();
3296                assert!(msg.contains(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV));
3297                assert!(msg.contains("unsupported algorithm"));
3298            },
3299        );
3300    }
3301
3302    #[test]
3303    fn e9_bad_observability_bool_env_fails_closed() {
3304        with_env_vars(
3305            &[(OBSERVABILITY_METRICS_ENABLED_ENV, Some("maybe"))],
3306            || {
3307                let mut cfg = ObservabilityConfig::default();
3308                let err = cfg.apply_env_overrides().unwrap_err();
3309                let msg = err.to_string();
3310                assert!(msg.contains(OBSERVABILITY_METRICS_ENABLED_ENV));
3311                assert!(msg.contains("bool"));
3312            },
3313        );
3314    }
3315
3316    #[test]
3317    fn observability_diagnostic_env_overrides_win_over_toml() {
3318        with_env_vars(
3319            &[
3320                (OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV, Some("false")),
3321                (OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV, Some("false")),
3322                (OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV, Some("false")),
3323            ],
3324            || {
3325                let mut cfg: ObservabilityConfig = toml::from_str(
3326                    r"
3327                        log_plaintext_oauth_tokens = true
3328                        log_oauth_claim_values = true
3329                        log_tool_call_arguments = true
3330                    ",
3331                )
3332                .unwrap();
3333
3334                let report = cfg.apply_env_overrides().unwrap();
3335
3336                assert!(!cfg.log_plaintext_oauth_tokens);
3337                assert!(!cfg.log_oauth_claim_values);
3338                assert!(!cfg.log_tool_call_arguments);
3339                assert_eq!(report.len(), 3);
3340                assert!(report.iter().any(|entry| {
3341                    entry.env_var == OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV
3342                        && entry.target_field == "observability.log_plaintext_oauth_tokens"
3343                        && entry.value.as_deref() == Some("false")
3344                }));
3345                assert!(report.iter().any(|entry| {
3346                    entry.env_var == OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV
3347                        && entry.target_field == "observability.log_oauth_claim_values"
3348                        && entry.value.as_deref() == Some("false")
3349                }));
3350                assert!(report.iter().any(|entry| {
3351                    entry.env_var == OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV
3352                        && entry.target_field == "observability.log_tool_call_arguments"
3353                        && entry.value.as_deref() == Some("false")
3354                }));
3355            },
3356        );
3357    }
3358
3359    #[test]
3360    fn bad_observability_diagnostic_bool_env_fails_closed() {
3361        for env_var in [
3362            OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV,
3363            OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV,
3364            OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV,
3365        ] {
3366            with_env_vars(&[(env_var, Some("notabool"))], || {
3367                let mut cfg = ObservabilityConfig::default();
3368                let err = cfg.apply_env_overrides().unwrap_err();
3369                let msg = err.to_string();
3370                assert!(msg.contains(env_var));
3371                assert!(msg.contains("bool"));
3372            });
3373        }
3374    }
3375
3376    #[test]
3377    fn e10_env_port_reaches_mcp_bridge() {
3378        with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("9100"))], || {
3379            let mut server: ServerConfig = toml::from_str(r#"listen_addr = "127.0.0.2""#).unwrap();
3380            server.apply_env_overrides().unwrap();
3381            let mcp = server
3382                .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
3383                .unwrap();
3384            assert_eq!(mcp.bind_addr, "127.0.0.2:9100");
3385            assert!(mcp.validate().is_ok());
3386        });
3387    }
3388
3389    #[test]
3390    fn key_eviction_policy_env_override_applies_and_reports() {
3391        with_env_vars(
3392            &[(SERVER_KEY_EVICTION_POLICY_ENV, Some("reject_new"))],
3393            || {
3394                let mut cfg: ServerConfig = toml::from_str(r#"key_eviction_policy = "evict_lru""#)
3395                    .expect("TOML policy parses");
3396                let report = cfg.apply_env_overrides().unwrap();
3397                assert_eq!(cfg.key_eviction_policy, KeyEvictionPolicy::RejectNew);
3398                assert_eq!(report.len(), 1);
3399                assert_eq!(report[0].env_var, SERVER_KEY_EVICTION_POLICY_ENV);
3400                assert_eq!(report[0].target_field, "server.key_eviction_policy");
3401                assert_eq!(report[0].value.as_deref(), Some("reject_new"));
3402            },
3403        );
3404    }
3405
3406    #[test]
3407    fn bad_key_eviction_policy_env_fails_closed() {
3408        with_env_vars(
3409            &[(SERVER_KEY_EVICTION_POLICY_ENV, Some("drop_random"))],
3410            || {
3411                let mut cfg = ServerConfig::default();
3412                let err = cfg.apply_env_overrides().unwrap_err();
3413                let msg = err.to_string();
3414                assert!(msg.contains(SERVER_KEY_EVICTION_POLICY_ENV));
3415                assert!(msg.contains("KeyEvictionPolicy"));
3416            },
3417        );
3418    }
3419
3420    #[cfg(unix)]
3421    #[test]
3422    fn non_unicode_env_value_fails_closed() {
3423        use std::{ffi::OsString, os::unix::ffi::OsStringExt};
3424
3425        let bad = OsString::from_vec(vec![0x66, 0x80, 0x6f]);
3426        temp_env::with_var(SERVER_LISTEN_ADDR_ENV, Some(bad), || {
3427            let mut cfg = ServerConfig::default();
3428            let err = cfg.apply_env_overrides().unwrap_err();
3429            let msg = err.to_string();
3430            assert!(msg.contains(SERVER_LISTEN_ADDR_ENV));
3431            assert!(msg.contains("UTF-8"));
3432        });
3433    }
3434
3435    #[cfg(not(feature = "oauth"))]
3436    #[test]
3437    fn e11_oauth_env_feature_off_fails_closed() {
3438        with_env_vars(&[(SERVER_OAUTH_ISSUER_ENV, Some("https://idp/"))], || {
3439            let mut cfg = ServerConfig {
3440                auth: Some(crate::auth::AuthConfig::with_keys(vec![])),
3441                ..ServerConfig::default()
3442            };
3443            let err = cfg.apply_env_overrides().unwrap_err();
3444            let msg = err.to_string();
3445            assert!(msg.contains(SERVER_OAUTH_ISSUER_ENV));
3446            assert!(msg.contains("oauth` feature"));
3447        });
3448    }
3449
3450    #[test]
3451    fn env_override_spec_matches_expected_set() {
3452        let vars = ENV_OVERRIDE_SPECS
3453            .iter()
3454            .map(|spec| {
3455                (
3456                    spec.env_var,
3457                    spec.target_field,
3458                    spec.required_feature,
3459                    spec.redacted,
3460                )
3461            })
3462            .collect::<Vec<_>>();
3463        assert_eq!(vars.len(), EXPECTED_ENV_OVERRIDE_SPECS.len());
3464        for expected in EXPECTED_ENV_OVERRIDE_SPECS {
3465            assert!(vars.contains(expected), "missing env spec {expected:?}");
3466        }
3467        assert_eq!(
3468            ENV_OVERRIDE_SPECS
3469                .iter()
3470                .filter(|spec| spec.value_type == "Path")
3471                .count(),
3472            3
3473        );
3474    }
3475
3476    #[derive(Debug)]
3477    struct GuideEnvRow {
3478        env_var: String,
3479        target_field: String,
3480        value_type: String,
3481        notes: String,
3482    }
3483
3484    #[derive(Debug)]
3485    struct GuideEnvAnnotation {
3486        env_var: String,
3487        key: String,
3488    }
3489
3490    // `_FILE` is documented next to its sibling because both target the same
3491    // TOML key (`rbac.redaction_salt`); duplicating the inline annotation on
3492    // the key would be ambiguous rather than helpful.
3493    const INLINE_ENV_ANNOTATION_EXEMPTIONS: &[&str] = &[RBAC_REDACTION_SALT_FILE_ENV];
3494
3495    type EnvSpecTuple = (&'static str, &'static str, Option<&'static str>, bool);
3496
3497    const EXPECTED_ENV_OVERRIDE_SPECS: &[EnvSpecTuple] = &[
3498        (SERVER_LISTEN_ADDR_ENV, "server.listen_addr", None, false),
3499        (SERVER_LISTEN_PORT_ENV, "server.listen_port", None, false),
3500        (SERVER_PUBLIC_URL_ENV, "server.public_url", None, false),
3501        (
3502            SERVER_TLS_CERT_PATH_ENV,
3503            "server.tls_cert_path",
3504            None,
3505            false,
3506        ),
3507        (SERVER_TLS_KEY_PATH_ENV, "server.tls_key_path", None, false),
3508        (
3509            SERVER_ADMIN_ENABLED_ENV,
3510            "server.admin_enabled",
3511            None,
3512            false,
3513        ),
3514        (
3515            SERVER_KEY_EVICTION_POLICY_ENV,
3516            "server.key_eviction_policy",
3517            None,
3518            false,
3519        ),
3520        (
3521            SERVER_OAUTH_ISSUER_ENV,
3522            "server.auth.oauth.issuer",
3523            Some("oauth"),
3524            false,
3525        ),
3526        (
3527            SERVER_OAUTH_AUDIENCE_ENV,
3528            "server.auth.oauth.audience",
3529            Some("oauth"),
3530            false,
3531        ),
3532        (
3533            SERVER_OAUTH_JWKS_URI_ENV,
3534            "server.auth.oauth.jwks_uri",
3535            Some("oauth"),
3536            false,
3537        ),
3538        (
3539            SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV,
3540            "server.auth.oauth.allowed_algorithms",
3541            Some("oauth"),
3542            false,
3543        ),
3544        (
3545            SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV,
3546            "server.auth.oauth.proxy.strip_resource_param",
3547            Some("oauth"),
3548            false,
3549        ),
3550        (
3551            OBSERVABILITY_LOG_FORMAT_ENV,
3552            "observability.log_format",
3553            None,
3554            false,
3555        ),
3556        (
3557            OBSERVABILITY_METRICS_ENABLED_ENV,
3558            "observability.metrics_enabled",
3559            None,
3560            false,
3561        ),
3562        (
3563            OBSERVABILITY_METRICS_BIND_ENV,
3564            "observability.metrics_bind",
3565            None,
3566            false,
3567        ),
3568        (
3569            OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV,
3570            "observability.log_plaintext_oauth_tokens",
3571            None,
3572            false,
3573        ),
3574        (
3575            OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV,
3576            "observability.log_oauth_claim_values",
3577            None,
3578            false,
3579        ),
3580        (
3581            OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV,
3582            "observability.log_tool_call_arguments",
3583            None,
3584            false,
3585        ),
3586        (
3587            OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV,
3588            "observability.log_upstream_error_bodies",
3589            None,
3590            false,
3591        ),
3592        (RBAC_REDACTION_SALT_ENV, "rbac.redaction_salt", None, true),
3593        (
3594            RBAC_REDACTION_SALT_FILE_ENV,
3595            "rbac.redaction_salt",
3596            None,
3597            true,
3598        ),
3599    ];
3600
3601    // Guards the public operator table against drifting from the code-side
3602    // env spec, and guards the reverse direction by parsing `*_ENV` consts
3603    // from source text. Source parsing is deliberate: it catches a newly added
3604    // env variable constant even if no Rust code references the spec table yet.
3605    #[test]
3606    fn guide_env_override_table_matches_code_spec() {
3607        let rows = parse_guide_env_override_table();
3608        assert_eq!(
3609            rows.len(),
3610            ENV_OVERRIDE_SPECS.len(),
3611            "GUIDE env override table row count {} must match ENV_OVERRIDE_SPECS row count {}",
3612            rows.len(),
3613            ENV_OVERRIDE_SPECS.len()
3614        );
3615
3616        for (idx, (row, spec)) in rows.iter().zip(ENV_OVERRIDE_SPECS.iter()).enumerate() {
3617            assert_eq!(
3618                row.env_var, spec.env_var,
3619                "row {idx} env var mismatch: GUIDE has {:?}, code has {:?}",
3620                row.env_var, spec.env_var
3621            );
3622            assert_eq!(
3623                row.target_field, spec.target_field,
3624                "{} target mismatch: GUIDE has {:?}, code has {:?}",
3625                spec.env_var, row.target_field, spec.target_field
3626            );
3627            assert_eq!(
3628                row.value_type, spec.value_type,
3629                "{} type mismatch: GUIDE has {:?}, code has {:?}",
3630                spec.env_var, row.value_type, spec.value_type
3631            );
3632
3633            let notes_lower = row.notes.to_ascii_lowercase();
3634            if let Some(feature) = spec.required_feature {
3635                assert!(
3636                    notes_lower.contains(feature),
3637                    "{} notes must mention required feature {:?}; notes were {:?}",
3638                    spec.env_var,
3639                    feature,
3640                    row.notes
3641                );
3642            } else {
3643                assert!(
3644                    !notes_lower.contains("requires") && !notes_lower.contains("feature"),
3645                    "{} notes must not mention a required feature; notes were {:?}",
3646                    spec.env_var,
3647                    row.notes
3648                );
3649            }
3650
3651            if spec.redacted {
3652                assert!(
3653                    notes_lower.contains("secret") && notes_lower.contains("redacted"),
3654                    "{} notes must indicate secret/redacted handling; notes were {:?}",
3655                    spec.env_var,
3656                    row.notes
3657                );
3658            } else {
3659                assert!(
3660                    !notes_lower.contains("secret") && !notes_lower.contains("redacted"),
3661                    "{} notes must not indicate secret/redacted handling; notes were {:?}",
3662                    spec.env_var,
3663                    row.notes
3664                );
3665            }
3666        }
3667
3668        let spec_vars = ENV_OVERRIDE_SPECS
3669            .iter()
3670            .map(|spec| spec.env_var)
3671            .collect::<HashSet<_>>();
3672        for env_var in parse_rmcp_env_constants_from_config_source() {
3673            assert!(
3674                spec_vars.contains(env_var.as_str()),
3675                "env const {env_var} is defined in src/config.rs but missing from ENV_OVERRIDE_SPECS"
3676            );
3677        }
3678    }
3679
3680    // Sibling guard for the canonical TOML example's inline `# env:` comments.
3681    // It is kept separate from the table test so failures name which public
3682    // copy drifted. Extraction is scoped to the canonical TOML example by the
3683    // surrounding headings: scanning the whole guide would let unrelated future
3684    // snippets accidentally satisfy this count/order contract.
3685    #[test]
3686    fn guide_toml_example_env_annotations_match_code_spec() {
3687        let annotations = parse_guide_toml_env_annotations();
3688        assert!(
3689            !annotations.is_empty(),
3690            "canonical TOML example contains no `# env:` annotations"
3691        );
3692
3693        let spec_by_var = ENV_OVERRIDE_SPECS
3694            .iter()
3695            .map(|spec| (spec.env_var, spec))
3696            .collect::<std::collections::HashMap<_, _>>();
3697        let mut seen = HashSet::new();
3698
3699        for annotation in &annotations {
3700            let Some(spec) = spec_by_var.get(annotation.env_var.as_str()) else {
3701                panic!(
3702                    "GUIDE inline env annotation {:?} is not present in ENV_OVERRIDE_SPECS",
3703                    annotation.env_var
3704                );
3705            };
3706            assert!(
3707                seen.insert(annotation.env_var.as_str()),
3708                "GUIDE inline env annotation {:?} appears more than once",
3709                annotation.env_var
3710            );
3711            let expected_key = spec
3712                .target_field
3713                .rsplit('.')
3714                .next()
3715                .expect("target_field has at least one segment");
3716            assert_eq!(
3717                annotation.key, expected_key,
3718                "{} inline annotation is attached to TOML key {:?}, but code spec target {:?} ends in {:?}",
3719                annotation.env_var, annotation.key, spec.target_field, expected_key
3720            );
3721        }
3722
3723        let expected_count = ENV_OVERRIDE_SPECS.len() - INLINE_ENV_ANNOTATION_EXEMPTIONS.len();
3724        assert_eq!(
3725            annotations.len(),
3726            expected_count,
3727            "GUIDE inline env annotation count {} must equal ENV_OVERRIDE_SPECS count {} minus exemptions {:?}",
3728            annotations.len(),
3729            ENV_OVERRIDE_SPECS.len(),
3730            INLINE_ENV_ANNOTATION_EXEMPTIONS
3731        );
3732
3733        for spec in ENV_OVERRIDE_SPECS {
3734            if INLINE_ENV_ANNOTATION_EXEMPTIONS.contains(&spec.env_var) {
3735                assert!(
3736                    !seen.contains(spec.env_var),
3737                    "{} is deliberately exempt from inline annotation but was annotated",
3738                    spec.env_var
3739                );
3740            } else {
3741                assert!(
3742                    seen.contains(spec.env_var),
3743                    "{} is missing from GUIDE canonical TOML inline `# env:` annotations",
3744                    spec.env_var
3745                );
3746            }
3747        }
3748    }
3749
3750    fn guide_markdown() -> &'static str {
3751        include_str!("../docs/GUIDE.md")
3752    }
3753
3754    fn parse_guide_env_override_table() -> Vec<GuideEnvRow> {
3755        let guide = guide_markdown();
3756        let (_, after_begin) = guide
3757            .split_once("<!-- BEGIN ENV_OVERRIDE_TABLE -->")
3758            .expect("docs/GUIDE.md is missing <!-- BEGIN ENV_OVERRIDE_TABLE --> marker");
3759        let (table, _) = after_begin
3760            .split_once("<!-- END ENV_OVERRIDE_TABLE -->")
3761            .expect("docs/GUIDE.md is missing <!-- END ENV_OVERRIDE_TABLE --> marker");
3762        let rows = table
3763            .lines()
3764            .filter_map(parse_guide_env_override_row)
3765            .collect::<Vec<_>>();
3766        assert!(
3767            !rows.is_empty(),
3768            "docs/GUIDE.md ENV_OVERRIDE_TABLE markers were found but no data rows parsed"
3769        );
3770        rows
3771    }
3772
3773    fn parse_guide_env_override_row(line: &str) -> Option<GuideEnvRow> {
3774        let trimmed = line.trim();
3775        if !trimmed.starts_with('|')
3776            || trimmed.contains("|---")
3777            || trimmed.contains("Environment variable")
3778        {
3779            return None;
3780        }
3781        let cells = trimmed
3782            .trim_matches('|')
3783            .split('|')
3784            .map(str::trim)
3785            .collect::<Vec<_>>();
3786        assert_eq!(
3787            cells.len(),
3788            4,
3789            "env override GUIDE table row must have four cells, got {} in line {:?}",
3790            cells.len(),
3791            line
3792        );
3793        Some(GuideEnvRow {
3794            env_var: unwrap_markdown_code(cells[0], "Environment variable", line),
3795            target_field: unwrap_markdown_code(cells[1], "Target TOML path", line),
3796            value_type: cells[2].trim().to_owned(),
3797            notes: cells[3].trim().to_owned(),
3798        })
3799    }
3800
3801    fn unwrap_markdown_code(cell: &str, column: &str, row: &str) -> String {
3802        let inner = cell
3803            .strip_prefix('`')
3804            .and_then(|value| value.strip_suffix('`'))
3805            .unwrap_or_else(|| panic!("{column} cell must be backtick-wrapped in row {row:?}"));
3806        inner.trim().to_owned()
3807    }
3808
3809    fn parse_guide_toml_env_annotations() -> Vec<GuideEnvAnnotation> {
3810        let guide = guide_markdown();
3811        let (_, after_heading) = guide
3812            .split_once("### Complete TOML configuration reference")
3813            .expect("docs/GUIDE.md is missing canonical TOML configuration heading");
3814        let (section, _) = after_heading
3815            .split_once("### Bridging TOML config to `McpServerConfig`")
3816            .expect("docs/GUIDE.md is missing bridge heading after canonical TOML example");
3817        let (_, after_fence_start) = section
3818            .split_once("```toml")
3819            .expect("canonical TOML section is missing opening ```toml fence");
3820        let (toml_block, _) = after_fence_start
3821            .split_once("```")
3822            .expect("canonical TOML section is missing closing code fence");
3823
3824        toml_block
3825            .lines()
3826            .filter_map(parse_guide_toml_env_annotation_line)
3827            .collect()
3828    }
3829
3830    fn parse_guide_toml_env_annotation_line(line: &str) -> Option<GuideEnvAnnotation> {
3831        let (before_marker, after_marker) = line.split_once("# env: ")?;
3832        let env_var = after_marker
3833            .split_whitespace()
3834            .next()
3835            .unwrap_or_else(|| panic!("missing env var after `# env:` in line {line:?}"));
3836        let key_source = before_marker
3837            .trim_end()
3838            .strip_prefix('#')
3839            .map_or_else(|| before_marker.trim_end(), str::trim);
3840        let key = key_source
3841            .split_once('=')
3842            .unwrap_or_else(|| panic!("missing TOML key before `# env:` in line {line:?}"))
3843            .0
3844            .trim();
3845
3846        Some(GuideEnvAnnotation {
3847            env_var: env_var.to_owned(),
3848            key: key.to_owned(),
3849        })
3850    }
3851
3852    fn parse_rmcp_env_constants_from_config_source() -> Vec<String> {
3853        include_str!("config.rs")
3854            .lines()
3855            .filter(|line| {
3856                let trimmed = line.trim_start();
3857                trimmed.starts_with("pub(crate) const ")
3858                    && trimmed
3859                        .strip_prefix("pub(crate) const ")
3860                        .and_then(|rest| rest.split_once(':'))
3861                        .is_some_and(|(name, _)| name.ends_with("_ENV"))
3862                    && trimmed.contains("RMCP_SERVER_KIT__")
3863            })
3864            .filter_map(|line| {
3865                line.split_once('"')
3866                    .and_then(|(_, rest)| rest.split_once('"'))
3867                    .map(|(value, _)| value.to_owned())
3868            })
3869            .collect()
3870    }
3871}