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