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