Skip to main content

rmcp_server_kit/
config.rs

1use std::{path::PathBuf, time::Duration};
2
3use serde::Deserialize;
4
5use crate::{
6    error::RmcpServerKitError,
7    transport::{McpServerConfig, SecurityHeadersConfig},
8};
9
10#[cfg(test)]
11const SERVER_CONFIG_BRIDGED_FIELDS: &[&str] = &[
12    "listen_addr",
13    "listen_port",
14    "tls_cert_path",
15    "tls_key_path",
16    "tls_handshake_timeout",
17    "max_concurrent_tls_handshakes",
18    "shutdown_timeout",
19    "request_timeout",
20    "allowed_origins",
21    "tool_rate_limit",
22    "tool_rate_limit_burst",
23    "extra_route_rate_limit",
24    "extra_route_rate_limit_burst",
25    "extra_route_rate_limit_exempt_paths",
26    "trusted_proxies",
27    "forwarded_header",
28    "session_idle_timeout",
29    "sse_keep_alive",
30    "public_url",
31    "compression_enabled",
32    "compression_min_size",
33    "max_concurrent_requests",
34    "admin_enabled",
35    "admin_role",
36    "auth",
37    "max_request_body",
38    "expose_build_metadata",
39    "security_headers",
40];
41
42#[cfg(test)]
43const SERVER_CONFIG_NOT_BRIDGED_FIELDS: &[&str] = &["stdio_enabled"];
44
45#[cfg(test)]
46const MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS: &[&str] = &[
47    "name",
48    "version",
49    "rbac",
50    "readiness_check",
51    "extra_router",
52    "on_reload_ready",
53    "metrics_enabled",
54    "metrics_bind",
55];
56
57/// One environment override applied to a configuration struct.
58///
59/// Secret-typed targets redact their value by setting [`Self::value`] to
60/// `None`; non-secret targets carry the parsed string value that was applied.
61#[derive(Debug, Clone, PartialEq, Eq)]
62#[non_exhaustive]
63pub struct EnvOverride {
64    /// Environment variable name that supplied the override.
65    pub env_var: String,
66    /// Dotted TOML path that was overridden, such as `server.listen_port`.
67    pub target_field: String,
68    /// Source of the override value.
69    pub source: EnvOverrideSource,
70    /// Applied non-secret value, or `None` for secret-typed targets.
71    pub value: Option<String>,
72}
73
74/// Source kind for an applied environment override.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum EnvOverrideSource {
78    /// Read directly from an environment variable.
79    Env,
80    /// Read from the file named by a `_FILE`-suffixed environment variable.
81    File,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85#[non_exhaustive]
86#[cfg(test)]
87pub(crate) struct EnvOverrideSpec {
88    pub(crate) env_var: &'static str,
89    pub(crate) target_field: &'static str,
90    pub(crate) value_type: &'static str,
91    pub(crate) required_feature: Option<&'static str>,
92    pub(crate) redacted: bool,
93}
94
95#[cfg(test)]
96pub(crate) const ENV_OVERRIDE_SPECS: &[EnvOverrideSpec] = &[
97    EnvOverrideSpec {
98        env_var: "RMCP_SERVER_KIT__SERVER__LISTEN_ADDR",
99        target_field: "server.listen_addr",
100        value_type: "String",
101        required_feature: None,
102        redacted: false,
103    },
104    EnvOverrideSpec {
105        env_var: "RMCP_SERVER_KIT__SERVER__LISTEN_PORT",
106        target_field: "server.listen_port",
107        value_type: "u16",
108        required_feature: None,
109        redacted: false,
110    },
111    EnvOverrideSpec {
112        env_var: "RMCP_SERVER_KIT__SERVER__PUBLIC_URL",
113        target_field: "server.public_url",
114        value_type: "String",
115        required_feature: None,
116        redacted: false,
117    },
118    EnvOverrideSpec {
119        env_var: "RMCP_SERVER_KIT__SERVER__TLS_CERT_PATH",
120        target_field: "server.tls_cert_path",
121        value_type: "Path",
122        required_feature: None,
123        redacted: false,
124    },
125    EnvOverrideSpec {
126        env_var: "RMCP_SERVER_KIT__SERVER__TLS_KEY_PATH",
127        target_field: "server.tls_key_path",
128        value_type: "Path",
129        required_feature: None,
130        redacted: false,
131    },
132    EnvOverrideSpec {
133        env_var: "RMCP_SERVER_KIT__SERVER__ADMIN_ENABLED",
134        target_field: "server.admin_enabled",
135        value_type: "bool",
136        required_feature: None,
137        redacted: false,
138    },
139    EnvOverrideSpec {
140        env_var: "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ISSUER",
141        target_field: "server.auth.oauth.issuer",
142        value_type: "String",
143        required_feature: Some("oauth"),
144        redacted: false,
145    },
146    EnvOverrideSpec {
147        env_var: "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__AUDIENCE",
148        target_field: "server.auth.oauth.audience",
149        value_type: "String",
150        required_feature: Some("oauth"),
151        redacted: false,
152    },
153    EnvOverrideSpec {
154        env_var: "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__JWKS_URI",
155        target_field: "server.auth.oauth.jwks_uri",
156        value_type: "String",
157        required_feature: Some("oauth"),
158        redacted: false,
159    },
160    EnvOverrideSpec {
161        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__LOG_FORMAT",
162        target_field: "observability.log_format",
163        value_type: "String",
164        required_feature: None,
165        redacted: false,
166    },
167    EnvOverrideSpec {
168        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__METRICS_ENABLED",
169        target_field: "observability.metrics_enabled",
170        value_type: "bool",
171        required_feature: None,
172        redacted: false,
173    },
174    EnvOverrideSpec {
175        env_var: "RMCP_SERVER_KIT__OBSERVABILITY__METRICS_BIND",
176        target_field: "observability.metrics_bind",
177        value_type: "String",
178        required_feature: None,
179        redacted: false,
180    },
181    EnvOverrideSpec {
182        env_var: "RMCP_SERVER_KIT__RBAC__REDACTION_SALT",
183        target_field: "rbac.redaction_salt",
184        value_type: "SecretString",
185        required_feature: None,
186        redacted: true,
187    },
188    EnvOverrideSpec {
189        env_var: "RMCP_SERVER_KIT__RBAC__REDACTION_SALT_FILE",
190        target_field: "rbac.redaction_salt",
191        value_type: "Path",
192        required_feature: None,
193        redacted: true,
194    },
195];
196
197pub(crate) const SERVER_LISTEN_ADDR_ENV: &str = "RMCP_SERVER_KIT__SERVER__LISTEN_ADDR";
198pub(crate) const SERVER_LISTEN_PORT_ENV: &str = "RMCP_SERVER_KIT__SERVER__LISTEN_PORT";
199pub(crate) const SERVER_PUBLIC_URL_ENV: &str = "RMCP_SERVER_KIT__SERVER__PUBLIC_URL";
200pub(crate) const SERVER_TLS_CERT_PATH_ENV: &str = "RMCP_SERVER_KIT__SERVER__TLS_CERT_PATH";
201pub(crate) const SERVER_TLS_KEY_PATH_ENV: &str = "RMCP_SERVER_KIT__SERVER__TLS_KEY_PATH";
202pub(crate) const SERVER_ADMIN_ENABLED_ENV: &str = "RMCP_SERVER_KIT__SERVER__ADMIN_ENABLED";
203pub(crate) const SERVER_OAUTH_ISSUER_ENV: &str = "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__ISSUER";
204pub(crate) const SERVER_OAUTH_AUDIENCE_ENV: &str = "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__AUDIENCE";
205pub(crate) const SERVER_OAUTH_JWKS_URI_ENV: &str = "RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__JWKS_URI";
206pub(crate) const OBSERVABILITY_LOG_FORMAT_ENV: &str = "RMCP_SERVER_KIT__OBSERVABILITY__LOG_FORMAT";
207pub(crate) const OBSERVABILITY_METRICS_ENABLED_ENV: &str =
208    "RMCP_SERVER_KIT__OBSERVABILITY__METRICS_ENABLED";
209pub(crate) const OBSERVABILITY_METRICS_BIND_ENV: &str =
210    "RMCP_SERVER_KIT__OBSERVABILITY__METRICS_BIND";
211pub(crate) const RBAC_REDACTION_SALT_ENV: &str = "RMCP_SERVER_KIT__RBAC__REDACTION_SALT";
212pub(crate) const RBAC_REDACTION_SALT_FILE_ENV: &str = "RMCP_SERVER_KIT__RBAC__REDACTION_SALT_FILE";
213
214/// Server listener configuration (reusable across MCP projects).
215#[derive(Debug, Deserialize)]
216#[allow(
217    clippy::struct_excessive_bools,
218    reason = "server configuration is a flat TOML schema with independent boolean feature flags"
219)]
220#[non_exhaustive]
221pub struct ServerConfig {
222    /// Listen address (IP or hostname). Default: `127.0.0.1`.
223    #[serde(default = "default_listen_addr")]
224    pub listen_addr: String,
225    /// Listen TCP port. Default: `8443`.
226    #[serde(default = "default_listen_port")]
227    pub listen_port: u16,
228    /// Path to the TLS certificate (PEM). Required for TLS/mTLS.
229    pub tls_cert_path: Option<PathBuf>,
230    /// Path to the TLS private key (PEM). Required for TLS/mTLS.
231    pub tls_key_path: Option<PathBuf>,
232    /// Per-handshake deadline on the TLS accept path, parsed via
233    /// `humantime`. Idle or slow-loris connections are dropped once it
234    /// elapses. Startup-only (not hot-reloadable); ignored unless TLS is
235    /// configured. Default: `10s`.
236    #[serde(default = "default_tls_handshake_timeout")]
237    pub tls_handshake_timeout: String,
238    /// Cap on concurrently in-flight TLS handshakes. At saturation the
239    /// acceptor stops pulling new connections from the kernel backlog
240    /// (backpressure). Startup-only (not hot-reloadable); ignored unless
241    /// TLS is configured. Default: `256`.
242    #[serde(default = "default_max_concurrent_tls_handshakes")]
243    pub max_concurrent_tls_handshakes: usize,
244    /// Graceful shutdown timeout, parsed via `humantime`.
245    #[serde(default = "default_shutdown_timeout")]
246    pub shutdown_timeout: String,
247    /// Per-request timeout, parsed via `humantime`.
248    #[serde(default = "default_request_timeout")]
249    pub request_timeout: String,
250    /// Maximum request body size in bytes. Default: 1 MiB.
251    #[serde(default = "default_max_request_body")]
252    pub max_request_body: usize,
253    /// Allowed Origin header values for DNS rebinding protection (MCP spec).
254    /// Requests with an Origin not in this list are rejected with 403.
255    /// Requests without an Origin header are always allowed (non-browser).
256    #[serde(default)]
257    pub allowed_origins: Vec<String>,
258    /// Allow the stdio transport subcommand. Disabled by default because
259    /// stdio mode bypasses auth, RBAC, TLS, and Origin validation.
260    #[serde(default)]
261    pub stdio_enabled: bool,
262    /// Maximum tool invocations per source IP per minute.
263    /// When set, enforced by the RBAC middleware on `tools/call` requests.
264    /// Protects against both abuse and runaway LLM loops.
265    pub tool_rate_limit: Option<u32>,
266    /// Burst capacity for the tool rate limiter (bucket size; sustained
267    /// rate stays `tool_rate_limit`). Requires `tool_rate_limit`; must
268    /// be greater than zero.
269    pub tool_rate_limit_burst: Option<u32>,
270    /// Maximum requests per source IP per minute on application routes
271    /// merged via `McpServerConfig::with_extra_router` (which bypass
272    /// auth/RBAC). Opt-in; must be greater than zero when set.
273    /// Keyed by the direct socket peer — no `X-Forwarded-For`
274    /// interpretation. Startup-only.
275    pub extra_route_rate_limit: Option<u32>,
276    /// Burst capacity for the extra-route rate limiter (bucket size;
277    /// sustained rate stays `extra_route_rate_limit`). Requires
278    /// `extra_route_rate_limit`; must be greater than zero.
279    pub extra_route_rate_limit_burst: Option<u32>,
280    /// Exact-match request paths exempt from the extra-route rate
281    /// limiter. Raw string comparison against the request path — no
282    /// globs, no normalization; fail-closed (anything not listed stays
283    /// limited). Requires `extra_route_rate_limit`; entries must be
284    /// non-empty and start with `/`. Startup-only.
285    #[serde(default)]
286    pub extra_route_rate_limit_exempt_paths: Vec<String>,
287    /// Trusted reverse-proxy networks (CIDRs or bare IPs) for
288    /// trusted-forwarder mode. Empty (default) = off. When the direct
289    /// peer is inside one of these networks, the client IP is resolved
290    /// from the forwarding header (rightmost-untrusted walk) and all
291    /// per-IP rate limiters key by it. Startup-only.
292    #[serde(default)]
293    pub trusted_proxies: Vec<String>,
294    /// Which forwarding header trusted-forwarder mode reads:
295    /// `"x-forwarded-for"` (default when unset) or `"forwarded"`
296    /// (RFC 7239). Requires `trusted_proxies` to be nonempty.
297    pub forwarded_header: Option<crate::transport::ForwardedHeaderMode>,
298    /// Idle timeout for MCP sessions. Sessions with no activity for this
299    /// duration are closed automatically. Default: 20 minutes.
300    #[serde(default = "default_session_idle_timeout")]
301    pub session_idle_timeout: String,
302    /// Interval for SSE keep-alive pings sent to the client. Prevents
303    /// proxies and load balancers from killing idle connections.
304    /// Default: 15 seconds.
305    #[serde(default = "default_sse_keep_alive")]
306    pub sse_keep_alive: String,
307    /// Externally reachable base URL (e.g. `https://mcp.example.com`).
308    /// When set, OAuth metadata endpoints advertise this URL instead of
309    /// the listen address. Required when the server binds to `0.0.0.0`
310    /// behind a reverse proxy or inside a container.
311    pub public_url: Option<String>,
312    /// Enable gzip/br response compression for MCP responses.
313    #[serde(default)]
314    pub compression_enabled: bool,
315    /// Minimum response size (bytes) before compression kicks in.
316    /// Only used when `compression_enabled` is true. Default: 1024.
317    #[serde(default = "default_compression_min_size")]
318    pub compression_min_size: u16,
319    /// Global cap on in-flight HTTP requests. When reached, excess
320    /// requests receive 503 Service Unavailable (via load shedding).
321    pub max_concurrent_requests: Option<usize>,
322    /// Enable `/admin/*` diagnostic endpoints.
323    #[serde(default)]
324    pub admin_enabled: bool,
325    /// RBAC role required to access admin endpoints.
326    #[serde(default = "default_admin_role")]
327    pub admin_role: String,
328    /// Authentication configuration (API keys, mTLS, OAuth).
329    pub auth: Option<crate::auth::AuthConfig>,
330    /// Expose build metadata on the unauthenticated `/version` endpoint.
331    #[serde(default = "default_expose_build_metadata")]
332    pub expose_build_metadata: bool,
333    /// Per-header OWASP security-header overrides.
334    #[serde(default = "default_security_headers")]
335    pub security_headers: SecurityHeadersConfig,
336}
337
338impl Default for ServerConfig {
339    fn default() -> Self {
340        Self {
341            listen_addr: default_listen_addr(),
342            listen_port: default_listen_port(),
343            tls_cert_path: None,
344            tls_key_path: None,
345            tls_handshake_timeout: default_tls_handshake_timeout(),
346            max_concurrent_tls_handshakes: default_max_concurrent_tls_handshakes(),
347            shutdown_timeout: default_shutdown_timeout(),
348            request_timeout: default_request_timeout(),
349            max_request_body: default_max_request_body(),
350            allowed_origins: Vec::new(),
351            stdio_enabled: false,
352            tool_rate_limit: None,
353            tool_rate_limit_burst: None,
354            extra_route_rate_limit: None,
355            extra_route_rate_limit_burst: None,
356            extra_route_rate_limit_exempt_paths: Vec::new(),
357            trusted_proxies: Vec::new(),
358            forwarded_header: None,
359            session_idle_timeout: default_session_idle_timeout(),
360            sse_keep_alive: default_sse_keep_alive(),
361            public_url: None,
362            compression_enabled: false,
363            compression_min_size: default_compression_min_size(),
364            max_concurrent_requests: None,
365            admin_enabled: false,
366            admin_role: default_admin_role(),
367            auth: None,
368            expose_build_metadata: default_expose_build_metadata(),
369            security_headers: default_security_headers(),
370        }
371    }
372}
373
374impl ServerConfig {
375    /// Applies `RMCP_SERVER_KIT__SERVER__*` environment overrides onto this config.
376    ///
377    /// Includes the nested OAuth variables under
378    /// `RMCP_SERVER_KIT__SERVER__AUTH__OAUTH__*`. This method is opt-in:
379    /// constructors, validators, and server startup do not call it.
380    ///
381    /// # Errors
382    ///
383    /// Returns [`RmcpServerKitError::Config`] when an override cannot be parsed, when an
384    /// OAuth override lacks a declared `[server.auth.oauth]` parent, or when an
385    /// OAuth override is used in a build without the `oauth` feature.
386    ///
387    /// # Examples
388    ///
389    /// The full config-file pipeline lives in
390    /// [`examples/config_file_server.rs`](https://github.com/andrico21/rmcp-server-kit/blob/main/examples/config_file_server.rs).
391    ///
392    /// ```no_run
393    /// use rmcp_server_kit::config::ServerConfig;
394    ///
395    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
396    /// let mut server = ServerConfig::default();
397    /// // Do not set process env in doctests: rustdoc examples share a process.
398    /// let report = server.apply_env_overrides()?;
399    /// let _applied_fields: Vec<&str> = report
400    ///     .iter()
401    ///     .map(|entry| entry.target_field.as_str())
402    ///     .collect();
403    /// # Ok(())
404    /// # }
405    /// ```
406    pub fn apply_env_overrides(&mut self) -> Result<Vec<EnvOverride>, RmcpServerKitError> {
407        let mut applied = Vec::new();
408        apply_string_env(
409            SERVER_LISTEN_ADDR_ENV,
410            "server.listen_addr",
411            &mut self.listen_addr,
412            &mut applied,
413        )?;
414        if let Some(raw) = read_env(SERVER_LISTEN_PORT_ENV)? {
415            self.listen_port = parse_env_value(SERVER_LISTEN_PORT_ENV, &raw, "u16")?;
416            applied.push(env_report(
417                SERVER_LISTEN_PORT_ENV,
418                "server.listen_port",
419                raw,
420            ));
421        }
422        apply_optional_string_env(
423            SERVER_PUBLIC_URL_ENV,
424            "server.public_url",
425            &mut self.public_url,
426            &mut applied,
427        )?;
428        apply_optional_path_env(
429            SERVER_TLS_CERT_PATH_ENV,
430            "server.tls_cert_path",
431            &mut self.tls_cert_path,
432            &mut applied,
433        )?;
434        apply_optional_path_env(
435            SERVER_TLS_KEY_PATH_ENV,
436            "server.tls_key_path",
437            &mut self.tls_key_path,
438            &mut applied,
439        )?;
440        if let Some(raw) = read_env(SERVER_ADMIN_ENABLED_ENV)? {
441            self.admin_enabled = parse_env_bool(SERVER_ADMIN_ENABLED_ENV, &raw)?;
442            applied.push(env_report(
443                SERVER_ADMIN_ENABLED_ENV,
444                "server.admin_enabled",
445                raw,
446            ));
447        }
448        let oauth_env = OAuthEnvOverrides::read()?;
449        #[cfg(feature = "oauth")]
450        self.apply_oauth_env_overrides(oauth_env, &mut applied)?;
451        #[cfg(not(feature = "oauth"))]
452        reject_oauth_env_overrides(&oauth_env)?;
453        Ok(applied)
454    }
455
456    #[cfg(feature = "oauth")]
457    fn apply_oauth_env_overrides(
458        &mut self,
459        oauth_env: OAuthEnvOverrides,
460        applied: &mut Vec<EnvOverride>,
461    ) -> Result<(), RmcpServerKitError> {
462        if !oauth_env.is_set() {
463            return Ok(());
464        }
465
466        let Some(auth) = self.auth.as_mut() else {
467            let var = oauth_env.first_set_var();
468            return Err(RmcpServerKitError::Config(format!(
469                "{var} requires declaring [server.auth.oauth] before applying env overrides"
470            )));
471        };
472        let Some(oauth) = auth.oauth.as_mut() else {
473            let var = oauth_env.first_set_var();
474            return Err(RmcpServerKitError::Config(format!(
475                "{var} requires declaring [server.auth.oauth] before applying env overrides"
476            )));
477        };
478        if let Some(raw) = oauth_env.issuer {
479            applied.push(env_report(
480                SERVER_OAUTH_ISSUER_ENV,
481                "server.auth.oauth.issuer",
482                raw.clone(),
483            ));
484            oauth.issuer = raw;
485        }
486        if let Some(raw) = oauth_env.audience {
487            applied.push(env_report(
488                SERVER_OAUTH_AUDIENCE_ENV,
489                "server.auth.oauth.audience",
490                raw.clone(),
491            ));
492            oauth.audience = raw;
493        }
494        if let Some(raw) = oauth_env.jwks_uri {
495            applied.push(env_report(
496                SERVER_OAUTH_JWKS_URI_ENV,
497                "server.auth.oauth.jwks_uri",
498                raw.clone(),
499            ));
500            oauth.jwks_uri = raw;
501        }
502        Ok(())
503    }
504
505    /// Apply this TOML server schema to a programmatic MCP server base.
506    ///
507    /// Replacement semantics are used for every bridgeable transport field:
508    /// `None` and `false` values in TOML clear the corresponding value from
509    /// `base`. Only runtime-only fields such as `name`, `version`, RBAC,
510    /// readiness callbacks, extra routers, reload callbacks, and metrics
511    /// listener settings are preserved from `base`.
512    ///
513    /// Chain application-code builder overrides after this method when those
514    /// overrides should take precedence over TOML. This method is side-effect
515    /// free and never reads process environment variables.
516    ///
517    /// # Errors
518    ///
519    /// Returns [`RmcpServerKitError::Config`] when a duration string cannot be parsed.
520    ///
521    /// # Examples
522    ///
523    /// The full config-file pipeline lives in
524    /// [`examples/config_file_server.rs`](https://github.com/andrico21/rmcp-server-kit/blob/main/examples/config_file_server.rs).
525    ///
526    /// ```
527    /// use rmcp_server_kit::config::{ServerConfig, validate_server_config};
528    /// use rmcp_server_kit::transport::McpServerConfig;
529    ///
530    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
531    /// let server = ServerConfig::default();
532    /// validate_server_config(&server)?;
533    /// let config = server.apply_to_mcp_config(McpServerConfig::new(
534    ///     "placeholder:0",
535    ///     "my-server",
536    ///     "0.1.0",
537    /// ))?;
538    /// let _validated = config.validate()?;
539    /// # Ok(())
540    /// # }
541    /// ```
542    pub fn apply_to_mcp_config(
543        &self,
544        base: McpServerConfig,
545    ) -> Result<McpServerConfig, RmcpServerKitError> {
546        let config = base
547            .with_bind_addr(format!("{}:{}", self.listen_addr, self.listen_port))
548            .with_tls_paths(self.tls_cert_path.clone(), self.tls_key_path.clone())
549            .with_optional_auth(self.auth.clone())
550            .with_max_request_body(self.max_request_body)
551            .with_request_timeout(parse_duration_field(
552                "server.request_timeout",
553                &self.request_timeout,
554            )?)
555            .with_shutdown_timeout(parse_duration_field(
556                "server.shutdown_timeout",
557                &self.shutdown_timeout,
558            )?)
559            .with_session_idle_timeout(parse_duration_field(
560                "server.session_idle_timeout",
561                &self.session_idle_timeout,
562            )?)
563            .with_sse_keep_alive(parse_duration_field(
564                "server.sse_keep_alive",
565                &self.sse_keep_alive,
566            )?)
567            .with_tls_handshake_timeout(parse_duration_field(
568                "server.tls_handshake_timeout",
569                &self.tls_handshake_timeout,
570            )?)
571            .with_max_concurrent_tls_handshakes(self.max_concurrent_tls_handshakes)
572            .with_allowed_origins(self.allowed_origins.iter().map(String::as_str))
573            .with_extra_route_rate_limit_exempt_paths(
574                self.extra_route_rate_limit_exempt_paths
575                    .iter()
576                    .map(String::as_str),
577            )
578            .with_trusted_proxies(self.trusted_proxies.iter().map(String::as_str))
579            .with_optional_tool_rate_limit(self.tool_rate_limit)
580            .with_optional_tool_rate_limit_burst(self.tool_rate_limit_burst)
581            .with_optional_extra_route_rate_limit(self.extra_route_rate_limit)
582            .with_optional_extra_route_rate_limit_burst(self.extra_route_rate_limit_burst)
583            .with_optional_forwarded_header(self.forwarded_header)
584            .with_optional_public_url(self.public_url.clone())
585            .with_compression_enabled(self.compression_enabled)
586            .with_compression_min_size(self.compression_min_size)
587            .with_optional_max_concurrent_requests(self.max_concurrent_requests)
588            .with_admin_enabled(self.admin_enabled)
589            .with_admin_role(&self.admin_role)
590            .with_expose_build_metadata(self.expose_build_metadata)
591            .with_security_headers(self.security_headers.clone());
592
593        Ok(config)
594    }
595}
596
597impl ObservabilityConfig {
598    /// Applies `RMCP_SERVER_KIT__OBSERVABILITY__*` environment overrides.
599    ///
600    /// This method is opt-in and only mutates this struct; it does not update
601    /// tracing subscribers or server metrics configuration by itself.
602    ///
603    /// # Errors
604    ///
605    /// Returns [`RmcpServerKitError::Config`] when a boolean override cannot be parsed.
606    ///
607    /// # Examples
608    ///
609    /// The full config-file pipeline lives in
610    /// [`examples/config_file_server.rs`](https://github.com/andrico21/rmcp-server-kit/blob/main/examples/config_file_server.rs).
611    ///
612    /// ```no_run
613    /// use rmcp_server_kit::config::ObservabilityConfig;
614    ///
615    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
616    /// let mut observability = ObservabilityConfig::default();
617    /// // Do not set process env in doctests: rustdoc examples share a process.
618    /// let report = observability.apply_env_overrides()?;
619    /// let _report_shape: Vec<(&str, &str, Option<&str>)> = report
620    ///     .iter()
621    ///     .map(|entry| {
622    ///         (
623    ///             entry.env_var.as_str(),
624    ///             entry.target_field.as_str(),
625    ///             entry.value.as_deref(),
626    ///         )
627    ///     })
628    ///     .collect();
629    /// # Ok(())
630    /// # }
631    /// ```
632    pub fn apply_env_overrides(&mut self) -> Result<Vec<EnvOverride>, RmcpServerKitError> {
633        let mut applied = Vec::new();
634        apply_string_env(
635            OBSERVABILITY_LOG_FORMAT_ENV,
636            "observability.log_format",
637            &mut self.log_format,
638            &mut applied,
639        )?;
640        if let Some(raw) = read_env(OBSERVABILITY_METRICS_ENABLED_ENV)? {
641            self.metrics_enabled = parse_env_bool(OBSERVABILITY_METRICS_ENABLED_ENV, &raw)?;
642            applied.push(env_report(
643                OBSERVABILITY_METRICS_ENABLED_ENV,
644                "observability.metrics_enabled",
645                raw,
646            ));
647        }
648        apply_string_env(
649            OBSERVABILITY_METRICS_BIND_ENV,
650            "observability.metrics_bind",
651            &mut self.metrics_bind,
652            &mut applied,
653        )?;
654        Ok(applied)
655    }
656}
657
658pub(crate) fn read_env(var: &str) -> Result<Option<String>, RmcpServerKitError> {
659    match std::env::var(var) {
660        Ok(value) => Ok(Some(value)),
661        Err(std::env::VarError::NotPresent) => Ok(None),
662        Err(std::env::VarError::NotUnicode(_)) => Err(RmcpServerKitError::Config(format!(
663            "{var} must contain valid UTF-8"
664        ))),
665    }
666}
667
668fn env_report(env_var: &str, target_field: &str, value: String) -> EnvOverride {
669    EnvOverride {
670        env_var: env_var.to_owned(),
671        target_field: target_field.to_owned(),
672        source: EnvOverrideSource::Env,
673        value: Some(value),
674    }
675}
676
677pub(crate) fn secret_env_report(
678    env_var: &str,
679    target_field: &str,
680    source: EnvOverrideSource,
681) -> EnvOverride {
682    EnvOverride {
683        env_var: env_var.to_owned(),
684        target_field: target_field.to_owned(),
685        source,
686        value: None,
687    }
688}
689
690fn parse_env_value<T>(env_var: &str, raw: &str, expected: &str) -> Result<T, RmcpServerKitError>
691where
692    T: std::str::FromStr,
693{
694    raw.parse::<T>().map_err(|_| {
695        RmcpServerKitError::Config(format!("invalid value for {env_var}: expected {expected}"))
696    })
697}
698
699pub(crate) fn parse_env_bool(env_var: &str, raw: &str) -> Result<bool, RmcpServerKitError> {
700    parse_env_value(env_var, raw, "bool")
701}
702
703fn apply_string_env(
704    env_var: &str,
705    target_field: &str,
706    target: &mut String,
707    applied: &mut Vec<EnvOverride>,
708) -> Result<(), RmcpServerKitError> {
709    if let Some(raw) = read_env(env_var)? {
710        applied.push(env_report(env_var, target_field, raw.clone()));
711        *target = raw;
712    }
713    Ok(())
714}
715
716fn apply_optional_string_env(
717    env_var: &str,
718    target_field: &str,
719    target: &mut Option<String>,
720    applied: &mut Vec<EnvOverride>,
721) -> Result<(), RmcpServerKitError> {
722    if let Some(raw) = read_env(env_var)? {
723        *target = Some(raw.clone());
724        applied.push(env_report(env_var, target_field, raw));
725    }
726    Ok(())
727}
728
729fn apply_optional_path_env(
730    env_var: &str,
731    target_field: &str,
732    target: &mut Option<PathBuf>,
733    applied: &mut Vec<EnvOverride>,
734) -> Result<(), RmcpServerKitError> {
735    if let Some(raw) = read_env(env_var)? {
736        *target = Some(PathBuf::from(&raw));
737        applied.push(env_report(env_var, target_field, raw));
738    }
739    Ok(())
740}
741
742struct OAuthEnvOverrides {
743    issuer: Option<String>,
744    audience: Option<String>,
745    jwks_uri: Option<String>,
746}
747
748impl OAuthEnvOverrides {
749    fn read() -> Result<Self, RmcpServerKitError> {
750        Ok(Self {
751            issuer: read_env(SERVER_OAUTH_ISSUER_ENV)?,
752            audience: read_env(SERVER_OAUTH_AUDIENCE_ENV)?,
753            jwks_uri: read_env(SERVER_OAUTH_JWKS_URI_ENV)?,
754        })
755    }
756
757    fn is_set(&self) -> bool {
758        self.issuer.is_some() || self.audience.is_some() || self.jwks_uri.is_some()
759    }
760
761    fn first_set_var(&self) -> &'static str {
762        first_set_oauth_env(
763            self.issuer.as_deref(),
764            self.audience.as_deref(),
765            self.jwks_uri.as_deref(),
766        )
767    }
768}
769
770#[cfg(not(feature = "oauth"))]
771fn reject_oauth_env_overrides(oauth_env: &OAuthEnvOverrides) -> Result<(), RmcpServerKitError> {
772    if oauth_env.is_set() {
773        let var = oauth_env.first_set_var();
774        Err(RmcpServerKitError::Config(format!(
775            "{var} requires the `oauth` feature"
776        )))
777    } else {
778        Ok(())
779    }
780}
781
782fn first_set_oauth_env(
783    issuer: Option<&str>,
784    audience: Option<&str>,
785    jwks_uri: Option<&str>,
786) -> &'static str {
787    if issuer.is_some() {
788        SERVER_OAUTH_ISSUER_ENV
789    } else if audience.is_some() {
790        SERVER_OAUTH_AUDIENCE_ENV
791    } else if jwks_uri.is_some() {
792        SERVER_OAUTH_JWKS_URI_ENV
793    } else {
794        SERVER_OAUTH_ISSUER_ENV
795    }
796}
797
798fn parse_duration_field(field: &str, value: &str) -> Result<Duration, RmcpServerKitError> {
799    humantime::parse_duration(value).map_err(|error| {
800        RmcpServerKitError::Config(format!("invalid duration for {field}: {value:?}: {error}"))
801    })
802}
803
804/// Observability settings (reusable across MCP projects).
805#[derive(Debug, Deserialize)]
806#[non_exhaustive]
807pub struct ObservabilityConfig {
808    /// `tracing` log level / env filter string (e.g. `info,rmcp_server_kit=debug`).
809    #[serde(default = "default_log_level")]
810    pub log_level: String,
811    /// Log output format: `json`, `pretty`, or `text` (default: `pretty`).
812    #[serde(default = "default_log_format")]
813    pub log_format: String,
814    /// Optional path to an append-only audit log file.
815    pub audit_log_path: Option<PathBuf>,
816    /// Emit inbound HTTP request headers at DEBUG level in transport logs.
817    /// Sensitive headers remain redacted when enabled.
818    #[serde(default)]
819    pub log_request_headers: bool,
820    /// Enable the Prometheus metrics endpoint.
821    #[serde(default)]
822    pub metrics_enabled: bool,
823    /// Bind address for the Prometheus metrics listener.
824    #[serde(default = "default_metrics_bind")]
825    pub metrics_bind: String,
826}
827
828impl Default for ObservabilityConfig {
829    fn default() -> Self {
830        Self {
831            log_level: default_log_level(),
832            log_format: default_log_format(),
833            audit_log_path: None,
834            log_request_headers: false,
835            metrics_enabled: false,
836            metrics_bind: default_metrics_bind(),
837        }
838    }
839}
840
841/// Validate the generic server config fields.
842///
843/// # Errors
844///
845/// Returns `RmcpServerKitError::Config` on invalid values.
846pub fn validate_server_config(server: &ServerConfig) -> crate::error::Result<()> {
847    use crate::error::RmcpServerKitError;
848
849    if server.listen_port == 0 {
850        return Err(RmcpServerKitError::Config(
851            "listen_port must be nonzero".into(),
852        ));
853    }
854
855    match (&server.tls_cert_path, &server.tls_key_path) {
856        (Some(_), None) | (None, Some(_)) => {
857            return Err(RmcpServerKitError::Config(
858                "tls_cert_path and tls_key_path must both be set or both omitted".into(),
859            ));
860        }
861        _ => {}
862    }
863
864    if server.max_concurrent_requests == Some(0) {
865        return Err(RmcpServerKitError::Config(
866            "max_concurrent_requests must be nonzero when set".into(),
867        ));
868    }
869
870    if server.extra_route_rate_limit == Some(0) {
871        return Err(RmcpServerKitError::Config(
872            "server.extra_route_rate_limit must be greater than zero".into(),
873        ));
874    }
875
876    validate_rate_limit_knobs(server)?;
877    validate_trusted_forwarder_config(server)?;
878
879    if server.admin_enabled {
880        let auth_enabled = server.auth.as_ref().is_some_and(|a| a.enabled);
881        if !auth_enabled {
882            return Err(RmcpServerKitError::Config(
883                "admin_enabled=true requires auth to be configured and enabled".into(),
884            ));
885        }
886        if server.admin_role.trim().is_empty() {
887            return Err(RmcpServerKitError::Config(
888                "admin_role must not be empty".into(),
889            ));
890        }
891    }
892
893    for (field, value) in [
894        ("server.shutdown_timeout", server.shutdown_timeout.as_str()),
895        ("server.request_timeout", server.request_timeout.as_str()),
896        (
897            "server.session_idle_timeout",
898            server.session_idle_timeout.as_str(),
899        ),
900        ("server.sse_keep_alive", server.sse_keep_alive.as_str()),
901        (
902            "server.tls_handshake_timeout",
903            server.tls_handshake_timeout.as_str(),
904        ),
905    ] {
906        if humantime::parse_duration(value).is_err() {
907            return Err(RmcpServerKitError::Config(format!(
908                "invalid duration for {field}: {value:?}"
909            )));
910        }
911    }
912
913    // The handshake deadline must be a positive duration: a zero value
914    // would reap every TLS handshake before it could complete. Mirrors
915    // check #11 in `McpServerConfig::check`.
916    if humantime::parse_duration(&server.tls_handshake_timeout).is_ok_and(|d| d == Duration::ZERO) {
917        return Err(RmcpServerKitError::Config(
918            "server.tls_handshake_timeout must be greater than zero".into(),
919        ));
920    }
921
922    // A zero-permit handshake semaphore would never admit a handshake,
923    // deadlocking the TLS accept path. Mirrors check #12 in
924    // `McpServerConfig::check`.
925    if server.max_concurrent_tls_handshakes == 0 {
926        return Err(RmcpServerKitError::Config(
927            "server.max_concurrent_tls_handshakes must be greater than zero".into(),
928        ));
929    }
930
931    Ok(())
932}
933
934/// Validate the rate-limit burst knobs of a TOML [`ServerConfig`]: zero
935/// bursts and orphan bursts fail fast (mirrors `McpServerConfig::check`;
936/// the auth bursts have no orphan rule — their base rates always resolve).
937fn validate_rate_limit_knobs(server: &ServerConfig) -> crate::error::Result<()> {
938    use crate::error::RmcpServerKitError;
939
940    if server.tool_rate_limit_burst == Some(0) {
941        return Err(RmcpServerKitError::Config(
942            "server.tool_rate_limit_burst must be greater than zero".into(),
943        ));
944    }
945    if server.extra_route_rate_limit_burst == Some(0) {
946        return Err(RmcpServerKitError::Config(
947            "server.extra_route_rate_limit_burst must be greater than zero".into(),
948        ));
949    }
950    if server.tool_rate_limit_burst.is_some() && server.tool_rate_limit.is_none() {
951        return Err(RmcpServerKitError::Config(
952            "server.tool_rate_limit_burst requires server.tool_rate_limit".into(),
953        ));
954    }
955    if server.extra_route_rate_limit_burst.is_some() && server.extra_route_rate_limit.is_none() {
956        return Err(RmcpServerKitError::Config(
957            "server.extra_route_rate_limit_burst requires server.extra_route_rate_limit".into(),
958        ));
959    }
960    if !server.extra_route_rate_limit_exempt_paths.is_empty()
961        && server.extra_route_rate_limit.is_none()
962    {
963        return Err(RmcpServerKitError::Config(
964            "server.extra_route_rate_limit_exempt_paths requires server.extra_route_rate_limit"
965                .into(),
966        ));
967    }
968    for path in &server.extra_route_rate_limit_exempt_paths {
969        if path.is_empty() || !path.starts_with('/') {
970            return Err(RmcpServerKitError::Config(format!(
971                "server.extra_route_rate_limit_exempt_paths entries must be non-empty and start with '/': {path:?}"
972            )));
973        }
974    }
975    if let Some(rl) = server.auth.as_ref().and_then(|a| a.rate_limit.as_ref()) {
976        if rl.burst == Some(0) {
977            return Err(RmcpServerKitError::Config(
978                "auth.rate_limit.burst must be greater than zero".into(),
979            ));
980        }
981        if rl.pre_auth_burst == Some(0) {
982            return Err(RmcpServerKitError::Config(
983                "auth.rate_limit.pre_auth_burst must be greater than zero".into(),
984            ));
985        }
986    }
987    Ok(())
988}
989
990/// Validate the trusted-forwarder knobs of a TOML [`ServerConfig`]
991/// (mirrors `McpServerConfig::check_trusted_forwarder`).
992fn validate_trusted_forwarder_config(server: &ServerConfig) -> crate::error::Result<()> {
993    use crate::error::RmcpServerKitError;
994
995    for entry in &server.trusted_proxies {
996        crate::transport::validate_trusted_proxy_entry(entry)
997            .map_err(RmcpServerKitError::Config)?;
998    }
999    if server.forwarded_header.is_some() && server.trusted_proxies.is_empty() {
1000        return Err(RmcpServerKitError::Config(
1001            "server.forwarded_header requires server.trusted_proxies to be nonempty".into(),
1002        ));
1003    }
1004    Ok(())
1005}
1006
1007/// Validate observability config fields.
1008///
1009/// # Errors
1010///
1011/// Returns `RmcpServerKitError::Config` on invalid values.
1012pub fn validate_observability_config(obs: &ObservabilityConfig) -> crate::error::Result<()> {
1013    use tracing_subscriber::EnvFilter;
1014
1015    use crate::error::RmcpServerKitError;
1016
1017    if EnvFilter::try_new(&obs.log_level).is_err() {
1018        return Err(RmcpServerKitError::Config(format!(
1019            "invalid log_level: {:?} (expected a valid tracing filter directive, e.g. \"info\", \"debug,hyper=warn\")",
1020            obs.log_level
1021        )));
1022    }
1023    let valid_formats = ["json", "pretty", "text"];
1024    if !valid_formats.contains(&obs.log_format.as_str()) {
1025        return Err(RmcpServerKitError::Config(format!(
1026            "invalid log_format: {:?} (expected one of: {valid_formats:?})",
1027            obs.log_format
1028        )));
1029    }
1030
1031    Ok(())
1032}
1033
1034// - Default value functions -
1035
1036fn default_listen_addr() -> String {
1037    "127.0.0.1".into()
1038}
1039fn default_listen_port() -> u16 {
1040    8443
1041}
1042fn default_shutdown_timeout() -> String {
1043    "30s".into()
1044}
1045fn default_request_timeout() -> String {
1046    "120s".into()
1047}
1048const fn default_max_request_body() -> usize {
1049    1024 * 1024
1050}
1051const fn default_expose_build_metadata() -> bool {
1052    false
1053}
1054fn default_security_headers() -> SecurityHeadersConfig {
1055    SecurityHeadersConfig::default()
1056}
1057fn default_log_level() -> String {
1058    "info,rmcp=warn".into()
1059}
1060fn default_log_format() -> String {
1061    "pretty".into()
1062}
1063fn default_metrics_bind() -> String {
1064    "127.0.0.1:9090".into()
1065}
1066fn default_session_idle_timeout() -> String {
1067    "20m".into()
1068}
1069fn default_tls_handshake_timeout() -> String {
1070    "10s".into()
1071}
1072const fn default_max_concurrent_tls_handshakes() -> usize {
1073    256
1074}
1075fn default_admin_role() -> String {
1076    "admin".into()
1077}
1078fn default_compression_min_size() -> u16 {
1079    1024
1080}
1081fn default_sse_keep_alive() -> String {
1082    "15s".into()
1083}
1084
1085#[cfg(test)]
1086mod tests {
1087    #![allow(
1088        clippy::unwrap_used,
1089        clippy::expect_used,
1090        clippy::panic,
1091        clippy::indexing_slicing,
1092        clippy::unwrap_in_result,
1093        clippy::print_stdout,
1094        clippy::print_stderr,
1095        deprecated,
1096        reason = "test-only relaxations; production code uses ? and tracing"
1097    )]
1098    use std::{collections::HashSet, time::Duration};
1099
1100    use super::*;
1101    use crate::transport::McpServerConfig;
1102
1103    #[derive(Deserialize)]
1104    struct RootConfig {
1105        server: ServerConfig,
1106    }
1107
1108    fn server_from_root_toml(toml: &str) -> ServerConfig {
1109        toml::from_str::<RootConfig>(toml).unwrap().server
1110    }
1111
1112    // -- ServerConfig defaults --
1113
1114    #[test]
1115    fn server_config_defaults() {
1116        let cfg = ServerConfig::default();
1117        assert_eq!(cfg.listen_addr, "127.0.0.1");
1118        assert_eq!(cfg.listen_port, 8443);
1119        assert!(cfg.tls_cert_path.is_none());
1120        assert!(cfg.tls_key_path.is_none());
1121        assert_eq!(cfg.shutdown_timeout, "30s");
1122        assert_eq!(cfg.request_timeout, "120s");
1123        assert!(cfg.allowed_origins.is_empty());
1124        assert!(!cfg.stdio_enabled);
1125        assert!(cfg.tool_rate_limit.is_none());
1126        assert_eq!(cfg.session_idle_timeout, "20m");
1127        assert_eq!(cfg.sse_keep_alive, "15s");
1128        assert!(cfg.public_url.is_none());
1129    }
1130
1131    #[test]
1132    fn observability_config_defaults() {
1133        let cfg = ObservabilityConfig::default();
1134        assert_eq!(cfg.log_level, "info,rmcp=warn");
1135        assert_eq!(cfg.log_format, "pretty");
1136        assert!(cfg.audit_log_path.is_none());
1137        assert!(!cfg.log_request_headers);
1138        assert!(!cfg.metrics_enabled);
1139        assert_eq!(cfg.metrics_bind, "127.0.0.1:9090");
1140    }
1141
1142    // -- validate_server_config --
1143
1144    #[test]
1145    fn valid_server_config_passes() {
1146        let cfg = ServerConfig::default();
1147        assert!(validate_server_config(&cfg).is_ok());
1148    }
1149
1150    #[test]
1151    fn zero_port_rejected() {
1152        let cfg = ServerConfig {
1153            listen_port: 0,
1154            ..ServerConfig::default()
1155        };
1156        let err = validate_server_config(&cfg).unwrap_err();
1157        assert!(err.to_string().contains("listen_port"));
1158    }
1159
1160    #[test]
1161    fn zero_extra_route_rate_limit_rejected() {
1162        let cfg = ServerConfig {
1163            extra_route_rate_limit: Some(0),
1164            ..ServerConfig::default()
1165        };
1166        let err = validate_server_config(&cfg).unwrap_err();
1167        assert!(err.to_string().contains("extra_route_rate_limit"));
1168    }
1169
1170    #[test]
1171    fn zero_burst_knobs_rejected() {
1172        let cfg = ServerConfig {
1173            tool_rate_limit: Some(10),
1174            tool_rate_limit_burst: Some(0),
1175            ..ServerConfig::default()
1176        };
1177        let err = validate_server_config(&cfg).unwrap_err();
1178        assert!(err.to_string().contains("tool_rate_limit_burst"));
1179
1180        let cfg = ServerConfig {
1181            extra_route_rate_limit: Some(10),
1182            extra_route_rate_limit_burst: Some(0),
1183            ..ServerConfig::default()
1184        };
1185        let err = validate_server_config(&cfg).unwrap_err();
1186        assert!(err.to_string().contains("extra_route_rate_limit_burst"));
1187    }
1188
1189    #[test]
1190    fn orphan_burst_knobs_rejected() {
1191        let cfg = ServerConfig {
1192            tool_rate_limit_burst: Some(5),
1193            ..ServerConfig::default()
1194        };
1195        let err = validate_server_config(&cfg).unwrap_err();
1196        assert!(err.to_string().contains("requires server.tool_rate_limit"));
1197
1198        let cfg = ServerConfig {
1199            extra_route_rate_limit_burst: Some(5),
1200            ..ServerConfig::default()
1201        };
1202        let err = validate_server_config(&cfg).unwrap_err();
1203        assert!(
1204            err.to_string()
1205                .contains("requires server.extra_route_rate_limit")
1206        );
1207    }
1208
1209    #[test]
1210    fn exempt_paths_toml_roundtrip_and_validation() {
1211        let cfg: ServerConfig = toml::from_str(
1212            r#"
1213                extra_route_rate_limit = 60
1214                extra_route_rate_limit_exempt_paths = ["/.well-known/oauth-authorization-server"]
1215            "#,
1216        )
1217        .unwrap();
1218        assert_eq!(
1219            cfg.extra_route_rate_limit_exempt_paths,
1220            vec!["/.well-known/oauth-authorization-server".to_owned()]
1221        );
1222        assert!(validate_server_config(&cfg).is_ok());
1223    }
1224
1225    #[test]
1226    fn orphan_exempt_paths_rejected() {
1227        let cfg = ServerConfig {
1228            extra_route_rate_limit_exempt_paths: vec!["/ok".into()],
1229            ..ServerConfig::default()
1230        };
1231        let err = validate_server_config(&cfg).unwrap_err();
1232        assert!(
1233            err.to_string()
1234                .contains("requires server.extra_route_rate_limit")
1235        );
1236    }
1237
1238    #[test]
1239    fn malformed_exempt_paths_rejected() {
1240        for bad in ["", "no-slash"] {
1241            let cfg = ServerConfig {
1242                extra_route_rate_limit: Some(10),
1243                extra_route_rate_limit_exempt_paths: vec![bad.into()],
1244                ..ServerConfig::default()
1245            };
1246            let err = validate_server_config(&cfg).unwrap_err();
1247            assert!(
1248                err.to_string()
1249                    .contains("must be non-empty and start with '/'"),
1250                "entry {bad:?}: {err}"
1251            );
1252        }
1253    }
1254
1255    #[test]
1256    fn bad_trusted_proxy_entry_rejected() {
1257        let cfg = ServerConfig {
1258            trusted_proxies: vec!["not-a-cidr".into()],
1259            ..ServerConfig::default()
1260        };
1261        let err = validate_server_config(&cfg).unwrap_err();
1262        assert!(err.to_string().contains("trusted_proxies"));
1263    }
1264
1265    #[test]
1266    fn zero_prefix_trusted_proxy_rejected() {
1267        for entry in ["0.0.0.0/0", "::/0"] {
1268            let cfg = ServerConfig {
1269                trusted_proxies: vec![entry.into()],
1270                ..ServerConfig::default()
1271            };
1272            let err = validate_server_config(&cfg).unwrap_err();
1273            assert!(
1274                err.to_string().contains("prefix length 0"),
1275                "entry {entry:?}: {err}"
1276            );
1277        }
1278    }
1279
1280    #[test]
1281    fn cidr_and_bare_ip_proxy_entries_accepted() {
1282        let cfg = ServerConfig {
1283            trusted_proxies: vec!["10.0.0.0/8".into(), "192.0.2.1".into()],
1284            ..ServerConfig::default()
1285        };
1286        assert!(validate_server_config(&cfg).is_ok());
1287    }
1288
1289    #[test]
1290    fn forwarded_header_without_proxies_rejected() {
1291        let cfg = ServerConfig {
1292            forwarded_header: Some(crate::transport::ForwardedHeaderMode::Forwarded),
1293            ..ServerConfig::default()
1294        };
1295        let err = validate_server_config(&cfg).unwrap_err();
1296        assert!(err.to_string().contains("requires server.trusted_proxies"));
1297    }
1298
1299    #[test]
1300    fn zero_auth_bursts_rejected() {
1301        let auth = crate::auth::AuthConfig::with_keys(vec![])
1302            .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_burst(0));
1303        let cfg = ServerConfig {
1304            auth: Some(auth),
1305            ..ServerConfig::default()
1306        };
1307        let err = validate_server_config(&cfg).unwrap_err();
1308        assert!(err.to_string().contains("rate_limit.burst"));
1309
1310        let auth = crate::auth::AuthConfig::with_keys(vec![])
1311            .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(0));
1312        let cfg = ServerConfig {
1313            auth: Some(auth),
1314            ..ServerConfig::default()
1315        };
1316        let err = validate_server_config(&cfg).unwrap_err();
1317        assert!(err.to_string().contains("pre_auth_burst"));
1318    }
1319
1320    #[test]
1321    fn tls_cert_without_key_rejected() {
1322        let cfg = ServerConfig {
1323            tls_cert_path: Some("/tmp/cert.pem".into()),
1324            ..ServerConfig::default()
1325        };
1326        let err = validate_server_config(&cfg).unwrap_err();
1327        assert!(err.to_string().contains("tls_cert_path"));
1328    }
1329
1330    #[test]
1331    fn tls_key_without_cert_rejected() {
1332        let cfg = ServerConfig {
1333            tls_key_path: Some("/tmp/key.pem".into()),
1334            ..ServerConfig::default()
1335        };
1336        let err = validate_server_config(&cfg).unwrap_err();
1337        assert!(err.to_string().contains("tls_cert_path"));
1338    }
1339
1340    #[test]
1341    fn tls_both_set_passes() {
1342        let cfg = ServerConfig {
1343            tls_cert_path: Some("/tmp/cert.pem".into()),
1344            tls_key_path: Some("/tmp/key.pem".into()),
1345            ..ServerConfig::default()
1346        };
1347        assert!(validate_server_config(&cfg).is_ok());
1348    }
1349
1350    #[test]
1351    fn invalid_tls_handshake_timeout_rejected() {
1352        let cfg = ServerConfig {
1353            tls_handshake_timeout: "not-a-duration".into(),
1354            ..ServerConfig::default()
1355        };
1356        let err = validate_server_config(&cfg).unwrap_err();
1357        assert!(err.to_string().contains("tls_handshake_timeout"));
1358    }
1359
1360    #[test]
1361    fn zero_tls_handshake_timeout_rejected() {
1362        let cfg = ServerConfig {
1363            tls_handshake_timeout: "0s".into(),
1364            ..ServerConfig::default()
1365        };
1366        let err = validate_server_config(&cfg).unwrap_err();
1367        assert!(err.to_string().contains("tls_handshake_timeout"));
1368    }
1369
1370    #[test]
1371    fn zero_max_concurrent_tls_handshakes_rejected() {
1372        let cfg = ServerConfig {
1373            max_concurrent_tls_handshakes: 0,
1374            ..ServerConfig::default()
1375        };
1376        let err = validate_server_config(&cfg).unwrap_err();
1377        assert!(err.to_string().contains("max_concurrent_tls_handshakes"));
1378    }
1379
1380    #[test]
1381    fn invalid_shutdown_timeout_rejected() {
1382        let cfg = ServerConfig {
1383            shutdown_timeout: "not-a-duration".into(),
1384            ..ServerConfig::default()
1385        };
1386        let err = validate_server_config(&cfg).unwrap_err();
1387        assert!(err.to_string().contains("shutdown_timeout"));
1388    }
1389
1390    #[test]
1391    fn invalid_request_timeout_rejected() {
1392        let cfg = ServerConfig {
1393            request_timeout: "xyz".into(),
1394            ..ServerConfig::default()
1395        };
1396        let err = validate_server_config(&cfg).unwrap_err();
1397        assert!(err.to_string().contains("request_timeout"));
1398    }
1399
1400    // -- validate_observability_config --
1401
1402    #[test]
1403    fn valid_observability_config_passes() {
1404        let cfg = ObservabilityConfig::default();
1405        assert!(validate_observability_config(&cfg).is_ok());
1406    }
1407
1408    #[test]
1409    fn invalid_log_level_rejected() {
1410        let cfg = ObservabilityConfig {
1411            log_level: "[invalid".into(),
1412            ..ObservabilityConfig::default()
1413        };
1414        let err = validate_observability_config(&cfg).unwrap_err();
1415        assert!(err.to_string().contains("log_level"));
1416    }
1417
1418    #[test]
1419    fn invalid_log_format_rejected() {
1420        let cfg = ObservabilityConfig {
1421            log_format: "yaml".into(),
1422            ..ObservabilityConfig::default()
1423        };
1424        let err = validate_observability_config(&cfg).unwrap_err();
1425        assert!(err.to_string().contains("log_format"));
1426    }
1427
1428    #[test]
1429    fn all_valid_log_levels_accepted() {
1430        for level in &[
1431            "trace",
1432            "debug",
1433            "info",
1434            "warn",
1435            "error",
1436            "info,rmcp=warn",
1437            "debug,hyper=error",
1438        ] {
1439            let cfg = ObservabilityConfig {
1440                log_level: (*level).into(),
1441                ..ObservabilityConfig::default()
1442            };
1443            assert!(
1444                validate_observability_config(&cfg).is_ok(),
1445                "level {level} should be valid"
1446            );
1447        }
1448    }
1449
1450    #[test]
1451    fn all_log_formats_accepted() {
1452        for fmt in &["json", "pretty", "text"] {
1453            let cfg = ObservabilityConfig {
1454                log_format: (*fmt).into(),
1455                ..ObservabilityConfig::default()
1456            };
1457            assert!(
1458                validate_observability_config(&cfg).is_ok(),
1459                "format {fmt} should be valid"
1460            );
1461        }
1462    }
1463
1464    // -- serde deserialization --
1465
1466    #[test]
1467    fn server_config_deserialize_defaults() {
1468        let cfg: ServerConfig = toml::from_str("").unwrap();
1469        assert_eq!(cfg.listen_port, 8443);
1470        assert_eq!(cfg.listen_addr, "127.0.0.1");
1471        assert_eq!(cfg.tls_handshake_timeout, "10s");
1472        assert_eq!(cfg.max_concurrent_tls_handshakes, 256);
1473    }
1474
1475    #[test]
1476    fn t1_existing_server_example_deserializes_with_new_defaults() {
1477        let server = server_from_root_toml(
1478            r#"
1479                [server]
1480                listen_addr = "0.0.0.0"
1481                listen_port = 8443
1482                tls_cert_path = "/etc/certs/server.crt"
1483                tls_key_path = "/etc/certs/server.key"
1484                shutdown_timeout = "30s"
1485                request_timeout = "120s"
1486                allowed_origins = ["http://localhost:3000", "https://myapp.example.com"]
1487                tool_rate_limit = 120
1488            "#,
1489        );
1490
1491        assert_eq!(server.max_request_body, 1024 * 1024);
1492        assert!(!server.expose_build_metadata);
1493        assert_eq!(server.security_headers, SecurityHeadersConfig::default());
1494    }
1495
1496    #[test]
1497    fn t2_default_bridge_is_no_op_for_mcp_defaults() {
1498        let actual = ServerConfig::default()
1499            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
1500            .unwrap();
1501        let expected = McpServerConfig::new("127.0.0.1:8443", "t", "0.0.0");
1502
1503        assert_default_bridge_core_fields(&actual, &expected);
1504        assert_default_bridge_limit_fields(&actual, &expected);
1505        assert_default_bridge_metadata_fields(&actual, &expected);
1506    }
1507
1508    fn assert_default_bridge_core_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
1509        assert_eq!(actual.bind_addr, expected.bind_addr);
1510        assert_eq!(actual.tls_cert_path, expected.tls_cert_path);
1511        assert_eq!(actual.tls_key_path, expected.tls_key_path);
1512        assert!(actual.auth.is_none());
1513        assert_eq!(actual.allowed_origins, expected.allowed_origins);
1514        assert_eq!(actual.trusted_proxies, expected.trusted_proxies);
1515        assert_eq!(actual.forwarded_header, expected.forwarded_header);
1516        assert_eq!(actual.public_url, expected.public_url);
1517        assert_eq!(actual.name, expected.name);
1518        assert_eq!(actual.version, expected.version);
1519    }
1520
1521    fn assert_default_bridge_limit_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
1522        assert_eq!(actual.tool_rate_limit, expected.tool_rate_limit);
1523        assert_eq!(actual.tool_rate_limit_burst, expected.tool_rate_limit_burst);
1524        assert_eq!(
1525            actual.extra_route_rate_limit,
1526            expected.extra_route_rate_limit
1527        );
1528        assert_eq!(
1529            actual.extra_route_rate_limit_burst,
1530            expected.extra_route_rate_limit_burst
1531        );
1532        assert_eq!(
1533            actual.extra_route_rate_limit_exempt_paths,
1534            expected.extra_route_rate_limit_exempt_paths
1535        );
1536        assert_eq!(actual.max_request_body, expected.max_request_body);
1537        assert_eq!(
1538            actual.max_concurrent_requests,
1539            expected.max_concurrent_requests
1540        );
1541    }
1542
1543    fn assert_default_bridge_metadata_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
1544        assert_eq!(actual.session_idle_timeout, expected.session_idle_timeout);
1545        assert_eq!(actual.sse_keep_alive, expected.sse_keep_alive);
1546        assert_eq!(actual.request_timeout, expected.request_timeout);
1547        assert_eq!(actual.shutdown_timeout, expected.shutdown_timeout);
1548        assert_eq!(actual.tls_handshake_timeout, expected.tls_handshake_timeout);
1549        assert_eq!(
1550            actual.max_concurrent_tls_handshakes,
1551            expected.max_concurrent_tls_handshakes
1552        );
1553        assert_eq!(actual.compression_enabled, expected.compression_enabled);
1554        assert_eq!(actual.compression_min_size, expected.compression_min_size);
1555        assert_eq!(actual.admin_enabled, expected.admin_enabled);
1556        assert_eq!(actual.admin_role, expected.admin_role);
1557        assert_eq!(actual.expose_build_metadata, expected.expose_build_metadata);
1558        assert_eq!(actual.security_headers, expected.security_headers);
1559    }
1560
1561    #[test]
1562    fn t5_hsts_preload_from_toml_rejected_by_mcp_validate() {
1563        let cfg = server_from_root_toml(
1564            r#"
1565                [server.security_headers]
1566                strict_transport_security = "max-age=1; preload"
1567            "#,
1568        );
1569        let mcp = cfg
1570            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
1571            .unwrap();
1572
1573        let err = mcp.validate().unwrap_err();
1574        let msg = err.to_string();
1575        assert!(msg.contains("preload"), "error must mention preload: {msg}");
1576    }
1577
1578    #[test]
1579    fn t6_bad_security_header_from_toml_rejected_by_mcp_validate() {
1580        let cfg = server_from_root_toml(
1581            r#"
1582                [server.security_headers]
1583                content_security_policy = "bad\nvalue"
1584            "#,
1585        );
1586        let mcp = cfg
1587            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
1588            .unwrap();
1589
1590        let err = mcp.validate().unwrap_err();
1591        let msg = err.to_string();
1592        assert!(
1593            msg.contains("invalid security_headers.content_security_policy"),
1594            "error must name invalid header field: {msg}"
1595        );
1596    }
1597
1598    #[test]
1599    fn t7_zero_max_request_body_rejected_by_mcp_validate() {
1600        let cfg: ServerConfig = toml::from_str("max_request_body = 0").unwrap();
1601        let mcp = cfg
1602            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
1603            .unwrap();
1604
1605        let err = mcp.validate().unwrap_err();
1606        assert!(
1607            err.to_string()
1608                .contains("max_request_body must be greater than zero")
1609        );
1610    }
1611
1612    #[test]
1613    fn t9_unknown_security_header_key_is_ignored() {
1614        let cfg = server_from_root_toml(
1615            r#"
1616                [server.security_headers]
1617                typo_content_security_policy = "default-src 'self'"
1618            "#,
1619        );
1620
1621        assert_eq!(cfg.security_headers, SecurityHeadersConfig::default());
1622    }
1623
1624    #[test]
1625    fn all_twelve_security_header_keys_deserialize_from_server_toml() {
1626        let cfg = server_from_root_toml(
1627            r#"
1628                [server.security_headers]
1629                content_security_policy = "csp"
1630                strict_transport_security = "max-age=1"
1631                cross_origin_embedder_policy = "coep"
1632                cross_origin_resource_policy = "corp"
1633                cross_origin_opener_policy = "coop"
1634                permissions_policy = "permissions"
1635                referrer_policy = "referrer"
1636                x_frame_options = "frame"
1637                cache_control = "cache"
1638                x_content_type_options = "content-type"
1639                x_dns_prefetch_control = "dns"
1640                x_permitted_cross_domain_policies = "cross-domain"
1641            "#,
1642        );
1643
1644        let headers = cfg.security_headers;
1645        assert_eq!(headers.content_security_policy.as_deref(), Some("csp"));
1646        assert_eq!(
1647            headers.strict_transport_security.as_deref(),
1648            Some("max-age=1")
1649        );
1650        assert_eq!(
1651            headers.cross_origin_embedder_policy.as_deref(),
1652            Some("coep")
1653        );
1654        assert_eq!(
1655            headers.cross_origin_resource_policy.as_deref(),
1656            Some("corp")
1657        );
1658        assert_eq!(headers.cross_origin_opener_policy.as_deref(), Some("coop"));
1659        assert_eq!(headers.permissions_policy.as_deref(), Some("permissions"));
1660        assert_eq!(headers.referrer_policy.as_deref(), Some("referrer"));
1661        assert_eq!(headers.x_frame_options.as_deref(), Some("frame"));
1662        assert_eq!(headers.cache_control.as_deref(), Some("cache"));
1663        assert_eq!(
1664            headers.x_content_type_options.as_deref(),
1665            Some("content-type")
1666        );
1667        assert_eq!(headers.x_dns_prefetch_control.as_deref(), Some("dns"));
1668        assert_eq!(
1669            headers.x_permitted_cross_domain_policies.as_deref(),
1670            Some("cross-domain")
1671        );
1672    }
1673
1674    #[test]
1675    fn t10_every_server_config_field_is_classified_for_bridge() {
1676        let source = include_str!("config.rs").replace("\r\n", "\n");
1677        let (_, after_struct_start) = source
1678            .split_once("pub struct ServerConfig {")
1679            .expect("ServerConfig struct start marker");
1680        let (struct_body, _) = after_struct_start
1681            .split_once("\n}\n\nimpl ServerConfig")
1682            .expect("ServerConfig struct end marker");
1683        let actual_fields: HashSet<&str> = struct_body
1684            .lines()
1685            .filter_map(|line| {
1686                line.trim()
1687                    .strip_prefix("pub ")
1688                    .and_then(|rest| rest.split_once(':').map(|(name, _)| name.trim()))
1689            })
1690            .collect();
1691        let bridged_fields: HashSet<&str> = SERVER_CONFIG_BRIDGED_FIELDS.iter().copied().collect();
1692        let not_bridged_fields: HashSet<&str> =
1693            SERVER_CONFIG_NOT_BRIDGED_FIELDS.iter().copied().collect();
1694        let runtime_only_fields: HashSet<&str> = MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS
1695            .iter()
1696            .copied()
1697            .collect();
1698        let classified_fields: HashSet<&str> =
1699            bridged_fields.union(&not_bridged_fields).copied().collect();
1700
1701        assert_eq!(actual_fields, classified_fields);
1702        assert!(bridged_fields.is_disjoint(&not_bridged_fields));
1703        assert!(runtime_only_fields.is_disjoint(&actual_fields));
1704        assert!(SERVER_CONFIG_NOT_BRIDGED_FIELDS.contains(&"stdio_enabled"));
1705        assert!(MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS.contains(&"rbac"));
1706        assert!(MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS.contains(&"metrics_bind"));
1707    }
1708
1709    #[test]
1710    fn replacement_semantics_clear_base_option_and_false_bool_fields() {
1711        let (_token, hash) = crate::auth::generate_api_key().unwrap();
1712        let base = McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
1713            .with_tls("/tmp/base.crt", "/tmp/base.key")
1714            .with_auth(crate::auth::AuthConfig::with_keys(vec![
1715                crate::auth::ApiKeyEntry::new("base-key", hash, "admin"),
1716            ]))
1717            .with_tool_rate_limit(10)
1718            .with_tool_rate_limit_burst(20)
1719            .with_extra_route_rate_limit(30)
1720            .with_extra_route_rate_limit_burst(40)
1721            .with_trusted_proxies(["127.0.0.1/32"])
1722            .with_forwarded_header(crate::transport::ForwardedHeaderMode::Forwarded)
1723            .with_public_url("https://base.example")
1724            .enable_compression(512)
1725            .with_max_concurrent_requests(99)
1726            .enable_admin("admin")
1727            .expose_build_metadata();
1728
1729        let actual = ServerConfig::default().apply_to_mcp_config(base).unwrap();
1730
1731        assert!(actual.tls_cert_path.is_none());
1732        assert!(actual.tls_key_path.is_none());
1733        assert!(actual.auth.is_none());
1734        assert!(actual.tool_rate_limit.is_none());
1735        assert!(actual.tool_rate_limit_burst.is_none());
1736        assert!(actual.extra_route_rate_limit.is_none());
1737        assert!(actual.extra_route_rate_limit_burst.is_none());
1738        assert!(actual.forwarded_header.is_none());
1739        assert!(actual.public_url.is_none());
1740        assert!(!actual.compression_enabled);
1741        assert_eq!(actual.compression_min_size, 1024);
1742        assert!(actual.max_concurrent_requests.is_none());
1743        assert!(!actual.admin_enabled);
1744        assert_eq!(actual.admin_role, "admin");
1745        assert!(!actual.expose_build_metadata);
1746    }
1747
1748    #[test]
1749    fn partial_tls_toml_does_not_inherit_base_key() {
1750        let cfg = ServerConfig {
1751            tls_cert_path: Some("/tmp/toml.crt".into()),
1752            tls_key_path: None,
1753            ..ServerConfig::default()
1754        };
1755        let mcp = cfg
1756            .apply_to_mcp_config(
1757                McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
1758                    .with_tls("/tmp/base.crt", "/tmp/base.key"),
1759            )
1760            .unwrap();
1761
1762        assert_eq!(mcp.tls_cert_path, Some(PathBuf::from("/tmp/toml.crt")));
1763        assert!(mcp.tls_key_path.is_none());
1764        let err = mcp.validate().unwrap_err();
1765        assert!(err.to_string().contains("tls_key_path"));
1766    }
1767
1768    #[test]
1769    fn partial_tls_toml_does_not_inherit_base_cert() {
1770        let cfg = ServerConfig {
1771            tls_cert_path: None,
1772            tls_key_path: Some("/tmp/toml.key".into()),
1773            ..ServerConfig::default()
1774        };
1775        let mcp = cfg
1776            .apply_to_mcp_config(
1777                McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
1778                    .with_tls("/tmp/base.crt", "/tmp/base.key"),
1779            )
1780            .unwrap();
1781
1782        assert!(mcp.tls_cert_path.is_none());
1783        assert_eq!(mcp.tls_key_path, Some(PathBuf::from("/tmp/toml.key")));
1784        let err = mcp.validate().unwrap_err();
1785        assert!(err.to_string().contains("tls_cert_path"));
1786    }
1787
1788    #[test]
1789    fn t11_bridge_maps_bind_addr_and_request_timeout() {
1790        let cfg: ServerConfig = toml::from_str(
1791            r#"
1792                listen_addr = "127.0.0.2"
1793                listen_port = 9000
1794                request_timeout = "5s"
1795            "#,
1796        )
1797        .unwrap();
1798
1799        let mcp = cfg
1800            .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
1801            .unwrap();
1802
1803        assert_eq!(mcp.bind_addr, "127.0.0.2:9000");
1804        assert_eq!(mcp.request_timeout, Duration::from_secs(5));
1805    }
1806
1807    #[test]
1808    fn t12_bridge_rejects_invalid_request_timeout() {
1809        let cfg: ServerConfig = toml::from_str(r#"request_timeout = "not-a-duration""#).unwrap();
1810
1811        let Err(err) = cfg.apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
1812        else {
1813            panic!("invalid request_timeout must fail");
1814        };
1815
1816        assert!(err.to_string().contains("request_timeout"));
1817    }
1818
1819    #[test]
1820    fn observability_config_deserialize_defaults() {
1821        let cfg: ObservabilityConfig = toml::from_str("").unwrap();
1822        assert_eq!(cfg.log_level, "info,rmcp=warn");
1823        assert_eq!(cfg.log_format, "pretty");
1824        assert!(!cfg.log_request_headers);
1825        assert!(!cfg.metrics_enabled);
1826    }
1827
1828    fn all_env_vars() -> Vec<&'static str> {
1829        ENV_OVERRIDE_SPECS.iter().map(|spec| spec.env_var).collect()
1830    }
1831
1832    fn with_env_vars<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
1833        let mut all = all_env_vars()
1834            .into_iter()
1835            .map(|var| (var, None::<&str>))
1836            .collect::<Vec<_>>();
1837        all.extend(vars.iter().copied());
1838        temp_env::with_vars(all, f)
1839    }
1840
1841    #[test]
1842    fn e1_server_env_overrides_absent_keeps_defaults() {
1843        with_env_vars(&[], || {
1844            let mut cfg = ServerConfig::default();
1845            let report = cfg.apply_env_overrides().unwrap();
1846            assert!(report.is_empty());
1847            assert_eq!(cfg.listen_addr, "127.0.0.1");
1848            assert_eq!(cfg.listen_port, 8443);
1849            assert!(cfg.tls_cert_path.is_none());
1850            assert!(cfg.tls_key_path.is_none());
1851            assert!(cfg.public_url.is_none());
1852            assert!(!cfg.admin_enabled);
1853            assert!(cfg.auth.is_none());
1854        });
1855    }
1856
1857    #[test]
1858    fn e2_listen_port_env_override_applies_and_reports() {
1859        with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("9000"))], || {
1860            let mut cfg = ServerConfig::default();
1861            let report = cfg.apply_env_overrides().unwrap();
1862            assert_eq!(cfg.listen_port, 9000);
1863            assert_eq!(report.len(), 1);
1864            assert_eq!(report[0].env_var, SERVER_LISTEN_PORT_ENV);
1865            assert_eq!(report[0].target_field, "server.listen_port");
1866            assert_eq!(report[0].source, EnvOverrideSource::Env);
1867            assert_eq!(report[0].value.as_deref(), Some("9000"));
1868        });
1869    }
1870
1871    #[test]
1872    fn e3_bad_listen_port_env_fails_closed() {
1873        with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("not-a-number"))], || {
1874            let mut cfg = ServerConfig::default();
1875            let err = cfg.apply_env_overrides().unwrap_err();
1876            let msg = err.to_string();
1877            assert!(msg.contains(SERVER_LISTEN_PORT_ENV));
1878            assert!(msg.contains("u16"));
1879        });
1880    }
1881
1882    #[test]
1883    fn e4_oauth_env_without_auth_parent_fails_closed() {
1884        with_env_vars(&[(SERVER_OAUTH_ISSUER_ENV, Some("https://idp/"))], || {
1885            let mut cfg = ServerConfig::default();
1886            let err = cfg.apply_env_overrides().unwrap_err();
1887            let msg = err.to_string();
1888            assert!(msg.contains(SERVER_OAUTH_ISSUER_ENV));
1889            #[cfg(feature = "oauth")]
1890            assert!(msg.contains("[server.auth.oauth]"));
1891            #[cfg(not(feature = "oauth"))]
1892            assert!(msg.contains("oauth` feature"));
1893        });
1894    }
1895
1896    #[cfg(feature = "oauth")]
1897    #[test]
1898    fn e5_oauth_env_populates_declared_parent_and_validates() {
1899        with_env_vars(
1900            &[
1901                (SERVER_OAUTH_ISSUER_ENV, Some("https://idp.example/")),
1902                (SERVER_OAUTH_AUDIENCE_ENV, Some("mcp")),
1903                (
1904                    SERVER_OAUTH_JWKS_URI_ENV,
1905                    Some("https://idp.example/.well-known/jwks.json"),
1906                ),
1907            ],
1908            || {
1909                let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
1910                auth.oauth = Some(crate::oauth::OAuthConfig {
1911                    role_claim: Some("roles".into()),
1912                    ..crate::oauth::OAuthConfig::default()
1913                });
1914                let mut cfg = ServerConfig {
1915                    auth: Some(auth),
1916                    ..ServerConfig::default()
1917                };
1918
1919                let report = cfg.apply_env_overrides().unwrap();
1920                let oauth = cfg
1921                    .auth
1922                    .as_ref()
1923                    .and_then(|auth| auth.oauth.as_ref())
1924                    .unwrap();
1925                assert_eq!(oauth.issuer, "https://idp.example/");
1926                assert_eq!(oauth.audience, "mcp");
1927                assert_eq!(oauth.jwks_uri, "https://idp.example/.well-known/jwks.json");
1928                assert!(oauth.validate().is_ok());
1929                assert_eq!(report.len(), 3);
1930            },
1931        );
1932    }
1933
1934    #[cfg(feature = "oauth")]
1935    #[test]
1936    fn e5b_oauth_env_missing_audience_fails_validate() {
1937        with_env_vars(
1938            &[
1939                (SERVER_OAUTH_ISSUER_ENV, Some("https://idp.example/")),
1940                (
1941                    SERVER_OAUTH_JWKS_URI_ENV,
1942                    Some("https://idp.example/.well-known/jwks.json"),
1943                ),
1944            ],
1945            || {
1946                let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
1947                auth.oauth = Some(crate::oauth::OAuthConfig {
1948                    role_claim: Some("roles".into()),
1949                    ..crate::oauth::OAuthConfig::default()
1950                });
1951                let mut cfg = ServerConfig {
1952                    auth: Some(auth),
1953                    ..ServerConfig::default()
1954                };
1955
1956                cfg.apply_env_overrides().unwrap();
1957                let oauth = cfg
1958                    .auth
1959                    .as_ref()
1960                    .and_then(|auth| auth.oauth.as_ref())
1961                    .unwrap();
1962                let err = oauth.validate().unwrap_err();
1963                assert!(err.to_string().contains("oauth.audience must not be empty"));
1964            },
1965        );
1966    }
1967
1968    #[test]
1969    fn e9_bad_observability_bool_env_fails_closed() {
1970        with_env_vars(
1971            &[(OBSERVABILITY_METRICS_ENABLED_ENV, Some("maybe"))],
1972            || {
1973                let mut cfg = ObservabilityConfig::default();
1974                let err = cfg.apply_env_overrides().unwrap_err();
1975                let msg = err.to_string();
1976                assert!(msg.contains(OBSERVABILITY_METRICS_ENABLED_ENV));
1977                assert!(msg.contains("bool"));
1978            },
1979        );
1980    }
1981
1982    #[test]
1983    fn e10_env_port_reaches_mcp_bridge() {
1984        with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("9100"))], || {
1985            let mut server: ServerConfig = toml::from_str(r#"listen_addr = "127.0.0.2""#).unwrap();
1986            server.apply_env_overrides().unwrap();
1987            let mcp = server
1988                .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
1989                .unwrap();
1990            assert_eq!(mcp.bind_addr, "127.0.0.2:9100");
1991            assert!(mcp.validate().is_ok());
1992        });
1993    }
1994
1995    #[cfg(unix)]
1996    #[test]
1997    fn non_unicode_env_value_fails_closed() {
1998        use std::{ffi::OsString, os::unix::ffi::OsStringExt};
1999
2000        let bad = OsString::from_vec(vec![0x66, 0x80, 0x6f]);
2001        temp_env::with_var(SERVER_LISTEN_ADDR_ENV, Some(bad), || {
2002            let mut cfg = ServerConfig::default();
2003            let err = cfg.apply_env_overrides().unwrap_err();
2004            let msg = err.to_string();
2005            assert!(msg.contains(SERVER_LISTEN_ADDR_ENV));
2006            assert!(msg.contains("UTF-8"));
2007        });
2008    }
2009
2010    #[cfg(not(feature = "oauth"))]
2011    #[test]
2012    fn e11_oauth_env_feature_off_fails_closed() {
2013        with_env_vars(&[(SERVER_OAUTH_ISSUER_ENV, Some("https://idp/"))], || {
2014            let mut cfg = ServerConfig {
2015                auth: Some(crate::auth::AuthConfig::with_keys(vec![])),
2016                ..ServerConfig::default()
2017            };
2018            let err = cfg.apply_env_overrides().unwrap_err();
2019            let msg = err.to_string();
2020            assert!(msg.contains(SERVER_OAUTH_ISSUER_ENV));
2021            assert!(msg.contains("oauth` feature"));
2022        });
2023    }
2024
2025    #[test]
2026    fn env_override_spec_contains_exact_fourteen_vars() {
2027        let vars = ENV_OVERRIDE_SPECS
2028            .iter()
2029            .map(|spec| {
2030                (
2031                    spec.env_var,
2032                    spec.target_field,
2033                    spec.required_feature,
2034                    spec.redacted,
2035                )
2036            })
2037            .collect::<Vec<_>>();
2038        assert_eq!(vars.len(), 14);
2039        assert!(vars.contains(&(SERVER_LISTEN_ADDR_ENV, "server.listen_addr", None, false)));
2040        assert!(vars.contains(&(SERVER_LISTEN_PORT_ENV, "server.listen_port", None, false)));
2041        assert!(vars.contains(&(SERVER_PUBLIC_URL_ENV, "server.public_url", None, false)));
2042        assert!(vars.contains(&(
2043            SERVER_TLS_CERT_PATH_ENV,
2044            "server.tls_cert_path",
2045            None,
2046            false
2047        )));
2048        assert!(vars.contains(&(SERVER_TLS_KEY_PATH_ENV, "server.tls_key_path", None, false)));
2049        assert!(vars.contains(&(
2050            SERVER_ADMIN_ENABLED_ENV,
2051            "server.admin_enabled",
2052            None,
2053            false
2054        )));
2055        assert!(vars.contains(&(
2056            SERVER_OAUTH_ISSUER_ENV,
2057            "server.auth.oauth.issuer",
2058            Some("oauth"),
2059            false
2060        )));
2061        assert!(vars.contains(&(
2062            SERVER_OAUTH_AUDIENCE_ENV,
2063            "server.auth.oauth.audience",
2064            Some("oauth"),
2065            false
2066        )));
2067        assert!(vars.contains(&(
2068            SERVER_OAUTH_JWKS_URI_ENV,
2069            "server.auth.oauth.jwks_uri",
2070            Some("oauth"),
2071            false
2072        )));
2073        assert!(vars.contains(&(
2074            OBSERVABILITY_LOG_FORMAT_ENV,
2075            "observability.log_format",
2076            None,
2077            false
2078        )));
2079        assert!(vars.contains(&(
2080            OBSERVABILITY_METRICS_ENABLED_ENV,
2081            "observability.metrics_enabled",
2082            None,
2083            false
2084        )));
2085        assert!(vars.contains(&(
2086            OBSERVABILITY_METRICS_BIND_ENV,
2087            "observability.metrics_bind",
2088            None,
2089            false
2090        )));
2091        assert!(vars.contains(&(RBAC_REDACTION_SALT_ENV, "rbac.redaction_salt", None, true)));
2092        assert!(vars.contains(&(
2093            RBAC_REDACTION_SALT_FILE_ENV,
2094            "rbac.redaction_salt",
2095            None,
2096            true
2097        )));
2098        assert_eq!(
2099            ENV_OVERRIDE_SPECS
2100                .iter()
2101                .filter(|spec| spec.value_type == "Path")
2102                .count(),
2103            3
2104        );
2105    }
2106
2107    #[derive(Debug)]
2108    struct GuideEnvRow {
2109        env_var: String,
2110        target_field: String,
2111        value_type: String,
2112        notes: String,
2113    }
2114
2115    #[derive(Debug)]
2116    struct GuideEnvAnnotation {
2117        env_var: String,
2118        key: String,
2119    }
2120
2121    // `_FILE` is documented next to its sibling because both target the same
2122    // TOML key (`rbac.redaction_salt`); duplicating the inline annotation on
2123    // the key would be ambiguous rather than helpful.
2124    const INLINE_ENV_ANNOTATION_EXEMPTIONS: &[&str] = &[RBAC_REDACTION_SALT_FILE_ENV];
2125
2126    // Guards the public operator table against drifting from the code-side
2127    // env spec, and guards the reverse direction by parsing `*_ENV` consts
2128    // from source text. Source parsing is deliberate: it catches a newly added
2129    // env variable constant even if no Rust code references the spec table yet.
2130    #[test]
2131    fn guide_env_override_table_matches_code_spec() {
2132        let rows = parse_guide_env_override_table();
2133        assert_eq!(
2134            rows.len(),
2135            ENV_OVERRIDE_SPECS.len(),
2136            "GUIDE env override table row count {} must match ENV_OVERRIDE_SPECS row count {}",
2137            rows.len(),
2138            ENV_OVERRIDE_SPECS.len()
2139        );
2140
2141        for (idx, (row, spec)) in rows.iter().zip(ENV_OVERRIDE_SPECS.iter()).enumerate() {
2142            assert_eq!(
2143                row.env_var, spec.env_var,
2144                "row {idx} env var mismatch: GUIDE has {:?}, code has {:?}",
2145                row.env_var, spec.env_var
2146            );
2147            assert_eq!(
2148                row.target_field, spec.target_field,
2149                "{} target mismatch: GUIDE has {:?}, code has {:?}",
2150                spec.env_var, row.target_field, spec.target_field
2151            );
2152            assert_eq!(
2153                row.value_type, spec.value_type,
2154                "{} type mismatch: GUIDE has {:?}, code has {:?}",
2155                spec.env_var, row.value_type, spec.value_type
2156            );
2157
2158            let notes_lower = row.notes.to_ascii_lowercase();
2159            if let Some(feature) = spec.required_feature {
2160                assert!(
2161                    notes_lower.contains(feature),
2162                    "{} notes must mention required feature {:?}; notes were {:?}",
2163                    spec.env_var,
2164                    feature,
2165                    row.notes
2166                );
2167            } else {
2168                assert!(
2169                    !notes_lower.contains("requires") && !notes_lower.contains("feature"),
2170                    "{} notes must not mention a required feature; notes were {:?}",
2171                    spec.env_var,
2172                    row.notes
2173                );
2174            }
2175
2176            if spec.redacted {
2177                assert!(
2178                    notes_lower.contains("secret") && notes_lower.contains("redacted"),
2179                    "{} notes must indicate secret/redacted handling; notes were {:?}",
2180                    spec.env_var,
2181                    row.notes
2182                );
2183            } else {
2184                assert!(
2185                    !notes_lower.contains("secret") && !notes_lower.contains("redacted"),
2186                    "{} notes must not indicate secret/redacted handling; notes were {:?}",
2187                    spec.env_var,
2188                    row.notes
2189                );
2190            }
2191        }
2192
2193        let spec_vars = ENV_OVERRIDE_SPECS
2194            .iter()
2195            .map(|spec| spec.env_var)
2196            .collect::<HashSet<_>>();
2197        for env_var in parse_rmcp_env_constants_from_config_source() {
2198            assert!(
2199                spec_vars.contains(env_var.as_str()),
2200                "env const {env_var} is defined in src/config.rs but missing from ENV_OVERRIDE_SPECS"
2201            );
2202        }
2203    }
2204
2205    // Sibling guard for the canonical TOML example's inline `# env:` comments.
2206    // It is kept separate from the table test so failures name which public
2207    // copy drifted. Extraction is scoped to the canonical TOML example by the
2208    // surrounding headings: scanning the whole guide would let unrelated future
2209    // snippets accidentally satisfy this count/order contract.
2210    #[test]
2211    fn guide_toml_example_env_annotations_match_code_spec() {
2212        let annotations = parse_guide_toml_env_annotations();
2213        assert!(
2214            !annotations.is_empty(),
2215            "canonical TOML example contains no `# env:` annotations"
2216        );
2217
2218        let spec_by_var = ENV_OVERRIDE_SPECS
2219            .iter()
2220            .map(|spec| (spec.env_var, spec))
2221            .collect::<std::collections::HashMap<_, _>>();
2222        let mut seen = HashSet::new();
2223
2224        for annotation in &annotations {
2225            let Some(spec) = spec_by_var.get(annotation.env_var.as_str()) else {
2226                panic!(
2227                    "GUIDE inline env annotation {:?} is not present in ENV_OVERRIDE_SPECS",
2228                    annotation.env_var
2229                );
2230            };
2231            assert!(
2232                seen.insert(annotation.env_var.as_str()),
2233                "GUIDE inline env annotation {:?} appears more than once",
2234                annotation.env_var
2235            );
2236            let expected_key = spec
2237                .target_field
2238                .rsplit('.')
2239                .next()
2240                .expect("target_field has at least one segment");
2241            assert_eq!(
2242                annotation.key, expected_key,
2243                "{} inline annotation is attached to TOML key {:?}, but code spec target {:?} ends in {:?}",
2244                annotation.env_var, annotation.key, spec.target_field, expected_key
2245            );
2246        }
2247
2248        let expected_count = ENV_OVERRIDE_SPECS.len() - INLINE_ENV_ANNOTATION_EXEMPTIONS.len();
2249        assert_eq!(
2250            annotations.len(),
2251            expected_count,
2252            "GUIDE inline env annotation count {} must equal ENV_OVERRIDE_SPECS count {} minus exemptions {:?}",
2253            annotations.len(),
2254            ENV_OVERRIDE_SPECS.len(),
2255            INLINE_ENV_ANNOTATION_EXEMPTIONS
2256        );
2257
2258        for spec in ENV_OVERRIDE_SPECS {
2259            if INLINE_ENV_ANNOTATION_EXEMPTIONS.contains(&spec.env_var) {
2260                assert!(
2261                    !seen.contains(spec.env_var),
2262                    "{} is deliberately exempt from inline annotation but was annotated",
2263                    spec.env_var
2264                );
2265            } else {
2266                assert!(
2267                    seen.contains(spec.env_var),
2268                    "{} is missing from GUIDE canonical TOML inline `# env:` annotations",
2269                    spec.env_var
2270                );
2271            }
2272        }
2273    }
2274
2275    fn guide_markdown() -> &'static str {
2276        include_str!("../docs/GUIDE.md")
2277    }
2278
2279    fn parse_guide_env_override_table() -> Vec<GuideEnvRow> {
2280        let guide = guide_markdown();
2281        let (_, after_begin) = guide
2282            .split_once("<!-- BEGIN ENV_OVERRIDE_TABLE -->")
2283            .expect("docs/GUIDE.md is missing <!-- BEGIN ENV_OVERRIDE_TABLE --> marker");
2284        let (table, _) = after_begin
2285            .split_once("<!-- END ENV_OVERRIDE_TABLE -->")
2286            .expect("docs/GUIDE.md is missing <!-- END ENV_OVERRIDE_TABLE --> marker");
2287        let rows = table
2288            .lines()
2289            .filter_map(parse_guide_env_override_row)
2290            .collect::<Vec<_>>();
2291        assert!(
2292            !rows.is_empty(),
2293            "docs/GUIDE.md ENV_OVERRIDE_TABLE markers were found but no data rows parsed"
2294        );
2295        rows
2296    }
2297
2298    fn parse_guide_env_override_row(line: &str) -> Option<GuideEnvRow> {
2299        let trimmed = line.trim();
2300        if !trimmed.starts_with('|')
2301            || trimmed.contains("|---")
2302            || trimmed.contains("Environment variable")
2303        {
2304            return None;
2305        }
2306        let cells = trimmed
2307            .trim_matches('|')
2308            .split('|')
2309            .map(str::trim)
2310            .collect::<Vec<_>>();
2311        assert_eq!(
2312            cells.len(),
2313            4,
2314            "env override GUIDE table row must have four cells, got {} in line {:?}",
2315            cells.len(),
2316            line
2317        );
2318        Some(GuideEnvRow {
2319            env_var: unwrap_markdown_code(cells[0], "Environment variable", line),
2320            target_field: unwrap_markdown_code(cells[1], "Target TOML path", line),
2321            value_type: cells[2].trim().to_owned(),
2322            notes: cells[3].trim().to_owned(),
2323        })
2324    }
2325
2326    fn unwrap_markdown_code(cell: &str, column: &str, row: &str) -> String {
2327        let inner = cell
2328            .strip_prefix('`')
2329            .and_then(|value| value.strip_suffix('`'))
2330            .unwrap_or_else(|| panic!("{column} cell must be backtick-wrapped in row {row:?}"));
2331        inner.trim().to_owned()
2332    }
2333
2334    fn parse_guide_toml_env_annotations() -> Vec<GuideEnvAnnotation> {
2335        let guide = guide_markdown();
2336        let (_, after_heading) = guide
2337            .split_once("### Complete TOML configuration reference")
2338            .expect("docs/GUIDE.md is missing canonical TOML configuration heading");
2339        let (section, _) = after_heading
2340            .split_once("### Bridging TOML config to `McpServerConfig`")
2341            .expect("docs/GUIDE.md is missing bridge heading after canonical TOML example");
2342        let (_, after_fence_start) = section
2343            .split_once("```toml")
2344            .expect("canonical TOML section is missing opening ```toml fence");
2345        let (toml_block, _) = after_fence_start
2346            .split_once("```")
2347            .expect("canonical TOML section is missing closing code fence");
2348
2349        toml_block
2350            .lines()
2351            .filter_map(parse_guide_toml_env_annotation_line)
2352            .collect()
2353    }
2354
2355    fn parse_guide_toml_env_annotation_line(line: &str) -> Option<GuideEnvAnnotation> {
2356        let (before_marker, after_marker) = line.split_once("# env: ")?;
2357        let env_var = after_marker
2358            .split_whitespace()
2359            .next()
2360            .unwrap_or_else(|| panic!("missing env var after `# env:` in line {line:?}"));
2361        let key_source = before_marker
2362            .trim_end()
2363            .strip_prefix('#')
2364            .map_or_else(|| before_marker.trim_end(), str::trim);
2365        let key = key_source
2366            .split_once('=')
2367            .unwrap_or_else(|| panic!("missing TOML key before `# env:` in line {line:?}"))
2368            .0
2369            .trim();
2370
2371        Some(GuideEnvAnnotation {
2372            env_var: env_var.to_owned(),
2373            key: key.to_owned(),
2374        })
2375    }
2376
2377    fn parse_rmcp_env_constants_from_config_source() -> Vec<String> {
2378        include_str!("config.rs")
2379            .lines()
2380            .filter(|line| {
2381                let trimmed = line.trim_start();
2382                trimmed.starts_with("pub(crate) const ")
2383                    && trimmed
2384                        .strip_prefix("pub(crate) const ")
2385                        .and_then(|rest| rest.split_once(':'))
2386                        .is_some_and(|(name, _)| name.ends_with("_ENV"))
2387                    && trimmed.contains("RMCP_SERVER_KIT__")
2388            })
2389            .filter_map(|line| {
2390                line.split_once('"')
2391                    .and_then(|(_, rest)| rest.split_once('"'))
2392                    .map(|(value, _)| value.to_owned())
2393            })
2394            .collect()
2395    }
2396}