Skip to main content

rmcp_server_kit/
config.rs

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