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#[derive(Debug, Clone, PartialEq, Eq)]
78#[non_exhaustive]
79pub struct EnvOverride {
80 pub env_var: String,
82 pub target_field: String,
84 pub source: EnvOverrideSource,
86 pub value: Option<String>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92#[non_exhaustive]
93pub enum EnvOverrideSource {
94 Env,
96 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#[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 #[serde(default = "default_listen_addr")]
322 pub listen_addr: String,
323 #[serde(default = "default_listen_port")]
325 pub listen_port: u16,
326 pub tls_cert_path: Option<PathBuf>,
328 pub tls_key_path: Option<PathBuf>,
330 #[serde(default = "default_tls_handshake_timeout")]
335 pub tls_handshake_timeout: String,
336 #[serde(default = "default_max_concurrent_tls_handshakes")]
341 pub max_concurrent_tls_handshakes: usize,
342 #[serde(default = "default_shutdown_timeout")]
344 pub shutdown_timeout: String,
345 #[serde(default = "default_request_timeout")]
347 pub request_timeout: String,
348 #[serde(default = "default_max_request_body")]
350 pub max_request_body: usize,
351 #[serde(default)]
355 pub allowed_origins: Vec<String>,
356 #[serde(default)]
359 pub stdio_enabled: bool,
360 pub tool_rate_limit: Option<u32>,
364 pub tool_rate_limit_burst: Option<u32>,
368 pub extra_route_rate_limit: Option<u32>,
374 pub extra_route_rate_limit_burst: Option<u32>,
378 #[serde(default)]
384 pub extra_route_rate_limit_exempt_paths: Vec<String>,
385 #[serde(default)]
387 pub key_eviction_policy: KeyEvictionPolicy,
388 #[serde(default)]
394 pub trusted_proxies: Vec<String>,
395 #[serde(default = "default_trusted_forwarder_max_entries")]
400 pub trusted_forwarder_max_entries: usize,
401 pub forwarded_header: Option<crate::transport::ForwardedHeaderMode>,
405 #[serde(default = "default_session_idle_timeout")]
408 pub session_idle_timeout: String,
409 #[serde(default = "default_session_binding")]
412 pub session_binding: bool,
413 pub session_binding_secret: Option<SecretString>,
417 #[serde(default)]
425 pub task_binding: bool,
426 #[serde(default = "default_sse_keep_alive")]
430 pub sse_keep_alive: String,
431 pub public_url: Option<String>,
436 #[serde(default)]
438 pub compression_enabled: bool,
439 #[serde(default = "default_compression_min_size")]
442 pub compression_min_size: u16,
443 pub max_concurrent_requests: Option<usize>,
446 #[serde(default)]
448 pub admin_enabled: bool,
449 #[serde(default = "default_admin_role")]
451 pub admin_role: String,
452 pub auth: Option<crate::auth::AuthConfig>,
454 #[serde(default = "default_tool_list_filtering")]
457 pub tool_list_filtering: bool,
458 #[serde(default = "default_expose_build_metadata")]
460 pub expose_build_metadata: bool,
461 #[serde(default = "default_security_headers")]
463 pub security_headers: SecurityHeadersConfig,
464}
465
466impl std::fmt::Debug for ServerConfig {
477 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
478 f.debug_struct("ServerConfig")
479 .field("listen_addr", &self.listen_addr)
480 .field("listen_port", &self.listen_port)
481 .field("tls_cert_path", &self.tls_cert_path)
482 .field(
483 "tls_key_path",
484 &self.tls_key_path.as_ref().map(|_| "[REDACTED]"),
485 )
486 .field("tls_handshake_timeout", &self.tls_handshake_timeout)
487 .field(
488 "max_concurrent_tls_handshakes",
489 &self.max_concurrent_tls_handshakes,
490 )
491 .field("shutdown_timeout", &self.shutdown_timeout)
492 .field("request_timeout", &self.request_timeout)
493 .field("max_request_body", &self.max_request_body)
494 .field("allowed_origins", &self.allowed_origins)
495 .field("stdio_enabled", &self.stdio_enabled)
496 .field("tool_rate_limit", &self.tool_rate_limit)
497 .field("tool_rate_limit_burst", &self.tool_rate_limit_burst)
498 .field("extra_route_rate_limit", &self.extra_route_rate_limit)
499 .field(
500 "extra_route_rate_limit_burst",
501 &self.extra_route_rate_limit_burst,
502 )
503 .field(
504 "extra_route_rate_limit_exempt_paths",
505 &self.extra_route_rate_limit_exempt_paths,
506 )
507 .field("key_eviction_policy", &self.key_eviction_policy)
508 .field("trusted_proxies", &self.trusted_proxies)
509 .field(
510 "trusted_forwarder_max_entries",
511 &self.trusted_forwarder_max_entries,
512 )
513 .field("forwarded_header", &self.forwarded_header)
514 .field("session_idle_timeout", &self.session_idle_timeout)
515 .field("session_binding", &self.session_binding)
516 .field(
517 "session_binding_secret",
518 &self.session_binding_secret.as_ref().map(|_| "[REDACTED]"),
519 )
520 .field("task_binding", &self.task_binding)
521 .field("sse_keep_alive", &self.sse_keep_alive)
522 .field("public_url", &self.public_url)
523 .field("compression_enabled", &self.compression_enabled)
524 .field("compression_min_size", &self.compression_min_size)
525 .field("max_concurrent_requests", &self.max_concurrent_requests)
526 .field("admin_enabled", &self.admin_enabled)
527 .field("admin_role", &self.admin_role)
528 .field("auth", &self.auth)
529 .field("tool_list_filtering", &self.tool_list_filtering)
530 .field("expose_build_metadata", &self.expose_build_metadata)
531 .field("security_headers", &self.security_headers)
532 .finish()
533 }
534}
535
536impl Default for ServerConfig {
537 fn default() -> Self {
538 Self {
539 listen_addr: default_listen_addr(),
540 listen_port: default_listen_port(),
541 tls_cert_path: None,
542 tls_key_path: None,
543 tls_handshake_timeout: default_tls_handshake_timeout(),
544 max_concurrent_tls_handshakes: default_max_concurrent_tls_handshakes(),
545 shutdown_timeout: default_shutdown_timeout(),
546 request_timeout: default_request_timeout(),
547 max_request_body: default_max_request_body(),
548 allowed_origins: Vec::new(),
549 stdio_enabled: false,
550 tool_rate_limit: None,
551 tool_rate_limit_burst: None,
552 extra_route_rate_limit: None,
553 extra_route_rate_limit_burst: None,
554 extra_route_rate_limit_exempt_paths: Vec::new(),
555 key_eviction_policy: KeyEvictionPolicy::default(),
556 trusted_proxies: Vec::new(),
557 trusted_forwarder_max_entries: default_trusted_forwarder_max_entries(),
558 forwarded_header: None,
559 session_idle_timeout: default_session_idle_timeout(),
560 session_binding: default_session_binding(),
561 session_binding_secret: None,
562 task_binding: false,
563 sse_keep_alive: default_sse_keep_alive(),
564 public_url: None,
565 compression_enabled: false,
566 compression_min_size: default_compression_min_size(),
567 max_concurrent_requests: None,
568 admin_enabled: false,
569 admin_role: default_admin_role(),
570 auth: None,
571 tool_list_filtering: default_tool_list_filtering(),
572 expose_build_metadata: default_expose_build_metadata(),
573 security_headers: default_security_headers(),
574 }
575 }
576}
577
578impl ServerConfig {
579 pub fn apply_env_overrides(&mut self) -> Result<Vec<EnvOverride>, RmcpServerKitError> {
611 let mut applied = Vec::new();
612 apply_string_env(
613 SERVER_LISTEN_ADDR_ENV,
614 "server.listen_addr",
615 &mut self.listen_addr,
616 &mut applied,
617 )?;
618 if let Some(raw) = read_env(SERVER_LISTEN_PORT_ENV)? {
619 self.listen_port = parse_env_value(SERVER_LISTEN_PORT_ENV, &raw, "u16")?;
620 applied.push(env_report(
621 SERVER_LISTEN_PORT_ENV,
622 "server.listen_port",
623 raw,
624 ));
625 }
626 apply_optional_string_env(
627 SERVER_PUBLIC_URL_ENV,
628 "server.public_url",
629 &mut self.public_url,
630 &mut applied,
631 )?;
632 apply_optional_path_env(
633 SERVER_TLS_CERT_PATH_ENV,
634 "server.tls_cert_path",
635 &mut self.tls_cert_path,
636 &mut applied,
637 )?;
638 apply_optional_path_env(
639 SERVER_TLS_KEY_PATH_ENV,
640 "server.tls_key_path",
641 &mut self.tls_key_path,
642 &mut applied,
643 )?;
644 if let Some(raw) = read_env(SERVER_ADMIN_ENABLED_ENV)? {
645 self.admin_enabled = parse_env_bool(SERVER_ADMIN_ENABLED_ENV, &raw)?;
646 applied.push(env_report(
647 SERVER_ADMIN_ENABLED_ENV,
648 "server.admin_enabled",
649 raw,
650 ));
651 }
652 if let Some(raw) = read_env(SERVER_KEY_EVICTION_POLICY_ENV)? {
653 self.key_eviction_policy =
654 parse_env_value(SERVER_KEY_EVICTION_POLICY_ENV, &raw, "KeyEvictionPolicy")?;
655 applied.push(env_report(
656 SERVER_KEY_EVICTION_POLICY_ENV,
657 "server.key_eviction_policy",
658 raw,
659 ));
660 }
661 self.apply_session_binding_secret_env(&mut applied)?;
662 let oauth_env = OAuthEnvOverrides::read()?;
663 #[cfg(feature = "oauth")]
664 self.apply_oauth_env_overrides(oauth_env, &mut applied)?;
665 #[cfg(not(feature = "oauth"))]
666 reject_oauth_env_overrides(&oauth_env)?;
667 Ok(applied)
668 }
669
670 fn apply_session_binding_secret_env(
671 &mut self,
672 applied: &mut Vec<EnvOverride>,
673 ) -> Result<(), RmcpServerKitError> {
674 let direct = read_env(SERVER_SESSION_BINDING_SECRET_ENV)?;
675 let file = read_env(SERVER_SESSION_BINDING_SECRET_FILE_ENV)?;
676 match (direct, file) {
677 (None, None) => Ok(()),
678 (Some(_), Some(_)) => Err(RmcpServerKitError::Config(format!(
679 "{SERVER_SESSION_BINDING_SECRET_ENV} and {SERVER_SESSION_BINDING_SECRET_FILE_ENV} must not both be set"
680 ))),
681 (Some(value), None) => {
682 validate_session_binding_secret_env(SERVER_SESSION_BINDING_SECRET_ENV, &value)?;
683 self.session_binding_secret = Some(SecretString::from(value));
684 applied.push(secret_env_report(
685 SERVER_SESSION_BINDING_SECRET_ENV,
686 "server.session_binding_secret",
687 EnvOverrideSource::Env,
688 ));
689 Ok(())
690 }
691 (None, Some(path)) => {
692 let secret = std::fs::read_to_string(PathBuf::from(&path)).map_err(|error| {
693 RmcpServerKitError::Config(format!(
694 "failed to read {SERVER_SESSION_BINDING_SECRET_FILE_ENV} file {path:?}: {error}"
695 ))
696 })?;
697 let secret = normalize_text_secret_file(secret);
698 validate_session_binding_secret_env(
699 SERVER_SESSION_BINDING_SECRET_FILE_ENV,
700 &secret,
701 )?;
702 self.session_binding_secret = Some(SecretString::from(secret));
703 applied.push(secret_env_report(
704 SERVER_SESSION_BINDING_SECRET_FILE_ENV,
705 "server.session_binding_secret",
706 EnvOverrideSource::File,
707 ));
708 Ok(())
709 }
710 }
711 }
712
713 #[cfg(feature = "oauth")]
714 fn apply_oauth_env_overrides(
715 &mut self,
716 oauth_env: OAuthEnvOverrides,
717 applied: &mut Vec<EnvOverride>,
718 ) -> Result<(), RmcpServerKitError> {
719 if !oauth_env.is_set() {
720 return Ok(());
721 }
722
723 let Some(auth) = self.auth.as_mut() else {
724 let var = oauth_env.first_set_var();
725 return Err(RmcpServerKitError::Config(format!(
726 "{var} requires declaring [server.auth.oauth] before applying env overrides"
727 )));
728 };
729 let Some(oauth) = auth.oauth.as_mut() else {
730 let var = oauth_env.first_set_var();
731 return Err(RmcpServerKitError::Config(format!(
732 "{var} requires declaring [server.auth.oauth] before applying env overrides"
733 )));
734 };
735 if let Some(raw) = oauth_env.issuer {
736 applied.push(env_report(
737 SERVER_OAUTH_ISSUER_ENV,
738 "server.auth.oauth.issuer",
739 raw.clone(),
740 ));
741 oauth.issuer = raw;
742 }
743 if let Some(raw) = oauth_env.audience {
744 applied.push(env_report(
745 SERVER_OAUTH_AUDIENCE_ENV,
746 "server.auth.oauth.audience",
747 raw.clone(),
748 ));
749 oauth.audience = raw;
750 }
751 if let Some(raw) = oauth_env.jwks_uri {
752 applied.push(env_report(
753 SERVER_OAUTH_JWKS_URI_ENV,
754 "server.auth.oauth.jwks_uri",
755 raw.clone(),
756 ));
757 oauth.jwks_uri = raw;
758 }
759 if let Some(raw) = oauth_env.allowed_algorithms {
760 let names: Vec<String> = raw
763 .split(',')
764 .map(str::trim)
765 .filter(|part| !part.is_empty())
766 .map(ToOwned::to_owned)
767 .collect();
768 crate::oauth::resolve_allowed_algorithms(Some(names.as_slice())).map_err(|err| {
772 RmcpServerKitError::Config(format!("{SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV}: {err}"))
773 })?;
774 applied.push(env_report(
775 SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV,
776 "server.auth.oauth.allowed_algorithms",
777 raw,
778 ));
779 oauth.allowed_algorithms = Some(names);
780 }
781 if let Some(raw) = oauth_env.proxy_strip_resource_param {
782 let value = parse_env_bool(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, &raw)?;
783 let Some(proxy) = oauth.proxy.as_mut() else {
787 return Err(RmcpServerKitError::Config(format!(
788 "{SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV} requires declaring \
789 [server.auth.oauth.proxy] before applying env overrides"
790 )));
791 };
792 applied.push(env_report(
793 SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV,
794 "server.auth.oauth.proxy.strip_resource_param",
795 raw,
796 ));
797 proxy.strip_resource_param = value;
798 }
799 Ok(())
800 }
801
802 pub fn apply_to_mcp_config(
840 &self,
841 base: McpServerConfig,
842 ) -> Result<McpServerConfig, RmcpServerKitError> {
843 let config = base
844 .with_bind_addr(format!("{}:{}", self.listen_addr, self.listen_port))
845 .with_tls_paths(self.tls_cert_path.clone(), self.tls_key_path.clone())
846 .with_optional_auth(self.auth.clone())
847 .with_max_request_body(self.max_request_body)
848 .with_request_timeout(parse_duration_field(
849 "server.request_timeout",
850 &self.request_timeout,
851 )?)
852 .with_shutdown_timeout(parse_duration_field(
853 "server.shutdown_timeout",
854 &self.shutdown_timeout,
855 )?)
856 .with_session_idle_timeout(parse_duration_field(
857 "server.session_idle_timeout",
858 &self.session_idle_timeout,
859 )?)
860 .with_session_binding(self.session_binding)
861 .with_task_binding(self.task_binding)
862 .with_optional_session_binding_secret(self.session_binding_secret.clone())
863 .with_sse_keep_alive(parse_duration_field(
864 "server.sse_keep_alive",
865 &self.sse_keep_alive,
866 )?)
867 .with_tls_handshake_timeout(parse_duration_field(
868 "server.tls_handshake_timeout",
869 &self.tls_handshake_timeout,
870 )?)
871 .with_max_concurrent_tls_handshakes(self.max_concurrent_tls_handshakes)
872 .with_allowed_origins(self.allowed_origins.iter().map(String::as_str))
873 .with_extra_route_rate_limit_exempt_paths(
874 self.extra_route_rate_limit_exempt_paths
875 .iter()
876 .map(String::as_str),
877 )
878 .with_trusted_proxies(self.trusted_proxies.iter().map(String::as_str))
879 .with_trusted_forwarder_max_entries(self.trusted_forwarder_max_entries)
880 .with_optional_tool_rate_limit(self.tool_rate_limit)
881 .with_optional_tool_rate_limit_burst(self.tool_rate_limit_burst)
882 .with_optional_extra_route_rate_limit(self.extra_route_rate_limit)
883 .with_optional_extra_route_rate_limit_burst(self.extra_route_rate_limit_burst)
884 .with_key_eviction_policy(self.key_eviction_policy)
885 .with_optional_forwarded_header(self.forwarded_header)
886 .with_optional_public_url(self.public_url.clone())
887 .with_compression_enabled(self.compression_enabled)
888 .with_compression_min_size(self.compression_min_size)
889 .with_optional_max_concurrent_requests(self.max_concurrent_requests)
890 .with_admin_enabled(self.admin_enabled)
891 .with_admin_role(&self.admin_role)
892 .with_tool_list_filtering(self.tool_list_filtering)
893 .with_expose_build_metadata(self.expose_build_metadata)
894 .with_security_headers(self.security_headers.clone());
895
896 Ok(config)
897 }
898}
899
900impl ObservabilityConfig {
901 pub fn apply_env_overrides(&mut self) -> Result<Vec<EnvOverride>, RmcpServerKitError> {
936 let mut applied = Vec::new();
937 apply_string_env(
938 OBSERVABILITY_LOG_FORMAT_ENV,
939 "observability.log_format",
940 &mut self.log_format,
941 &mut applied,
942 )?;
943 if let Some(raw) = read_env(OBSERVABILITY_METRICS_ENABLED_ENV)? {
944 self.metrics_enabled = parse_env_bool(OBSERVABILITY_METRICS_ENABLED_ENV, &raw)?;
945 applied.push(env_report(
946 OBSERVABILITY_METRICS_ENABLED_ENV,
947 "observability.metrics_enabled",
948 raw,
949 ));
950 }
951 if let Some(raw) = read_env(OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV)? {
952 self.log_plaintext_oauth_tokens =
953 parse_env_bool(OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV, &raw)?;
954 applied.push(env_report(
955 OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV,
956 "observability.log_plaintext_oauth_tokens",
957 raw,
958 ));
959 }
960 if let Some(raw) = read_env(OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV)? {
961 self.log_oauth_claim_values =
962 parse_env_bool(OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV, &raw)?;
963 applied.push(env_report(
964 OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV,
965 "observability.log_oauth_claim_values",
966 raw,
967 ));
968 }
969 if let Some(raw) = read_env(OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV)? {
970 self.log_tool_call_arguments =
971 parse_env_bool(OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV, &raw)?;
972 applied.push(env_report(
973 OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV,
974 "observability.log_tool_call_arguments",
975 raw,
976 ));
977 }
978 if let Some(raw) = read_env(OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV)? {
979 self.log_upstream_error_bodies =
980 parse_env_bool(OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV, &raw)?;
981 applied.push(env_report(
982 OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV,
983 "observability.log_upstream_error_bodies",
984 raw,
985 ));
986 }
987 apply_string_env(
988 OBSERVABILITY_METRICS_BIND_ENV,
989 "observability.metrics_bind",
990 &mut self.metrics_bind,
991 &mut applied,
992 )?;
993 Ok(applied)
994 }
995}
996
997pub(crate) fn read_env(var: &str) -> Result<Option<String>, RmcpServerKitError> {
998 match std::env::var(var) {
999 Ok(value) => Ok(Some(value)),
1000 Err(std::env::VarError::NotPresent) => Ok(None),
1001 Err(std::env::VarError::NotUnicode(_)) => Err(RmcpServerKitError::Config(format!(
1002 "{var} must contain valid UTF-8"
1003 ))),
1004 }
1005}
1006
1007fn env_report(env_var: &str, target_field: &str, value: String) -> EnvOverride {
1008 EnvOverride {
1009 env_var: env_var.to_owned(),
1010 target_field: target_field.to_owned(),
1011 source: EnvOverrideSource::Env,
1012 value: Some(value),
1013 }
1014}
1015
1016pub(crate) fn secret_env_report(
1017 env_var: &str,
1018 target_field: &str,
1019 source: EnvOverrideSource,
1020) -> EnvOverride {
1021 EnvOverride {
1022 env_var: env_var.to_owned(),
1023 target_field: target_field.to_owned(),
1024 source,
1025 value: None,
1026 }
1027}
1028
1029fn parse_env_value<T>(env_var: &str, raw: &str, expected: &str) -> Result<T, RmcpServerKitError>
1030where
1031 T: std::str::FromStr,
1032{
1033 raw.parse::<T>().map_err(|_| {
1034 RmcpServerKitError::Config(format!("invalid value for {env_var}: expected {expected}"))
1035 })
1036}
1037
1038pub(crate) fn parse_env_bool(env_var: &str, raw: &str) -> Result<bool, RmcpServerKitError> {
1039 parse_env_value(env_var, raw, "bool")
1040}
1041
1042fn apply_string_env(
1043 env_var: &str,
1044 target_field: &str,
1045 target: &mut String,
1046 applied: &mut Vec<EnvOverride>,
1047) -> Result<(), RmcpServerKitError> {
1048 if let Some(raw) = read_env(env_var)? {
1049 applied.push(env_report(env_var, target_field, raw.clone()));
1050 *target = raw;
1051 }
1052 Ok(())
1053}
1054
1055fn apply_optional_string_env(
1056 env_var: &str,
1057 target_field: &str,
1058 target: &mut Option<String>,
1059 applied: &mut Vec<EnvOverride>,
1060) -> Result<(), RmcpServerKitError> {
1061 if let Some(raw) = read_env(env_var)? {
1062 *target = Some(raw.clone());
1063 applied.push(env_report(env_var, target_field, raw));
1064 }
1065 Ok(())
1066}
1067
1068fn apply_optional_path_env(
1069 env_var: &str,
1070 target_field: &str,
1071 target: &mut Option<PathBuf>,
1072 applied: &mut Vec<EnvOverride>,
1073) -> Result<(), RmcpServerKitError> {
1074 if let Some(raw) = read_env(env_var)? {
1075 *target = Some(PathBuf::from(&raw));
1076 applied.push(env_report(env_var, target_field, raw));
1077 }
1078 Ok(())
1079}
1080
1081pub(crate) fn normalize_text_secret_file(mut secret: String) -> String {
1082 if secret.ends_with("\r\n") {
1083 secret.truncate(secret.len() - 2);
1084 } else if secret.ends_with('\n') || secret.ends_with('\r') {
1085 secret.truncate(secret.len() - 1);
1086 }
1087 secret
1088}
1089
1090fn validate_session_binding_secret_env(
1091 env_var: &str,
1092 value: &str,
1093) -> Result<(), RmcpServerKitError> {
1094 crate::session_binding::validate_configured_secret(env_var, value)
1095}
1096
1097struct OAuthEnvOverrides {
1098 issuer: Option<String>,
1099 audience: Option<String>,
1100 jwks_uri: Option<String>,
1101 allowed_algorithms: Option<String>,
1102 proxy_strip_resource_param: Option<String>,
1103}
1104
1105impl OAuthEnvOverrides {
1106 fn read() -> Result<Self, RmcpServerKitError> {
1107 Ok(Self {
1108 issuer: read_env(SERVER_OAUTH_ISSUER_ENV)?,
1109 audience: read_env(SERVER_OAUTH_AUDIENCE_ENV)?,
1110 jwks_uri: read_env(SERVER_OAUTH_JWKS_URI_ENV)?,
1111 allowed_algorithms: read_env(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV)?,
1112 proxy_strip_resource_param: read_env(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV)?,
1113 })
1114 }
1115
1116 fn is_set(&self) -> bool {
1117 self.issuer.is_some()
1118 || self.audience.is_some()
1119 || self.jwks_uri.is_some()
1120 || self.allowed_algorithms.is_some()
1121 || self.proxy_strip_resource_param.is_some()
1122 }
1123
1124 fn first_set_var(&self) -> &'static str {
1125 first_set_oauth_env(
1126 self.issuer.as_deref(),
1127 self.audience.as_deref(),
1128 self.jwks_uri.as_deref(),
1129 self.allowed_algorithms.as_deref(),
1130 self.proxy_strip_resource_param.as_deref(),
1131 )
1132 }
1133}
1134
1135const _OBSERVABILITY_CONFIG_DOC_ANCHOR: &str = "ObservabilityConfig";
1136
1137#[cfg(not(feature = "oauth"))]
1138fn reject_oauth_env_overrides(oauth_env: &OAuthEnvOverrides) -> Result<(), RmcpServerKitError> {
1139 if oauth_env.is_set() {
1140 let var = oauth_env.first_set_var();
1141 Err(RmcpServerKitError::Config(format!(
1142 "{var} requires the `oauth` feature"
1143 )))
1144 } else {
1145 Ok(())
1146 }
1147}
1148
1149fn first_set_oauth_env(
1150 issuer: Option<&str>,
1151 audience: Option<&str>,
1152 jwks_uri: Option<&str>,
1153 allowed_algorithms: Option<&str>,
1154 proxy_strip_resource_param: Option<&str>,
1155) -> &'static str {
1156 if issuer.is_some() {
1157 SERVER_OAUTH_ISSUER_ENV
1158 } else if audience.is_some() {
1159 SERVER_OAUTH_AUDIENCE_ENV
1160 } else if jwks_uri.is_some() {
1161 SERVER_OAUTH_JWKS_URI_ENV
1162 } else if allowed_algorithms.is_some() {
1163 SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV
1164 } else if proxy_strip_resource_param.is_some() {
1165 SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV
1166 } else {
1167 SERVER_OAUTH_ISSUER_ENV
1168 }
1169}
1170
1171fn parse_duration_field(field: &str, value: &str) -> Result<Duration, RmcpServerKitError> {
1172 humantime::parse_duration(value).map_err(|error| {
1173 RmcpServerKitError::Config(format!("invalid duration for {field}: {value:?}: {error}"))
1174 })
1175}
1176
1177#[derive(Deserialize)]
1179#[serde(deny_unknown_fields)]
1180#[allow(
1181 clippy::struct_excessive_bools,
1182 reason = "observability configuration is a flat TOML schema with independent boolean feature flags"
1183)]
1184#[non_exhaustive]
1185pub struct ObservabilityConfig {
1186 #[serde(default = "default_log_level")]
1188 pub log_level: String,
1189 #[serde(default = "default_log_format")]
1191 pub log_format: String,
1192 pub audit_log_path: Option<PathBuf>,
1194 #[serde(default)]
1197 pub log_request_headers: bool,
1198 #[serde(default)]
1200 pub metrics_enabled: bool,
1201 #[serde(default = "default_metrics_bind")]
1203 pub metrics_bind: String,
1204 #[serde(default)]
1208 pub log_plaintext_oauth_tokens: bool,
1209 #[serde(default)]
1213 pub log_oauth_claim_values: bool,
1214 #[serde(default)]
1218 pub log_tool_call_arguments: bool,
1219 #[serde(default)]
1224 pub log_upstream_error_bodies: bool,
1225}
1226
1227impl std::fmt::Debug for ObservabilityConfig {
1234 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1235 f.debug_struct("ObservabilityConfig")
1236 .field("log_level", &self.log_level)
1237 .field("log_format", &self.log_format)
1238 .field(
1239 "audit_log_path",
1240 &self.audit_log_path.as_ref().map(|_| "[REDACTED]"),
1241 )
1242 .field("log_request_headers", &self.log_request_headers)
1243 .field("metrics_enabled", &self.metrics_enabled)
1244 .field("metrics_bind", &self.metrics_bind)
1245 .field(
1246 "log_plaintext_oauth_tokens",
1247 &self.log_plaintext_oauth_tokens,
1248 )
1249 .field("log_oauth_claim_values", &self.log_oauth_claim_values)
1250 .field("log_tool_call_arguments", &self.log_tool_call_arguments)
1251 .field("log_upstream_error_bodies", &self.log_upstream_error_bodies)
1252 .finish()
1253 }
1254}
1255
1256impl Default for ObservabilityConfig {
1257 fn default() -> Self {
1258 Self {
1259 log_level: default_log_level(),
1260 log_format: default_log_format(),
1261 audit_log_path: None,
1262 log_request_headers: false,
1263 metrics_enabled: false,
1264 metrics_bind: default_metrics_bind(),
1265 log_plaintext_oauth_tokens: false,
1266 log_oauth_claim_values: false,
1267 log_tool_call_arguments: false,
1268 log_upstream_error_bodies: false,
1269 }
1270 }
1271}
1272
1273pub(crate) enum SharedConfigViolation {
1280 AdminRequiresAuth,
1282 TlsCertWithoutKey,
1284 TlsKeyWithoutCert,
1286 MtlsRequiresTls,
1288}
1289
1290#[allow(
1299 clippy::fn_params_excessive_bools,
1300 reason = "these are the five independent predicates both validators evaluate; a params struct would carry the same five bools and only relocate the lint"
1301)]
1302pub(crate) fn check_shared_config_invariants(
1303 admin_enabled: bool,
1304 auth_enabled: bool,
1305 has_tls_cert: bool,
1306 has_tls_key: bool,
1307 has_mtls: bool,
1308) -> Result<(), SharedConfigViolation> {
1309 if admin_enabled && !auth_enabled {
1310 return Err(SharedConfigViolation::AdminRequiresAuth);
1311 }
1312 match (has_tls_cert, has_tls_key) {
1313 (true, false) => return Err(SharedConfigViolation::TlsCertWithoutKey),
1314 (false, true) => return Err(SharedConfigViolation::TlsKeyWithoutCert),
1315 _ => {}
1316 }
1317 if has_mtls && !(has_tls_cert && has_tls_key) {
1318 return Err(SharedConfigViolation::MtlsRequiresTls);
1319 }
1320 Ok(())
1321}
1322
1323pub fn validate_server_config(server: &ServerConfig) -> crate::error::Result<()> {
1329 use crate::error::RmcpServerKitError;
1330
1331 if server.listen_port == 0 {
1332 return Err(RmcpServerKitError::Config(
1333 "listen_port must be nonzero".into(),
1334 ));
1335 }
1336
1337 if let Err(violation) = check_shared_config_invariants(
1346 server.admin_enabled,
1347 server.auth.as_ref().is_some_and(|a| a.enabled),
1348 server.tls_cert_path.is_some(),
1349 server.tls_key_path.is_some(),
1350 server.auth.as_ref().is_some_and(|a| a.mtls.is_some()),
1351 ) {
1352 return Err(RmcpServerKitError::Config(
1353 match violation {
1354 SharedConfigViolation::AdminRequiresAuth => {
1355 "admin_enabled=true requires auth to be configured and enabled"
1356 }
1357 SharedConfigViolation::TlsCertWithoutKey
1358 | SharedConfigViolation::TlsKeyWithoutCert => {
1359 "tls_cert_path and tls_key_path must both be set or both omitted"
1360 }
1361 SharedConfigViolation::MtlsRequiresTls => {
1367 "auth.mtls requires TLS: set both tls_cert_path and tls_key_path \
1368 (mTLS client certificates cannot be verified on a plaintext listener)"
1369 }
1370 }
1371 .into(),
1372 ));
1373 }
1374
1375 if let Some(auth) = &server.auth {
1376 auth.validate_api_key_names()?;
1377 }
1378
1379 if server.max_concurrent_requests == Some(0) {
1380 return Err(RmcpServerKitError::Config(
1381 "max_concurrent_requests must be nonzero when set".into(),
1382 ));
1383 }
1384
1385 if server.extra_route_rate_limit == Some(0) {
1386 return Err(RmcpServerKitError::Config(
1387 "server.extra_route_rate_limit must be greater than zero".into(),
1388 ));
1389 }
1390
1391 validate_rate_limit_knobs(server)?;
1392 validate_mtls_knobs(server)?;
1393 validate_trusted_forwarder_config(server)?;
1394
1395 if server.admin_enabled && server.admin_role.trim().is_empty() {
1396 return Err(RmcpServerKitError::Config(
1397 "admin_role must not be empty".into(),
1398 ));
1399 }
1400
1401 if let Some(secret) = &server.session_binding_secret {
1402 crate::session_binding::validate_configured_secret(
1403 "server.session_binding_secret",
1404 secret.expose_secret(),
1405 )?;
1406 }
1407
1408 for (field, value) in [
1409 ("server.shutdown_timeout", server.shutdown_timeout.as_str()),
1410 ("server.request_timeout", server.request_timeout.as_str()),
1411 (
1412 "server.session_idle_timeout",
1413 server.session_idle_timeout.as_str(),
1414 ),
1415 ("server.sse_keep_alive", server.sse_keep_alive.as_str()),
1416 (
1417 "server.tls_handshake_timeout",
1418 server.tls_handshake_timeout.as_str(),
1419 ),
1420 ] {
1421 if humantime::parse_duration(value).is_err() {
1422 return Err(RmcpServerKitError::Config(format!(
1423 "invalid duration for {field}: {value:?}"
1424 )));
1425 }
1426 }
1427
1428 if humantime::parse_duration(&server.tls_handshake_timeout).is_ok_and(|d| d == Duration::ZERO) {
1432 return Err(RmcpServerKitError::Config(
1433 "server.tls_handshake_timeout must be greater than zero".into(),
1434 ));
1435 }
1436
1437 if server.max_concurrent_tls_handshakes == 0 {
1441 return Err(RmcpServerKitError::Config(
1442 "server.max_concurrent_tls_handshakes must be greater than zero".into(),
1443 ));
1444 }
1445
1446 Ok(())
1447}
1448
1449fn validate_rate_limit_knobs(server: &ServerConfig) -> crate::error::Result<()> {
1453 use crate::error::RmcpServerKitError;
1454
1455 if server.tool_rate_limit_burst == Some(0) {
1456 return Err(RmcpServerKitError::Config(
1457 "server.tool_rate_limit_burst must be greater than zero".into(),
1458 ));
1459 }
1460 if server.extra_route_rate_limit_burst == Some(0) {
1461 return Err(RmcpServerKitError::Config(
1462 "server.extra_route_rate_limit_burst must be greater than zero".into(),
1463 ));
1464 }
1465 if server.tool_rate_limit_burst.is_some() && server.tool_rate_limit.is_none() {
1466 return Err(RmcpServerKitError::Config(
1467 "server.tool_rate_limit_burst requires server.tool_rate_limit".into(),
1468 ));
1469 }
1470 if server.extra_route_rate_limit_burst.is_some() && server.extra_route_rate_limit.is_none() {
1471 return Err(RmcpServerKitError::Config(
1472 "server.extra_route_rate_limit_burst requires server.extra_route_rate_limit".into(),
1473 ));
1474 }
1475 if !server.extra_route_rate_limit_exempt_paths.is_empty()
1476 && server.extra_route_rate_limit.is_none()
1477 {
1478 return Err(RmcpServerKitError::Config(
1479 "server.extra_route_rate_limit_exempt_paths requires server.extra_route_rate_limit"
1480 .into(),
1481 ));
1482 }
1483 for path in &server.extra_route_rate_limit_exempt_paths {
1484 if path.is_empty() || !path.starts_with('/') {
1485 return Err(RmcpServerKitError::Config(format!(
1486 "server.extra_route_rate_limit_exempt_paths entries must be non-empty and start with '/': {path:?}"
1487 )));
1488 }
1489 }
1490 if let Some(auth) = server.auth.as_ref() {
1491 auth.check_oauth_feature()?;
1492 }
1493 if let Some(rl) = server.auth.as_ref().and_then(|a| a.rate_limit.as_ref()) {
1494 (rl.max_attempts_per_minute != 0).ok_or_else(|| {
1495 RmcpServerKitError::Config(
1496 "auth.rate_limit.max_attempts_per_minute must be nonzero".into(),
1497 )
1498 })?;
1499 if rl.burst == Some(0) {
1500 return Err(RmcpServerKitError::Config(
1501 "auth.rate_limit.burst must be greater than zero".into(),
1502 ));
1503 }
1504 if rl.pre_auth_burst == Some(0) {
1505 return Err(RmcpServerKitError::Config(
1506 "auth.rate_limit.pre_auth_burst must be greater than zero".into(),
1507 ));
1508 }
1509 (rl.pre_auth_max_per_minute != Some(0)).ok_or_else(|| {
1514 RmcpServerKitError::Config(
1515 "auth.rate_limit.pre_auth_max_per_minute must be nonzero when set".into(),
1516 )
1517 })?;
1518 }
1519 Ok(())
1520}
1521
1522fn validate_mtls_knobs(server: &ServerConfig) -> crate::error::Result<()> {
1523 use crate::error::RmcpServerKitError;
1524
1525 if let Some(mtls) = server.auth.as_ref().and_then(|a| a.mtls.as_ref()) {
1526 (mtls.crl_max_concurrent_fetches != 0).ok_or_else(|| {
1527 RmcpServerKitError::Config(
1528 "auth.mtls.crl_max_concurrent_fetches must be nonzero".into(),
1529 )
1530 })?;
1531 (mtls.crl_discovery_rate_per_min != 0).ok_or_else(|| {
1532 RmcpServerKitError::Config(
1533 "auth.mtls.crl_discovery_rate_per_min must be nonzero".into(),
1534 )
1535 })?;
1536 (mtls.crl_max_host_semaphores != 0).ok_or_else(|| {
1537 RmcpServerKitError::Config("auth.mtls.crl_max_host_semaphores must be nonzero".into())
1538 })?;
1539 (mtls.crl_max_seen_urls != 0).ok_or_else(|| {
1540 RmcpServerKitError::Config("auth.mtls.crl_max_seen_urls must be nonzero".into())
1541 })?;
1542 (mtls.crl_max_cache_entries != 0).ok_or_else(|| {
1543 RmcpServerKitError::Config("auth.mtls.crl_max_cache_entries must be nonzero".into())
1544 })?;
1545 (mtls.crl_max_response_bytes != 0).ok_or_else(|| {
1550 RmcpServerKitError::Config("auth.mtls.crl_max_response_bytes must be nonzero".into())
1551 })?;
1552 }
1553 Ok(())
1554}
1555
1556fn validate_trusted_forwarder_config(server: &ServerConfig) -> crate::error::Result<()> {
1559 use crate::error::RmcpServerKitError;
1560
1561 for entry in &server.trusted_proxies {
1562 crate::transport::validate_trusted_proxy_entry(entry)
1563 .map_err(RmcpServerKitError::Config)?;
1564 }
1565 if server.forwarded_header.is_some() && server.trusted_proxies.is_empty() {
1566 return Err(RmcpServerKitError::Config(
1567 "server.forwarded_header requires server.trusted_proxies to be nonempty".into(),
1568 ));
1569 }
1570 if server.trusted_forwarder_max_entries == 0
1571 || server.trusted_forwarder_max_entries > crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES
1572 {
1573 return Err(RmcpServerKitError::Config(format!(
1574 "server.trusted_forwarder_max_entries must be in 1..={}, got {}",
1575 crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES,
1576 server.trusted_forwarder_max_entries
1577 )));
1578 }
1579 Ok(())
1580}
1581
1582pub fn validate_observability_config(obs: &ObservabilityConfig) -> crate::error::Result<()> {
1588 use tracing_subscriber::EnvFilter;
1589
1590 use crate::error::RmcpServerKitError;
1591
1592 if EnvFilter::try_new(&obs.log_level).is_err() {
1593 return Err(RmcpServerKitError::Config(format!(
1594 "invalid log_level: {:?} (expected a valid tracing filter directive, e.g. \"info\", \"debug,hyper=warn\")",
1595 obs.log_level
1596 )));
1597 }
1598 let valid_formats = ["json", "pretty", "text"];
1599 if !valid_formats.contains(&obs.log_format.as_str()) {
1600 return Err(RmcpServerKitError::Config(format!(
1601 "invalid log_format: {:?} (expected one of: {valid_formats:?})",
1602 obs.log_format
1603 )));
1604 }
1605
1606 Ok(())
1607}
1608
1609fn default_listen_addr() -> String {
1612 "127.0.0.1".into()
1613}
1614fn default_listen_port() -> u16 {
1615 8443
1616}
1617fn default_shutdown_timeout() -> String {
1618 "30s".into()
1619}
1620fn default_request_timeout() -> String {
1621 "120s".into()
1622}
1623const fn default_max_request_body() -> usize {
1624 1024 * 1024
1625}
1626const fn default_trusted_forwarder_max_entries() -> usize {
1627 crate::forwarded::MAX_SCANNED_ENTRIES
1628}
1629const fn default_expose_build_metadata() -> bool {
1630 false
1631}
1632const fn default_tool_list_filtering() -> bool {
1633 true
1634}
1635fn default_security_headers() -> SecurityHeadersConfig {
1636 SecurityHeadersConfig::default()
1637}
1638fn default_log_level() -> String {
1639 "info,rmcp=warn".into()
1640}
1641fn default_log_format() -> String {
1642 "pretty".into()
1643}
1644fn default_metrics_bind() -> String {
1645 "127.0.0.1:9090".into()
1646}
1647fn default_session_idle_timeout() -> String {
1648 "20m".into()
1649}
1650const fn default_session_binding() -> bool {
1651 true
1652}
1653fn default_tls_handshake_timeout() -> String {
1654 "10s".into()
1655}
1656const fn default_max_concurrent_tls_handshakes() -> usize {
1657 256
1658}
1659fn default_admin_role() -> String {
1660 "admin".into()
1661}
1662fn default_compression_min_size() -> u16 {
1663 1024
1664}
1665fn default_sse_keep_alive() -> String {
1666 "15s".into()
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671 #![allow(
1672 clippy::unwrap_used,
1673 clippy::expect_used,
1674 clippy::panic,
1675 clippy::indexing_slicing,
1676 clippy::unwrap_in_result,
1677 clippy::print_stdout,
1678 clippy::print_stderr,
1679 deprecated,
1680 reason = "test-only relaxations; production code uses ? and tracing"
1681 )]
1682 use std::{collections::HashSet, time::Duration};
1683
1684 use super::*;
1685 use crate::transport::McpServerConfig;
1686
1687 #[derive(Debug, Deserialize)]
1688 #[serde(deny_unknown_fields)]
1689 struct RootConfig {
1690 server: ServerConfig,
1691 }
1692
1693 fn server_from_root_toml(toml: &str) -> ServerConfig {
1694 toml::from_str::<RootConfig>(toml).unwrap().server
1695 }
1696
1697 #[test]
1700 fn server_config_defaults() {
1701 let cfg = ServerConfig::default();
1702 assert_eq!(cfg.listen_addr, "127.0.0.1");
1703 assert_eq!(cfg.listen_port, 8443);
1704 assert!(cfg.tls_cert_path.is_none());
1705 assert!(cfg.tls_key_path.is_none());
1706 assert_eq!(cfg.shutdown_timeout, "30s");
1707 assert_eq!(cfg.request_timeout, "120s");
1708 assert!(cfg.allowed_origins.is_empty());
1709 assert!(!cfg.stdio_enabled);
1710 assert!(cfg.tool_rate_limit.is_none());
1711 assert_eq!(cfg.key_eviction_policy, KeyEvictionPolicy::EvictLru);
1712 assert_eq!(cfg.session_idle_timeout, "20m");
1713 assert_eq!(cfg.sse_keep_alive, "15s");
1714 assert!(cfg.public_url.is_none());
1715 assert!(cfg.tool_list_filtering);
1716 }
1717
1718 #[test]
1719 fn observability_config_defaults() {
1720 let cfg = ObservabilityConfig::default();
1721 assert_eq!(cfg.log_level, "info,rmcp=warn");
1722 assert_eq!(cfg.log_format, "pretty");
1723 assert!(cfg.audit_log_path.is_none());
1724 assert!(!cfg.log_request_headers);
1725 assert!(!cfg.metrics_enabled);
1726 assert_eq!(cfg.metrics_bind, "127.0.0.1:9090");
1727 assert!(!cfg.log_plaintext_oauth_tokens);
1728 assert!(!cfg.log_oauth_claim_values);
1729 assert!(!cfg.log_tool_call_arguments);
1730 }
1731
1732 #[test]
1735 fn valid_server_config_passes() {
1736 let cfg = ServerConfig::default();
1737 assert!(validate_server_config(&cfg).is_ok());
1738 }
1739
1740 #[test]
1741 fn validate_server_config_rejects_blank_api_key_name() {
1742 let blank = ServerConfig {
1743 auth: Some(crate::auth::AuthConfig::with_keys(vec![
1744 crate::auth::ApiKeyEntry::new("", "hash", "viewer"),
1745 ])),
1746 ..ServerConfig::default()
1747 };
1748 let err = validate_server_config(&blank).unwrap_err().to_string();
1749 assert!(
1750 err.contains("api_keys[0]"),
1751 "must name offending index: {err}"
1752 );
1753
1754 let whitespace = ServerConfig {
1755 auth: Some(crate::auth::AuthConfig::with_keys(vec![
1756 crate::auth::ApiKeyEntry::new(" ", "hash", "viewer"),
1757 ])),
1758 ..ServerConfig::default()
1759 };
1760 assert!(validate_server_config(&whitespace).is_err());
1761
1762 let ok = ServerConfig {
1763 auth: Some(crate::auth::AuthConfig::with_keys(vec![
1764 crate::auth::ApiKeyEntry::new("viewer-key", "hash", "viewer"),
1765 ])),
1766 ..ServerConfig::default()
1767 };
1768 assert!(validate_server_config(&ok).is_ok());
1769 }
1770
1771 #[test]
1772 fn admin_auth_check_precedes_tls_and_mtls_like_the_builder() {
1773 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
1777 auth.enabled = false;
1778 auth.mtls = Some(valid_mtls_config());
1779 let cfg = ServerConfig {
1780 admin_enabled: true,
1781 auth: Some(auth),
1782 tls_cert_path: None,
1783 tls_key_path: None,
1784 ..ServerConfig::default()
1785 };
1786 let err = validate_server_config(&cfg).unwrap_err().to_string();
1787 assert!(
1788 err.contains("admin_enabled=true requires auth"),
1789 "admin/auth must fire before TLS and mTLS checks; got {err}"
1790 );
1791 }
1792
1793 fn classify_shared_check(err: RmcpServerKitError) -> SharedCheck {
1794 match err {
1795 RmcpServerKitError::Config(msg) => {
1796 if msg.contains("admin_enabled=true requires auth") {
1797 SharedCheck::AdminAuth
1798 } else if msg.contains("must both be set or both omitted")
1799 || msg.contains("tls_cert_path is set but tls_key_path is missing")
1800 || msg.contains("tls_key_path is set but tls_cert_path is missing")
1801 {
1802 SharedCheck::TlsPairing
1803 } else if msg.contains("auth.mtls requires TLS") {
1804 SharedCheck::MtlsRequiresTls
1805 } else {
1806 panic!("unclassified shared-check config error: {msg}");
1807 }
1808 }
1809 RmcpServerKitError::Auth(msg) => {
1810 panic!("expected Config error, got Auth({msg})");
1811 }
1812 RmcpServerKitError::Rbac(msg) => {
1813 panic!("expected Config error, got Rbac({msg})");
1814 }
1815 RmcpServerKitError::RateLimited(msg) => {
1816 panic!("expected Config error, got RateLimited({msg})");
1817 }
1818 RmcpServerKitError::RateLimitedFor {
1819 message,
1820 retry_after,
1821 } => {
1822 panic!("expected Config error, got RateLimitedFor({message}, {retry_after:?})");
1823 }
1824 RmcpServerKitError::Io(error) => {
1825 panic!("expected Config error, got Io({error})");
1826 }
1827 RmcpServerKitError::Json(error) => {
1828 panic!("expected Config error, got Json({error})");
1829 }
1830 RmcpServerKitError::Toml(error) => {
1831 panic!("expected Config error, got Toml({error})");
1832 }
1833 RmcpServerKitError::Tls(msg) => {
1834 panic!("expected Config error, got Tls({msg})");
1835 }
1836 RmcpServerKitError::Startup(msg) => {
1837 panic!("expected Config error, got Startup({msg})");
1838 }
1839 RmcpServerKitError::Internal(msg) => {
1840 panic!("expected Config error, got Internal({msg})");
1841 }
1842 #[cfg(feature = "metrics")]
1843 RmcpServerKitError::Metrics(msg) => {
1844 panic!("expected Config error, got Metrics({msg})");
1845 }
1846 }
1847 }
1848
1849 #[derive(Debug, Clone, Copy)]
1850 enum AdminSetting {
1851 Valid,
1852 EnabledWithDisabledAuth,
1853 }
1854
1855 #[derive(Debug, Clone, Copy)]
1856 enum TlsSetting {
1857 Absent,
1858 CertOnly,
1859 KeyOnly,
1860 }
1861
1862 #[derive(Debug, Clone, Copy)]
1863 enum MtlsSetting {
1864 Absent,
1865 WithoutTls,
1866 WithoutTlsAndInvalidCapacity,
1867 }
1868
1869 #[derive(Debug)]
1870 struct SharedCheckCase {
1871 name: &'static str,
1872 admin: AdminSetting,
1873 tls_variants: &'static [TlsSetting],
1874 mtls: MtlsSetting,
1875 expected: SharedCheck,
1876 }
1877
1878 const ABSENT_TLS: &[TlsSetting] = &[TlsSetting::Absent];
1879 const BOTH_PARTIAL_TLS_DIRECTIONS: &[TlsSetting] = &[TlsSetting::CertOnly, TlsSetting::KeyOnly];
1880
1881 #[test]
1882 fn toml_and_builder_validators_report_the_expected_shared_check_order() {
1883 let cases = [
1884 SharedCheckCase {
1885 name: "case 1: admin/auth dependency only",
1886 admin: AdminSetting::EnabledWithDisabledAuth,
1887 tls_variants: ABSENT_TLS,
1888 mtls: MtlsSetting::Absent,
1889 expected: SharedCheck::AdminAuth,
1890 },
1891 SharedCheckCase {
1892 name: "case 2: TLS pairing only",
1893 admin: AdminSetting::Valid,
1894 tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1895 mtls: MtlsSetting::Absent,
1896 expected: SharedCheck::TlsPairing,
1897 },
1898 SharedCheckCase {
1899 name: "case 3: mTLS without TLS only",
1900 admin: AdminSetting::Valid,
1901 tls_variants: ABSENT_TLS,
1902 mtls: MtlsSetting::WithoutTls,
1903 expected: SharedCheck::MtlsRequiresTls,
1904 },
1905 SharedCheckCase {
1906 name: "case 4: admin/auth dependency before TLS pairing",
1907 admin: AdminSetting::EnabledWithDisabledAuth,
1908 tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1909 mtls: MtlsSetting::Absent,
1910 expected: SharedCheck::AdminAuth,
1911 },
1912 SharedCheckCase {
1913 name: "case 5: admin/auth dependency before mTLS without TLS",
1914 admin: AdminSetting::EnabledWithDisabledAuth,
1915 tls_variants: ABSENT_TLS,
1916 mtls: MtlsSetting::WithoutTls,
1917 expected: SharedCheck::AdminAuth,
1918 },
1919 SharedCheckCase {
1920 name: "case 6: TLS pairing before mTLS without TLS",
1921 admin: AdminSetting::Valid,
1922 tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1923 mtls: MtlsSetting::WithoutTls,
1924 expected: SharedCheck::TlsPairing,
1925 },
1926 SharedCheckCase {
1927 name: "case 7: admin/auth dependency before TLS pairing and mTLS without TLS",
1928 admin: AdminSetting::EnabledWithDisabledAuth,
1929 tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1930 mtls: MtlsSetting::WithoutTls,
1931 expected: SharedCheck::AdminAuth,
1932 },
1933 SharedCheckCase {
1934 name: "case 8: mTLS without TLS before mTLS capacity knobs",
1935 admin: AdminSetting::Valid,
1936 tls_variants: ABSENT_TLS,
1937 mtls: MtlsSetting::WithoutTlsAndInvalidCapacity,
1938 expected: SharedCheck::MtlsRequiresTls,
1939 },
1940 ];
1941
1942 for case in cases {
1943 for tls in case.tls_variants {
1944 let config = shared_check_config(case.admin, *tls, case.mtls);
1945
1946 let toml_class = classify_toml_validator_error(&config);
1947 assert_eq!(
1948 toml_class, case.expected,
1949 "{} with {:?} must fail TOML validation at {:?}",
1950 case.name, tls, case.expected
1951 );
1952
1953 let builder_class = classify_builder_validator_error(&config);
1954 assert_eq!(
1955 builder_class, case.expected,
1956 "{} with {:?} must fail builder validation at {:?}",
1957 case.name, tls, case.expected
1958 );
1959 }
1960 }
1961 }
1962
1963 fn classify_toml_validator_error(config: &ServerConfig) -> SharedCheck {
1964 let err = validate_server_config(config).expect_err("config must fail TOML validation");
1965 classify_shared_check(err)
1966 }
1967
1968 fn classify_builder_validator_error(config: &ServerConfig) -> SharedCheck {
1969 let builder_config = config
1970 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:1", "t", "0.0.0"))
1971 .expect("valid durations must bridge into McpServerConfig");
1972 let err = builder_config
1973 .validate()
1974 .expect_err("config must fail builder validation");
1975 classify_shared_check(err)
1976 }
1977
1978 fn shared_check_config(
1979 admin: AdminSetting,
1980 tls: TlsSetting,
1981 mtls: MtlsSetting,
1982 ) -> ServerConfig {
1983 let mut config = ServerConfig::default();
1984 apply_admin_setting(&mut config, admin);
1985 apply_tls_setting(&mut config, tls);
1986 apply_mtls_setting(&mut config, admin, mtls);
1987 config
1988 }
1989
1990 fn apply_admin_setting(config: &mut ServerConfig, admin: AdminSetting) {
1991 match admin {
1992 AdminSetting::Valid => {}
1993 AdminSetting::EnabledWithDisabledAuth => {
1994 config.admin_enabled = true;
1995 let auth = config
1996 .auth
1997 .get_or_insert_with(|| crate::auth::AuthConfig::with_keys(vec![]));
1998 auth.enabled = false;
1999 }
2000 }
2001 }
2002
2003 fn apply_tls_setting(config: &mut ServerConfig, tls: TlsSetting) {
2004 match tls {
2005 TlsSetting::Absent => {}
2006 TlsSetting::CertOnly => {
2007 config.tls_cert_path = Some("/tmp/cert.pem".into());
2008 }
2009 TlsSetting::KeyOnly => {
2010 config.tls_key_path = Some("/tmp/key.pem".into());
2011 }
2012 }
2013 }
2014
2015 fn apply_mtls_setting(config: &mut ServerConfig, admin: AdminSetting, mtls: MtlsSetting) {
2016 match mtls {
2017 MtlsSetting::Absent => {}
2018 MtlsSetting::WithoutTls => {
2019 let enabled = matches!(admin, AdminSetting::Valid);
2020 let auth = config
2021 .auth
2022 .get_or_insert_with(|| crate::auth::AuthConfig::with_keys(vec![]));
2023 auth.enabled = enabled;
2024 auth.mtls = Some(valid_mtls_config());
2025 }
2026 MtlsSetting::WithoutTlsAndInvalidCapacity => {
2027 let auth = config
2028 .auth
2029 .get_or_insert_with(|| crate::auth::AuthConfig::with_keys(vec![]));
2030 auth.enabled = true;
2031 let mut mtls_config = valid_mtls_config();
2032 mtls_config.crl_max_concurrent_fetches = 0;
2033 auth.mtls = Some(mtls_config);
2034 }
2035 }
2036 }
2037
2038 #[test]
2039 fn mtls_without_tls_rejected() {
2040 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
2041 auth.mtls = Some(valid_mtls_config());
2042 let cfg = ServerConfig {
2043 auth: Some(auth),
2044 tls_cert_path: None,
2045 tls_key_path: None,
2046 ..ServerConfig::default()
2047 };
2048 let err = validate_server_config(&cfg).unwrap_err();
2049 let msg = err.to_string();
2050 assert!(
2051 msg.contains("tls_cert_path") && msg.contains("tls_key_path"),
2052 "{msg}"
2053 );
2054 }
2055
2056 #[test]
2057 fn mtls_with_tls_accepted() {
2058 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
2059 auth.mtls = Some(valid_mtls_config());
2060 let cfg = ServerConfig {
2061 auth: Some(auth),
2062 tls_cert_path: Some("cert.pem".into()),
2063 tls_key_path: Some("key.pem".into()),
2064 ..ServerConfig::default()
2065 };
2066 assert!(validate_server_config(&cfg).is_ok());
2067 }
2068
2069 #[test]
2070 fn zero_port_rejected() {
2071 let cfg = ServerConfig {
2072 listen_port: 0,
2073 ..ServerConfig::default()
2074 };
2075 let err = validate_server_config(&cfg).unwrap_err();
2076 assert!(err.to_string().contains("listen_port"));
2077 }
2078
2079 #[test]
2080 fn zero_extra_route_rate_limit_rejected() {
2081 let cfg = ServerConfig {
2082 extra_route_rate_limit: Some(0),
2083 ..ServerConfig::default()
2084 };
2085 let err = validate_server_config(&cfg).unwrap_err();
2086 assert!(err.to_string().contains("extra_route_rate_limit"));
2087 }
2088
2089 #[test]
2090 fn zero_burst_knobs_rejected() {
2091 let cfg = ServerConfig {
2092 tool_rate_limit: Some(10),
2093 tool_rate_limit_burst: Some(0),
2094 ..ServerConfig::default()
2095 };
2096 let err = validate_server_config(&cfg).unwrap_err();
2097 assert!(err.to_string().contains("tool_rate_limit_burst"));
2098
2099 let cfg = ServerConfig {
2100 extra_route_rate_limit: Some(10),
2101 extra_route_rate_limit_burst: Some(0),
2102 ..ServerConfig::default()
2103 };
2104 let err = validate_server_config(&cfg).unwrap_err();
2105 assert!(err.to_string().contains("extra_route_rate_limit_burst"));
2106 }
2107
2108 #[test]
2109 fn orphan_burst_knobs_rejected() {
2110 let cfg = ServerConfig {
2111 tool_rate_limit_burst: Some(5),
2112 ..ServerConfig::default()
2113 };
2114 let err = validate_server_config(&cfg).unwrap_err();
2115 assert!(err.to_string().contains("requires server.tool_rate_limit"));
2116
2117 let cfg = ServerConfig {
2118 extra_route_rate_limit_burst: Some(5),
2119 ..ServerConfig::default()
2120 };
2121 let err = validate_server_config(&cfg).unwrap_err();
2122 assert!(
2123 err.to_string()
2124 .contains("requires server.extra_route_rate_limit")
2125 );
2126 }
2127
2128 #[test]
2129 fn exempt_paths_toml_roundtrip_and_validation() {
2130 let cfg: ServerConfig = toml::from_str(
2131 r#"
2132 extra_route_rate_limit = 60
2133 extra_route_rate_limit_exempt_paths = ["/.well-known/oauth-authorization-server"]
2134 "#,
2135 )
2136 .unwrap();
2137 assert_eq!(
2138 cfg.extra_route_rate_limit_exempt_paths,
2139 vec!["/.well-known/oauth-authorization-server".to_owned()]
2140 );
2141 assert!(validate_server_config(&cfg).is_ok());
2142 }
2143
2144 #[test]
2145 fn orphan_exempt_paths_rejected() {
2146 let cfg = ServerConfig {
2147 extra_route_rate_limit_exempt_paths: vec!["/ok".into()],
2148 ..ServerConfig::default()
2149 };
2150 let err = validate_server_config(&cfg).unwrap_err();
2151 assert!(
2152 err.to_string()
2153 .contains("requires server.extra_route_rate_limit")
2154 );
2155 }
2156
2157 #[test]
2158 fn malformed_exempt_paths_rejected() {
2159 for bad in ["", "no-slash"] {
2160 let cfg = ServerConfig {
2161 extra_route_rate_limit: Some(10),
2162 extra_route_rate_limit_exempt_paths: vec![bad.into()],
2163 ..ServerConfig::default()
2164 };
2165 let err = validate_server_config(&cfg).unwrap_err();
2166 assert!(
2167 err.to_string()
2168 .contains("must be non-empty and start with '/'"),
2169 "entry {bad:?}: {err}"
2170 );
2171 }
2172 }
2173
2174 #[test]
2175 fn bad_trusted_proxy_entry_rejected() {
2176 let cfg = ServerConfig {
2177 trusted_proxies: vec!["not-a-cidr".into()],
2178 ..ServerConfig::default()
2179 };
2180 let err = validate_server_config(&cfg).unwrap_err();
2181 assert!(err.to_string().contains("trusted_proxies"));
2182 }
2183
2184 #[test]
2185 fn zero_prefix_trusted_proxy_rejected() {
2186 for entry in ["0.0.0.0/0", "::/0"] {
2187 let cfg = ServerConfig {
2188 trusted_proxies: vec![entry.into()],
2189 ..ServerConfig::default()
2190 };
2191 let err = validate_server_config(&cfg).unwrap_err();
2192 assert!(
2193 err.to_string().contains("prefix length 0"),
2194 "entry {entry:?}: {err}"
2195 );
2196 }
2197 }
2198
2199 #[test]
2200 fn toml_trusted_forwarder_max_entries_bounds_are_enforced() {
2201 let parse = |v: usize| -> crate::error::Result<()> {
2202 let cfg: ServerConfig =
2203 toml::from_str(&format!("trusted_forwarder_max_entries = {v}")).unwrap();
2204 validate_server_config(&cfg)
2205 };
2206 assert!(parse(0).is_err());
2207 assert!(parse(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES + 1).is_err());
2208 assert!(parse(1).is_ok());
2209 assert!(parse(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES).is_ok());
2210 }
2211
2212 #[test]
2213 fn toml_trusted_forwarder_max_entries_defaults_and_bridges() {
2214 let cfg: ServerConfig = toml::from_str("").unwrap();
2215 assert_eq!(
2216 cfg.trusted_forwarder_max_entries,
2217 crate::forwarded::MAX_SCANNED_ENTRIES
2218 );
2219 let base = crate::transport::McpServerConfig::new("127.0.0.1:8080", "t", "0");
2220 let src: ServerConfig =
2221 toml::from_str("trusted_forwarder_max_entries = 32").expect("parses");
2222 let bridged = src.apply_to_mcp_config(base).expect("bridges");
2223 assert_eq!(bridged.trusted_forwarder_max_entries, 32);
2224 }
2225
2226 #[test]
2227 fn cidr_and_bare_ip_proxy_entries_accepted() {
2228 let cfg = ServerConfig {
2229 trusted_proxies: vec!["10.0.0.0/8".into(), "192.0.2.1".into()],
2230 ..ServerConfig::default()
2231 };
2232 assert!(validate_server_config(&cfg).is_ok());
2233 }
2234
2235 #[test]
2236 fn forwarded_header_without_proxies_rejected() {
2237 let cfg = ServerConfig {
2238 forwarded_header: Some(crate::transport::ForwardedHeaderMode::Forwarded),
2239 ..ServerConfig::default()
2240 };
2241 let err = validate_server_config(&cfg).unwrap_err();
2242 assert!(err.to_string().contains("requires server.trusted_proxies"));
2243 }
2244
2245 #[test]
2246 fn zero_auth_bursts_rejected() {
2247 let auth = crate::auth::AuthConfig::with_keys(vec![])
2248 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_burst(0));
2249 let cfg = ServerConfig {
2250 auth: Some(auth),
2251 ..ServerConfig::default()
2252 };
2253 let err = validate_server_config(&cfg).unwrap_err();
2254 assert!(err.to_string().contains("rate_limit.burst"));
2255
2256 let auth = crate::auth::AuthConfig::with_keys(vec![])
2257 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(0));
2258 let cfg = ServerConfig {
2259 auth: Some(auth),
2260 ..ServerConfig::default()
2261 };
2262 let err = validate_server_config(&cfg).unwrap_err();
2263 assert!(err.to_string().contains("pre_auth_burst"));
2264 }
2265
2266 fn valid_mtls_config() -> crate::auth::MtlsConfig {
2267 crate::auth::MtlsConfig {
2268 ca_cert_path: "memory://ca.pem".into(),
2269 required: true,
2270 default_role: "viewer".into(),
2271 crl_enabled: true,
2272 crl_refresh_interval: None,
2273 crl_fetch_timeout: Duration::from_secs(30),
2274 crl_stale_grace: Duration::from_secs(24 * 60 * 60),
2275 crl_deny_on_unavailable: false,
2276 crl_end_entity_only: false,
2277 crl_allow_http: true,
2278 crl_enforce_expiration: true,
2279 crl_max_concurrent_fetches: 4,
2280 crl_max_response_bytes: 5 * 1024 * 1024,
2281 crl_discovery_rate_per_min: 60,
2282 crl_max_host_semaphores: 1024,
2283 crl_max_seen_urls: 4096,
2284 crl_max_cache_entries: 1024,
2285 }
2286 }
2287
2288 fn assert_config_nonzero_error(err: RmcpServerKitError, field: &str) {
2289 let RmcpServerKitError::Config(msg) = err else {
2290 panic!("expected Config error for {field}");
2291 };
2292 assert!(
2293 msg.contains(field) && msg.contains("must be nonzero"),
2294 "error must name {field} and say must be nonzero; got {msg:?}"
2295 );
2296 }
2297
2298 fn server_config_with_mtls(mtls: crate::auth::MtlsConfig) -> ServerConfig {
2299 ServerConfig {
2300 auth: Some(crate::auth::AuthConfig {
2301 enabled: true,
2302 api_keys: Vec::new(),
2303 mtls: Some(mtls),
2304 rate_limit: None,
2305 #[cfg(feature = "oauth")]
2306 oauth: None,
2307 #[cfg(not(feature = "oauth"))]
2308 oauth: None,
2309 }),
2310 tls_cert_path: Some("cert.pem".into()),
2314 tls_key_path: Some("key.pem".into()),
2315 ..ServerConfig::default()
2316 }
2317 }
2318
2319 #[test]
2320 fn rejects_zero_crl_max_cache_entries() {
2321 let mut mtls = valid_mtls_config();
2322 mtls.crl_max_cache_entries = 0;
2323 let err = validate_server_config(&server_config_with_mtls(mtls))
2324 .expect_err("zero crl_max_cache_entries must be rejected");
2325 assert_config_nonzero_error(err, "auth.mtls.crl_max_cache_entries");
2326 }
2327
2328 #[test]
2329 fn rejects_zero_crl_max_concurrent_fetches() {
2330 let mut mtls = valid_mtls_config();
2331 mtls.crl_max_concurrent_fetches = 0;
2332 let err = validate_server_config(&server_config_with_mtls(mtls))
2333 .expect_err("zero crl_max_concurrent_fetches must be rejected");
2334 assert_config_nonzero_error(err, "auth.mtls.crl_max_concurrent_fetches");
2335 }
2336
2337 #[test]
2338 fn rejects_zero_crl_discovery_rate_per_min() {
2339 let mut mtls = valid_mtls_config();
2340 mtls.crl_discovery_rate_per_min = 0;
2341 let err = validate_server_config(&server_config_with_mtls(mtls))
2342 .expect_err("zero crl_discovery_rate_per_min must be rejected");
2343 assert_config_nonzero_error(err, "auth.mtls.crl_discovery_rate_per_min");
2344 }
2345
2346 #[test]
2347 fn rejects_zero_crl_max_host_semaphores() {
2348 let mut mtls = valid_mtls_config();
2349 mtls.crl_max_host_semaphores = 0;
2350 let err = validate_server_config(&server_config_with_mtls(mtls))
2351 .expect_err("zero crl_max_host_semaphores must be rejected");
2352 assert_config_nonzero_error(err, "auth.mtls.crl_max_host_semaphores");
2353 }
2354
2355 #[test]
2356 fn rejects_zero_crl_max_seen_urls() {
2357 let mut mtls = valid_mtls_config();
2358 mtls.crl_max_seen_urls = 0;
2359 let err = validate_server_config(&server_config_with_mtls(mtls))
2360 .expect_err("zero crl_max_seen_urls must be rejected");
2361 assert_config_nonzero_error(err, "auth.mtls.crl_max_seen_urls");
2362 }
2363
2364 #[test]
2365 fn rejects_zero_crl_max_response_bytes() {
2366 let mut mtls = valid_mtls_config();
2367 mtls.crl_max_response_bytes = 0;
2368 let err = validate_server_config(&server_config_with_mtls(mtls))
2369 .expect_err("zero crl_max_response_bytes must be rejected");
2370 assert_config_nonzero_error(err, "auth.mtls.crl_max_response_bytes");
2371 }
2372
2373 #[test]
2374 fn rejects_zero_auth_rate_limit() {
2375 let auth = crate::auth::AuthConfig::with_keys(vec![])
2376 .with_rate_limit(crate::auth::RateLimitConfig::new(0));
2377 let cfg = ServerConfig {
2378 auth: Some(auth),
2379 ..ServerConfig::default()
2380 };
2381 let err = validate_server_config(&cfg).expect_err("zero auth rate limit must be rejected");
2382 assert_config_nonzero_error(err, "auth.rate_limit.max_attempts_per_minute");
2383 }
2384
2385 #[test]
2386 fn rejects_zero_pre_auth_max_per_minute() {
2387 let mut rl = crate::auth::RateLimitConfig::new(30);
2391 rl.pre_auth_max_per_minute = Some(0);
2392 let cfg = ServerConfig {
2393 auth: Some(crate::auth::AuthConfig::with_keys(vec![]).with_rate_limit(rl)),
2394 ..ServerConfig::default()
2395 };
2396 let err = validate_server_config(&cfg)
2397 .expect_err("zero pre_auth_max_per_minute must be rejected");
2398 assert_config_nonzero_error(err, "auth.rate_limit.pre_auth_max_per_minute");
2399 }
2400
2401 #[test]
2402 fn tls_cert_without_key_rejected() {
2403 let cfg = ServerConfig {
2404 tls_cert_path: Some("/tmp/cert.pem".into()),
2405 ..ServerConfig::default()
2406 };
2407 let err = validate_server_config(&cfg).unwrap_err();
2408 assert!(err.to_string().contains("tls_cert_path"));
2409 }
2410
2411 #[test]
2412 fn tls_key_without_cert_rejected() {
2413 let cfg = ServerConfig {
2414 tls_key_path: Some("/tmp/key.pem".into()),
2415 ..ServerConfig::default()
2416 };
2417 let err = validate_server_config(&cfg).unwrap_err();
2418 assert!(err.to_string().contains("tls_cert_path"));
2419 }
2420
2421 #[test]
2422 fn tls_both_set_passes() {
2423 let cfg = ServerConfig {
2424 tls_cert_path: Some("/tmp/cert.pem".into()),
2425 tls_key_path: Some("/tmp/key.pem".into()),
2426 ..ServerConfig::default()
2427 };
2428 assert!(validate_server_config(&cfg).is_ok());
2429 }
2430
2431 #[test]
2432 fn invalid_tls_handshake_timeout_rejected() {
2433 let cfg = ServerConfig {
2434 tls_handshake_timeout: "not-a-duration".into(),
2435 ..ServerConfig::default()
2436 };
2437 let err = validate_server_config(&cfg).unwrap_err();
2438 assert!(err.to_string().contains("tls_handshake_timeout"));
2439 }
2440
2441 #[test]
2442 fn zero_tls_handshake_timeout_rejected() {
2443 let cfg = ServerConfig {
2444 tls_handshake_timeout: "0s".into(),
2445 ..ServerConfig::default()
2446 };
2447 let err = validate_server_config(&cfg).unwrap_err();
2448 assert!(err.to_string().contains("tls_handshake_timeout"));
2449 }
2450
2451 #[test]
2452 fn zero_max_concurrent_tls_handshakes_rejected() {
2453 let cfg = ServerConfig {
2454 max_concurrent_tls_handshakes: 0,
2455 ..ServerConfig::default()
2456 };
2457 let err = validate_server_config(&cfg).unwrap_err();
2458 assert!(err.to_string().contains("max_concurrent_tls_handshakes"));
2459 }
2460
2461 #[test]
2462 fn invalid_shutdown_timeout_rejected() {
2463 let cfg = ServerConfig {
2464 shutdown_timeout: "not-a-duration".into(),
2465 ..ServerConfig::default()
2466 };
2467 let err = validate_server_config(&cfg).unwrap_err();
2468 assert!(err.to_string().contains("shutdown_timeout"));
2469 }
2470
2471 #[test]
2472 fn invalid_request_timeout_rejected() {
2473 let cfg = ServerConfig {
2474 request_timeout: "xyz".into(),
2475 ..ServerConfig::default()
2476 };
2477 let err = validate_server_config(&cfg).unwrap_err();
2478 assert!(err.to_string().contains("request_timeout"));
2479 }
2480
2481 #[test]
2484 fn valid_observability_config_passes() {
2485 let cfg = ObservabilityConfig::default();
2486 assert!(validate_observability_config(&cfg).is_ok());
2487 }
2488
2489 #[test]
2490 fn invalid_log_level_rejected() {
2491 let cfg = ObservabilityConfig {
2492 log_level: "[invalid".into(),
2493 ..ObservabilityConfig::default()
2494 };
2495 let err = validate_observability_config(&cfg).unwrap_err();
2496 assert!(err.to_string().contains("log_level"));
2497 }
2498
2499 #[test]
2500 fn invalid_log_format_rejected() {
2501 let cfg = ObservabilityConfig {
2502 log_format: "yaml".into(),
2503 ..ObservabilityConfig::default()
2504 };
2505 let err = validate_observability_config(&cfg).unwrap_err();
2506 assert!(err.to_string().contains("log_format"));
2507 }
2508
2509 #[test]
2510 fn all_valid_log_levels_accepted() {
2511 for level in &[
2512 "trace",
2513 "debug",
2514 "info",
2515 "warn",
2516 "error",
2517 "info,rmcp=warn",
2518 "debug,hyper=error",
2519 ] {
2520 let cfg = ObservabilityConfig {
2521 log_level: (*level).into(),
2522 ..ObservabilityConfig::default()
2523 };
2524 assert!(
2525 validate_observability_config(&cfg).is_ok(),
2526 "level {level} should be valid"
2527 );
2528 }
2529 }
2530
2531 #[test]
2532 fn all_log_formats_accepted() {
2533 for fmt in &["json", "pretty", "text"] {
2534 let cfg = ObservabilityConfig {
2535 log_format: (*fmt).into(),
2536 ..ObservabilityConfig::default()
2537 };
2538 assert!(
2539 validate_observability_config(&cfg).is_ok(),
2540 "format {fmt} should be valid"
2541 );
2542 }
2543 }
2544
2545 #[test]
2548 fn server_config_deserialize_defaults() {
2549 let cfg: ServerConfig = toml::from_str("").unwrap();
2550 assert_eq!(cfg.listen_port, 8443);
2551 assert_eq!(cfg.listen_addr, "127.0.0.1");
2552 assert_eq!(cfg.tls_handshake_timeout, "10s");
2553 assert_eq!(cfg.max_concurrent_tls_handshakes, 256);
2554 }
2555
2556 #[test]
2557 fn t1_existing_server_example_deserializes_with_new_defaults() {
2558 let server = server_from_root_toml(
2559 r#"
2560 [server]
2561 listen_addr = "0.0.0.0"
2562 listen_port = 8443
2563 tls_cert_path = "/etc/certs/server.crt"
2564 tls_key_path = "/etc/certs/server.key"
2565 shutdown_timeout = "30s"
2566 request_timeout = "120s"
2567 allowed_origins = ["http://localhost:3000", "https://myapp.example.com"]
2568 tool_rate_limit = 120
2569 "#,
2570 );
2571
2572 assert_eq!(server.max_request_body, 1024 * 1024);
2573 assert!(!server.expose_build_metadata);
2574 assert_eq!(server.security_headers, SecurityHeadersConfig::default());
2575 }
2576
2577 #[test]
2578 fn t2_default_bridge_is_no_op_for_mcp_defaults() {
2579 let actual = ServerConfig::default()
2580 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2581 .unwrap();
2582 let expected = McpServerConfig::new("127.0.0.1:8443", "t", "0.0.0");
2583
2584 assert_default_bridge_core_fields(&actual, &expected);
2585 assert_default_bridge_limit_fields(&actual, &expected);
2586 assert_default_bridge_metadata_fields(&actual, &expected);
2587 }
2588
2589 fn assert_default_bridge_core_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
2590 assert_eq!(actual.bind_addr, expected.bind_addr);
2591 assert_eq!(actual.tls_cert_path, expected.tls_cert_path);
2592 assert_eq!(actual.tls_key_path, expected.tls_key_path);
2593 assert!(actual.auth.is_none());
2594 assert_eq!(actual.allowed_origins, expected.allowed_origins);
2595 assert_eq!(actual.trusted_proxies, expected.trusted_proxies);
2596 assert_eq!(actual.forwarded_header, expected.forwarded_header);
2597 assert_eq!(actual.public_url, expected.public_url);
2598 assert_eq!(actual.name, expected.name);
2599 assert_eq!(actual.version, expected.version);
2600 }
2601
2602 fn assert_default_bridge_limit_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
2603 assert_eq!(actual.tool_rate_limit, expected.tool_rate_limit);
2604 assert_eq!(actual.tool_rate_limit_burst, expected.tool_rate_limit_burst);
2605 assert_eq!(
2606 actual.extra_route_rate_limit,
2607 expected.extra_route_rate_limit
2608 );
2609 assert_eq!(
2610 actual.extra_route_rate_limit_burst,
2611 expected.extra_route_rate_limit_burst
2612 );
2613 assert_eq!(
2614 actual.extra_route_rate_limit_exempt_paths,
2615 expected.extra_route_rate_limit_exempt_paths
2616 );
2617 assert_eq!(actual.key_eviction_policy, expected.key_eviction_policy);
2618 assert_eq!(actual.max_request_body, expected.max_request_body);
2619 assert_eq!(
2620 actual.max_concurrent_requests,
2621 expected.max_concurrent_requests
2622 );
2623 }
2624
2625 fn assert_default_bridge_metadata_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
2626 assert_eq!(actual.session_idle_timeout, expected.session_idle_timeout);
2627 assert_eq!(actual.session_binding, expected.session_binding);
2628 assert_eq!(actual.sse_keep_alive, expected.sse_keep_alive);
2629 assert_eq!(actual.request_timeout, expected.request_timeout);
2630 assert_eq!(actual.shutdown_timeout, expected.shutdown_timeout);
2631 assert_eq!(actual.tls_handshake_timeout, expected.tls_handshake_timeout);
2632 assert_eq!(
2633 actual.max_concurrent_tls_handshakes,
2634 expected.max_concurrent_tls_handshakes
2635 );
2636 assert_eq!(actual.compression_enabled, expected.compression_enabled);
2637 assert_eq!(actual.compression_min_size, expected.compression_min_size);
2638 assert_eq!(actual.admin_enabled, expected.admin_enabled);
2639 assert_eq!(actual.admin_role, expected.admin_role);
2640 assert_eq!(actual.tool_list_filtering, expected.tool_list_filtering);
2641 assert_eq!(actual.expose_build_metadata, expected.expose_build_metadata);
2642 assert_eq!(actual.security_headers, expected.security_headers);
2643 }
2644
2645 #[test]
2646 fn session_binding_toml_roundtrip_and_bridge() {
2647 let cfg = server_from_root_toml(
2648 r"
2649 [server]
2650 session_binding = false
2651 ",
2652 );
2653 let bridged = cfg
2654 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2655 .unwrap();
2656
2657 assert!(!cfg.session_binding);
2658 assert!(!bridged.session_binding);
2659 assert!(ServerConfig::default().session_binding);
2660 assert!(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0").session_binding);
2661 }
2662
2663 #[test]
2664 fn session_binding_secret_toml_roundtrip_and_bridge() {
2665 let cfg = server_from_root_toml(
2666 r#"
2667 [server]
2668 session_binding_secret = "0123456789abcdef0123456789abcdef"
2669 "#,
2670 );
2671 let bridged = cfg
2672 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2673 .unwrap();
2674
2675 assert!(cfg.session_binding_secret.is_some());
2676 assert!(bridged.session_binding_secret.is_some());
2677 assert!(validate_server_config(&cfg).is_ok());
2678 }
2679
2680 #[test]
2681 fn session_binding_secret_short_toml_rejected() {
2682 let cfg = server_from_root_toml(
2683 r#"
2684 [server]
2685 session_binding_secret = "too-short"
2686 "#,
2687 );
2688
2689 let err = validate_server_config(&cfg).expect_err("short binding secret fails");
2690
2691 assert!(err.to_string().contains("at least 32 UTF-8 bytes"));
2692 }
2693
2694 #[test]
2695 fn tool_list_filtering_toml_roundtrip_and_bridge() {
2696 let cfg = server_from_root_toml(
2697 r"
2698 [server]
2699 tool_list_filtering = false
2700 ",
2701 );
2702 let bridged = cfg
2703 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2704 .unwrap();
2705
2706 assert!(!cfg.tool_list_filtering);
2707 assert!(!bridged.tool_list_filtering);
2708 assert!(ServerConfig::default().tool_list_filtering);
2709 assert!(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0").tool_list_filtering);
2710 }
2711
2712 #[test]
2713 fn t5_hsts_preload_from_toml_rejected_by_mcp_validate() {
2714 let cfg = server_from_root_toml(
2715 r#"
2716 [server.security_headers]
2717 strict_transport_security = "max-age=1; preload"
2718 "#,
2719 );
2720 let mcp = cfg
2721 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2722 .unwrap();
2723
2724 let err = mcp.validate().unwrap_err();
2725 let msg = err.to_string();
2726 assert!(msg.contains("preload"), "error must mention preload: {msg}");
2727 }
2728
2729 #[test]
2730 fn t6_bad_security_header_from_toml_rejected_by_mcp_validate() {
2731 let cfg = server_from_root_toml(
2732 r#"
2733 [server.security_headers]
2734 content_security_policy = "bad\nvalue"
2735 "#,
2736 );
2737 let mcp = cfg
2738 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2739 .unwrap();
2740
2741 let err = mcp.validate().unwrap_err();
2742 let msg = err.to_string();
2743 assert!(
2744 msg.contains("invalid security_headers.content_security_policy"),
2745 "error must name invalid header field: {msg}"
2746 );
2747 }
2748
2749 #[test]
2750 fn t7_zero_max_request_body_rejected_by_mcp_validate() {
2751 let cfg: ServerConfig = toml::from_str("max_request_body = 0").unwrap();
2752 let mcp = cfg
2753 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2754 .unwrap();
2755
2756 let err = mcp.validate().unwrap_err();
2757 assert!(
2758 err.to_string()
2759 .contains("max_request_body must be greater than zero")
2760 );
2761 }
2762
2763 #[test]
2764 fn t9_unknown_security_header_key_is_rejected() {
2765 let err = toml::from_str::<RootConfig>(
2766 r#"
2767 [server.security_headers]
2768 typo_content_security_policy = "default-src 'self'"
2769 "#,
2770 )
2771 .unwrap_err();
2772
2773 let msg = err.to_string();
2774 assert!(
2775 msg.contains("typo_content_security_policy"),
2776 "error must name the offending key: {msg}"
2777 );
2778 }
2779
2780 #[test]
2781 fn unknown_server_config_key_is_rejected() {
2782 let err = toml::from_str::<ServerConfig>(
2783 r#"
2784 tls_keypath = "/etc/certs/server.key"
2785 "#,
2786 )
2787 .unwrap_err();
2788
2789 let msg = err.to_string();
2790 assert!(
2791 msg.contains("tls_keypath"),
2792 "error must name the offending key: {msg}"
2793 );
2794 }
2795
2796 #[cfg(not(feature = "oauth"))]
2797 #[test]
2798 fn oauth_table_without_oauth_feature_is_rejected_with_actionable_message() {
2799 let server = toml::from_str::<ServerConfig>(
2804 r#"
2805 listen_port = 8080
2806
2807 [auth]
2808 enabled = true
2809
2810 [auth.oauth]
2811 issuer = "https://auth.example.com"
2812 "#,
2813 )
2814 .expect("[auth.oauth] must parse so validation can produce the real message");
2815
2816 let msg = validate_server_config(&server)
2817 .expect_err("auth.oauth without the oauth feature must be rejected")
2818 .to_string();
2819
2820 assert!(
2821 msg.contains("oauth") && msg.contains("--features oauth"),
2822 "error must name the missing cargo feature and how to fix it: {msg}"
2823 );
2824 }
2825
2826 #[test]
2827 fn all_twelve_security_header_keys_deserialize_from_server_toml() {
2828 let cfg = server_from_root_toml(
2829 r#"
2830 [server.security_headers]
2831 content_security_policy = "csp"
2832 strict_transport_security = "max-age=1"
2833 cross_origin_embedder_policy = "coep"
2834 cross_origin_resource_policy = "corp"
2835 cross_origin_opener_policy = "coop"
2836 permissions_policy = "permissions"
2837 referrer_policy = "referrer"
2838 x_frame_options = "frame"
2839 cache_control = "cache"
2840 x_content_type_options = "content-type"
2841 x_dns_prefetch_control = "dns"
2842 x_permitted_cross_domain_policies = "cross-domain"
2843 "#,
2844 );
2845
2846 let headers = cfg.security_headers;
2847 assert_eq!(headers.content_security_policy.as_deref(), Some("csp"));
2848 assert_eq!(
2849 headers.strict_transport_security.as_deref(),
2850 Some("max-age=1")
2851 );
2852 assert_eq!(
2853 headers.cross_origin_embedder_policy.as_deref(),
2854 Some("coep")
2855 );
2856 assert_eq!(
2857 headers.cross_origin_resource_policy.as_deref(),
2858 Some("corp")
2859 );
2860 assert_eq!(headers.cross_origin_opener_policy.as_deref(), Some("coop"));
2861 assert_eq!(headers.permissions_policy.as_deref(), Some("permissions"));
2862 assert_eq!(headers.referrer_policy.as_deref(), Some("referrer"));
2863 assert_eq!(headers.x_frame_options.as_deref(), Some("frame"));
2864 assert_eq!(headers.cache_control.as_deref(), Some("cache"));
2865 assert_eq!(
2866 headers.x_content_type_options.as_deref(),
2867 Some("content-type")
2868 );
2869 assert_eq!(headers.x_dns_prefetch_control.as_deref(), Some("dns"));
2870 assert_eq!(
2871 headers.x_permitted_cross_domain_policies.as_deref(),
2872 Some("cross-domain")
2873 );
2874 }
2875
2876 fn struct_pub_fields(marker: &str) -> Vec<String> {
2878 let source = include_str!("config.rs").replace("\r\n", "\n");
2879 let (_, after) = source
2880 .split_once(marker)
2881 .unwrap_or_else(|| panic!("struct start marker {marker:?} not found"));
2882 let (body, _) = after
2883 .split_once("\n}\n")
2884 .expect("struct end marker not found");
2885 body.lines()
2886 .filter_map(|line| {
2887 line.trim()
2888 .strip_prefix("pub ")
2889 .and_then(|rest| rest.split_once(':').map(|(name, _)| name.trim().to_owned()))
2890 })
2891 .collect()
2892 }
2893
2894 const ENV_OVERRIDE_EXCLUDED_FIELDS: &[&str] = &[
2902 "server.allowed_origins",
2904 "server.extra_route_rate_limit_exempt_paths",
2905 "server.trusted_proxies",
2906 "server.auth",
2907 "server.security_headers",
2908 "server.tls_handshake_timeout",
2911 "server.max_concurrent_tls_handshakes",
2912 "server.shutdown_timeout",
2913 "server.request_timeout",
2914 "server.max_request_body",
2915 "server.stdio_enabled",
2916 "server.tool_rate_limit",
2917 "server.tool_rate_limit_burst",
2918 "server.extra_route_rate_limit",
2919 "server.extra_route_rate_limit_burst",
2920 "server.trusted_forwarder_max_entries",
2921 "server.forwarded_header",
2922 "server.session_idle_timeout",
2923 "server.session_binding",
2924 "server.task_binding",
2928 "server.sse_keep_alive",
2929 "server.compression_enabled",
2930 "server.compression_min_size",
2931 "server.max_concurrent_requests",
2932 "server.admin_role",
2933 "server.tool_list_filtering",
2934 "server.expose_build_metadata",
2935 "observability.log_level",
2938 "observability.audit_log_path",
2939 "observability.log_request_headers",
2940 ];
2941
2942 #[test]
2943 fn every_config_field_is_env_overridable_or_excluded() {
2944 for (marker, prefix) in [
2945 ("pub struct ServerConfig {", "server"),
2946 ("pub struct ObservabilityConfig {", "observability"),
2947 ] {
2948 for field in struct_pub_fields(marker) {
2949 let target = format!("{prefix}.{field}");
2950 let overridable = ENV_OVERRIDE_SPECS
2951 .iter()
2952 .any(|spec| spec.target_field == target);
2953 let excluded = ENV_OVERRIDE_EXCLUDED_FIELDS.contains(&target.as_str());
2954 assert!(
2955 overridable || excluded,
2956 "`{target}` is neither env-overridable nor listed in \
2957 ENV_OVERRIDE_EXCLUDED_FIELDS; classify it deliberately"
2958 );
2959 assert!(
2960 !(overridable && excluded),
2961 "`{target}` is both env-overridable and excluded; remove one"
2962 );
2963 }
2964 }
2965 }
2966
2967 #[test]
2968 fn shared_invariants_report_a_fixed_precedence() {
2969 assert!(matches!(
2972 check_shared_config_invariants(true, false, true, false, true),
2973 Err(SharedConfigViolation::AdminRequiresAuth)
2974 ));
2975 assert!(matches!(
2977 check_shared_config_invariants(false, true, true, false, true),
2978 Err(SharedConfigViolation::TlsCertWithoutKey)
2979 ));
2980 assert!(matches!(
2981 check_shared_config_invariants(false, true, false, true, true),
2982 Err(SharedConfigViolation::TlsKeyWithoutCert)
2983 ));
2984 assert!(matches!(
2986 check_shared_config_invariants(false, true, false, false, true),
2987 Err(SharedConfigViolation::MtlsRequiresTls)
2988 ));
2989 check_shared_config_invariants(true, true, true, true, true)
2991 .unwrap_or_else(|_| panic!("admin+auth with full TLS and mTLS must be valid"));
2992 check_shared_config_invariants(false, false, false, false, false)
2993 .unwrap_or_else(|_| panic!("an empty config must be valid"));
2994 }
2995
2996 #[test]
2997 fn toml_validator_surfaces_the_shared_precedence() {
2998 let server = ServerConfig {
2999 admin_enabled: true,
3000 tls_cert_path: Some(PathBuf::from("/etc/certs/server.crt")),
3001 ..Default::default()
3002 };
3003
3004 let err = validate_server_config(&server)
3005 .expect_err("admin without auth must fail")
3006 .to_string();
3007 assert!(
3008 err.contains("admin_enabled=true requires auth"),
3009 "admin must be reported before the TLS pairing failure; got {err:?}"
3010 );
3011 }
3012
3013 #[test]
3014 fn server_config_debug_redacts_tls_key_path() {
3015 let cfg = ServerConfig {
3016 tls_cert_path: Some(PathBuf::from("/etc/certs/server.crt")),
3017 tls_key_path: Some(PathBuf::from("/etc/secrets/server.key")),
3018 ..Default::default()
3019 };
3020
3021 let rendered = format!("{cfg:?}");
3022 assert!(
3023 !rendered.contains("server.key") && !rendered.contains("/etc/secrets"),
3024 "the private-key path must never render; got {rendered}"
3025 );
3026 assert!(
3027 rendered.contains("tls_key_path: Some(\"[REDACTED]\")"),
3028 "presence must still be reported for diagnostics; got {rendered}"
3029 );
3030 assert!(
3031 rendered.contains("server.crt"),
3032 "the certificate path is not secret and must remain visible"
3033 );
3034 }
3035
3036 #[test]
3037 fn observability_config_debug_redacts_audit_log_path() {
3038 let cfg = ObservabilityConfig {
3039 audit_log_path: Some(PathBuf::from("/var/log/rmcp/audit.log")),
3040 ..Default::default()
3041 };
3042
3043 let rendered = format!("{cfg:?}");
3044 assert!(
3045 !rendered.contains("audit.log") && !rendered.contains("/var/log"),
3046 "the audit log location must never render; got {rendered}"
3047 );
3048 assert!(rendered.contains("audit_log_path: Some(\"[REDACTED]\")"));
3049 }
3050
3051 #[test]
3052 fn server_config_debug_lists_every_field() {
3053 let rendered = format!("{:?}", ServerConfig::default());
3054 for field in struct_pub_fields("pub struct ServerConfig {") {
3055 assert!(
3056 rendered.contains(&format!("{field}:")),
3057 "hand-written Debug omits `{field}`; add it (redacted if sensitive)"
3058 );
3059 }
3060 }
3061
3062 #[test]
3063 fn observability_config_debug_lists_every_field() {
3064 let rendered = format!("{:?}", ObservabilityConfig::default());
3065 for field in struct_pub_fields("pub struct ObservabilityConfig {") {
3066 assert!(
3067 rendered.contains(&format!("{field}:")),
3068 "hand-written Debug omits `{field}`; add it (redacted if sensitive)"
3069 );
3070 }
3071 }
3072
3073 #[test]
3074 fn t10_every_server_config_field_is_classified_for_bridge() {
3075 let source = include_str!("config.rs").replace("\r\n", "\n");
3076 let (_, after_struct_start) = source
3077 .split_once("pub struct ServerConfig {")
3078 .expect("ServerConfig struct start marker");
3079 let (struct_body, _) = after_struct_start
3080 .split_once("\n}\n\nimpl ServerConfig")
3081 .expect("ServerConfig struct end marker");
3082 let actual_fields: HashSet<&str> = struct_body
3083 .lines()
3084 .filter_map(|line| {
3085 line.trim()
3086 .strip_prefix("pub ")
3087 .and_then(|rest| rest.split_once(':').map(|(name, _)| name.trim()))
3088 })
3089 .collect();
3090 let bridged_fields: HashSet<&str> = SERVER_CONFIG_BRIDGED_FIELDS.iter().copied().collect();
3091 let not_bridged_fields: HashSet<&str> =
3092 SERVER_CONFIG_NOT_BRIDGED_FIELDS.iter().copied().collect();
3093 let runtime_only_fields: HashSet<&str> = MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS
3094 .iter()
3095 .copied()
3096 .collect();
3097 let classified_fields: HashSet<&str> =
3098 bridged_fields.union(¬_bridged_fields).copied().collect();
3099
3100 assert_eq!(actual_fields, classified_fields);
3101 assert!(bridged_fields.is_disjoint(¬_bridged_fields));
3102 assert!(runtime_only_fields.is_disjoint(&actual_fields));
3103 assert!(SERVER_CONFIG_NOT_BRIDGED_FIELDS.contains(&"stdio_enabled"));
3104 assert!(MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS.contains(&"rbac"));
3105 assert!(MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS.contains(&"metrics_bind"));
3106 }
3107
3108 #[test]
3109 fn replacement_semantics_clear_base_option_and_false_bool_fields() {
3110 let (_token, hash) = crate::auth::generate_api_key().unwrap();
3111 let base = McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
3112 .with_tls("/tmp/base.crt", "/tmp/base.key")
3113 .with_auth(crate::auth::AuthConfig::with_keys(vec![
3114 crate::auth::ApiKeyEntry::new("base-key", hash, "admin"),
3115 ]))
3116 .with_tool_rate_limit(10)
3117 .with_tool_rate_limit_burst(20)
3118 .with_extra_route_rate_limit(30)
3119 .with_extra_route_rate_limit_burst(40)
3120 .with_trusted_proxies(["127.0.0.1/32"])
3121 .with_forwarded_header(crate::transport::ForwardedHeaderMode::Forwarded)
3122 .with_public_url("https://base.example")
3123 .enable_compression(512)
3124 .with_max_concurrent_requests(99)
3125 .enable_admin("admin")
3126 .expose_build_metadata();
3127
3128 let actual = ServerConfig::default().apply_to_mcp_config(base).unwrap();
3129
3130 assert!(actual.tls_cert_path.is_none());
3131 assert!(actual.tls_key_path.is_none());
3132 assert!(actual.auth.is_none());
3133 assert!(actual.tool_rate_limit.is_none());
3134 assert!(actual.tool_rate_limit_burst.is_none());
3135 assert!(actual.extra_route_rate_limit.is_none());
3136 assert!(actual.extra_route_rate_limit_burst.is_none());
3137 assert_eq!(actual.key_eviction_policy, KeyEvictionPolicy::EvictLru);
3138 assert!(actual.forwarded_header.is_none());
3139 assert!(actual.public_url.is_none());
3140 assert!(!actual.compression_enabled);
3141 assert_eq!(actual.compression_min_size, 1024);
3142 assert!(actual.max_concurrent_requests.is_none());
3143 assert!(!actual.admin_enabled);
3144 assert_eq!(actual.admin_role, "admin");
3145 assert!(!actual.expose_build_metadata);
3146 }
3147
3148 #[test]
3149 fn partial_tls_toml_does_not_inherit_base_key() {
3150 let cfg = ServerConfig {
3151 tls_cert_path: Some("/tmp/toml.crt".into()),
3152 tls_key_path: None,
3153 ..ServerConfig::default()
3154 };
3155 let mcp = cfg
3156 .apply_to_mcp_config(
3157 McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
3158 .with_tls("/tmp/base.crt", "/tmp/base.key"),
3159 )
3160 .unwrap();
3161
3162 assert_eq!(mcp.tls_cert_path, Some(PathBuf::from("/tmp/toml.crt")));
3163 assert!(mcp.tls_key_path.is_none());
3164 let err = mcp.validate().unwrap_err();
3165 assert!(err.to_string().contains("tls_key_path"));
3166 }
3167
3168 #[test]
3169 fn partial_tls_toml_does_not_inherit_base_cert() {
3170 let cfg = ServerConfig {
3171 tls_cert_path: None,
3172 tls_key_path: Some("/tmp/toml.key".into()),
3173 ..ServerConfig::default()
3174 };
3175 let mcp = cfg
3176 .apply_to_mcp_config(
3177 McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
3178 .with_tls("/tmp/base.crt", "/tmp/base.key"),
3179 )
3180 .unwrap();
3181
3182 assert!(mcp.tls_cert_path.is_none());
3183 assert_eq!(mcp.tls_key_path, Some(PathBuf::from("/tmp/toml.key")));
3184 let err = mcp.validate().unwrap_err();
3185 assert!(err.to_string().contains("tls_cert_path"));
3186 }
3187
3188 #[test]
3189 fn t11_bridge_maps_bind_addr_and_request_timeout() {
3190 let cfg: ServerConfig = toml::from_str(
3191 r#"
3192 listen_addr = "127.0.0.2"
3193 listen_port = 9000
3194 request_timeout = "5s"
3195 "#,
3196 )
3197 .unwrap();
3198
3199 let mcp = cfg
3200 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
3201 .unwrap();
3202
3203 assert_eq!(mcp.bind_addr, "127.0.0.2:9000");
3204 assert_eq!(mcp.request_timeout, Duration::from_secs(5));
3205 }
3206
3207 #[test]
3208 fn key_eviction_policy_toml_defaults_and_overrides() {
3209 let default_cfg: ServerConfig = toml::from_str("").unwrap();
3210 assert_eq!(default_cfg.key_eviction_policy, KeyEvictionPolicy::EvictLru);
3211
3212 let reject_new: ServerConfig = toml::from_str(r#"key_eviction_policy = "reject_new""#)
3213 .expect("reject_new policy parses");
3214 assert_eq!(reject_new.key_eviction_policy, KeyEvictionPolicy::RejectNew);
3215 let bridged = reject_new
3216 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
3217 .unwrap();
3218 assert_eq!(bridged.key_eviction_policy, KeyEvictionPolicy::RejectNew);
3219 }
3220
3221 #[test]
3222 fn t12_bridge_rejects_invalid_request_timeout() {
3223 let cfg: ServerConfig = toml::from_str(r#"request_timeout = "not-a-duration""#).unwrap();
3224
3225 let Err(err) = cfg.apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
3226 else {
3227 panic!("invalid request_timeout must fail");
3228 };
3229
3230 assert!(err.to_string().contains("request_timeout"));
3231 }
3232
3233 #[test]
3234 fn observability_config_deserialize_defaults() {
3235 let cfg: ObservabilityConfig = toml::from_str("").unwrap();
3236 assert_eq!(cfg.log_level, "info,rmcp=warn");
3237 assert_eq!(cfg.log_format, "pretty");
3238 assert!(!cfg.log_request_headers);
3239 assert!(!cfg.metrics_enabled);
3240 assert!(!cfg.log_plaintext_oauth_tokens);
3241 assert!(!cfg.log_oauth_claim_values);
3242 assert!(!cfg.log_tool_call_arguments);
3243 }
3244
3245 #[test]
3246 fn observability_diagnostic_knobs_deserialize_true() {
3247 let cfg: ObservabilityConfig = toml::from_str(
3248 r"
3249 log_plaintext_oauth_tokens = true
3250 log_oauth_claim_values = true
3251 log_tool_call_arguments = true
3252 ",
3253 )
3254 .unwrap();
3255
3256 assert!(cfg.log_plaintext_oauth_tokens);
3257 assert!(cfg.log_oauth_claim_values);
3258 assert!(cfg.log_tool_call_arguments);
3259 }
3260
3261 fn all_env_vars() -> Vec<&'static str> {
3262 ENV_OVERRIDE_SPECS.iter().map(|spec| spec.env_var).collect()
3263 }
3264
3265 fn with_env_vars<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
3266 let mut all = all_env_vars()
3267 .into_iter()
3268 .map(|var| (var, None::<&str>))
3269 .collect::<Vec<_>>();
3270 all.extend(vars.iter().copied());
3271 temp_env::with_vars(all, f)
3272 }
3273
3274 #[test]
3275 fn e1_server_env_overrides_absent_keeps_defaults() {
3276 with_env_vars(&[], || {
3277 let mut cfg = ServerConfig::default();
3278 let report = cfg.apply_env_overrides().unwrap();
3279 assert!(report.is_empty());
3280 assert_eq!(cfg.listen_addr, "127.0.0.1");
3281 assert_eq!(cfg.listen_port, 8443);
3282 assert!(cfg.tls_cert_path.is_none());
3283 assert!(cfg.tls_key_path.is_none());
3284 assert!(cfg.public_url.is_none());
3285 assert!(!cfg.admin_enabled);
3286 assert!(cfg.auth.is_none());
3287 });
3288 }
3289
3290 #[test]
3291 fn e2_listen_port_env_override_applies_and_reports() {
3292 with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("9000"))], || {
3293 let mut cfg = ServerConfig::default();
3294 let report = cfg.apply_env_overrides().unwrap();
3295 assert_eq!(cfg.listen_port, 9000);
3296 assert_eq!(report.len(), 1);
3297 assert_eq!(report[0].env_var, SERVER_LISTEN_PORT_ENV);
3298 assert_eq!(report[0].target_field, "server.listen_port");
3299 assert_eq!(report[0].source, EnvOverrideSource::Env);
3300 assert_eq!(report[0].value.as_deref(), Some("9000"));
3301 });
3302 }
3303
3304 #[test]
3305 fn e3_bad_listen_port_env_fails_closed() {
3306 with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("not-a-number"))], || {
3307 let mut cfg = ServerConfig::default();
3308 let err = cfg.apply_env_overrides().unwrap_err();
3309 let msg = err.to_string();
3310 assert!(msg.contains(SERVER_LISTEN_PORT_ENV));
3311 assert!(msg.contains("u16"));
3312 });
3313 }
3314
3315 #[test]
3316 fn session_binding_secret_env_and_file_conflict_rejected() {
3317 with_env_vars(
3318 &[
3319 (
3320 SERVER_SESSION_BINDING_SECRET_ENV,
3321 Some("0123456789abcdef0123456789abcdef"),
3322 ),
3323 (SERVER_SESSION_BINDING_SECRET_FILE_ENV, Some("/tmp/secret")),
3324 ],
3325 || {
3326 let mut cfg = ServerConfig::default();
3327 let err = cfg.apply_env_overrides().unwrap_err();
3328 let msg = err.to_string();
3329 assert!(msg.contains(SERVER_SESSION_BINDING_SECRET_ENV));
3330 assert!(msg.contains(SERVER_SESSION_BINDING_SECRET_FILE_ENV));
3331 },
3332 );
3333 }
3334
3335 #[test]
3336 fn session_binding_secret_blank_rejected() {
3337 for value in ["", "\n", " "] {
3338 with_env_vars(&[(SERVER_SESSION_BINDING_SECRET_ENV, Some(value))], || {
3339 let mut cfg = ServerConfig::default();
3340 let err = cfg.apply_env_overrides().unwrap_err();
3341 assert!(err.to_string().contains(SERVER_SESSION_BINDING_SECRET_ENV));
3342 });
3343 }
3344 }
3345
3346 #[test]
3347 fn session_binding_secret_file_normalizes_newline_and_reports_file_source() {
3348 let path = std::env::temp_dir().join(format!(
3349 "rmcp-server-kit-session-binding-secret-{}.txt",
3350 std::time::SystemTime::now()
3351 .duration_since(std::time::UNIX_EPOCH)
3352 .expect("clock after epoch")
3353 .as_nanos()
3354 ));
3355 std::fs::write(&path, "0123456789abcdef0123456789abcdef\n").expect("write secret file");
3356 let path_string = path.to_string_lossy().to_string();
3357 let report = with_env_vars(
3358 &[(
3359 SERVER_SESSION_BINDING_SECRET_FILE_ENV,
3360 Some(path_string.as_str()),
3361 )],
3362 || {
3363 let mut cfg = ServerConfig::default();
3364 let report = cfg.apply_env_overrides().unwrap();
3365 assert_eq!(
3366 cfg.session_binding_secret
3367 .as_ref()
3368 .map(SecretString::expose_secret),
3369 Some("0123456789abcdef0123456789abcdef")
3370 );
3371 report
3372 },
3373 );
3374 std::fs::remove_file(path).expect("remove secret file");
3375
3376 assert_eq!(report.len(), 1);
3377 assert_eq!(report[0].env_var, SERVER_SESSION_BINDING_SECRET_FILE_ENV);
3378 assert_eq!(report[0].target_field, "server.session_binding_secret");
3379 assert_eq!(report[0].source, EnvOverrideSource::File);
3380 assert!(report[0].value.is_none());
3381 }
3382
3383 #[test]
3384 fn e4_oauth_env_without_auth_parent_fails_closed() {
3385 with_env_vars(&[(SERVER_OAUTH_ISSUER_ENV, Some("https://idp/"))], || {
3386 let mut cfg = ServerConfig::default();
3387 let err = cfg.apply_env_overrides().unwrap_err();
3388 let msg = err.to_string();
3389 assert!(msg.contains(SERVER_OAUTH_ISSUER_ENV));
3390 #[cfg(feature = "oauth")]
3391 assert!(msg.contains("[server.auth.oauth]"));
3392 #[cfg(not(feature = "oauth"))]
3393 assert!(msg.contains("oauth` feature"));
3394 });
3395 }
3396
3397 #[cfg(feature = "oauth")]
3398 #[test]
3399 fn e5_oauth_env_populates_declared_parent_and_validates() {
3400 with_env_vars(
3401 &[
3402 (SERVER_OAUTH_ISSUER_ENV, Some("https://idp.example/")),
3403 (SERVER_OAUTH_AUDIENCE_ENV, Some("mcp")),
3404 (
3405 SERVER_OAUTH_JWKS_URI_ENV,
3406 Some("https://idp.example/.well-known/jwks.json"),
3407 ),
3408 ],
3409 || {
3410 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3411 auth.oauth = Some(crate::oauth::OAuthConfig {
3412 role_claim: Some("roles".into()),
3413 ..crate::oauth::OAuthConfig::default()
3414 });
3415 let mut cfg = ServerConfig {
3416 auth: Some(auth),
3417 ..ServerConfig::default()
3418 };
3419
3420 let report = cfg.apply_env_overrides().unwrap();
3421 let oauth = cfg
3422 .auth
3423 .as_ref()
3424 .and_then(|auth| auth.oauth.as_ref())
3425 .unwrap();
3426 assert_eq!(oauth.issuer, "https://idp.example/");
3427 assert_eq!(oauth.audience, "mcp");
3428 assert_eq!(oauth.jwks_uri, "https://idp.example/.well-known/jwks.json");
3429 assert!(oauth.validate().is_ok());
3430 assert_eq!(report.len(), 3);
3431 },
3432 );
3433 }
3434
3435 #[cfg(feature = "oauth")]
3436 #[test]
3437 fn e5b_oauth_env_missing_audience_fails_validate() {
3438 with_env_vars(
3439 &[
3440 (SERVER_OAUTH_ISSUER_ENV, Some("https://idp.example/")),
3441 (
3442 SERVER_OAUTH_JWKS_URI_ENV,
3443 Some("https://idp.example/.well-known/jwks.json"),
3444 ),
3445 ],
3446 || {
3447 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3448 auth.oauth = Some(crate::oauth::OAuthConfig {
3449 role_claim: Some("roles".into()),
3450 ..crate::oauth::OAuthConfig::default()
3451 });
3452 let mut cfg = ServerConfig {
3453 auth: Some(auth),
3454 ..ServerConfig::default()
3455 };
3456
3457 cfg.apply_env_overrides().unwrap();
3458 let oauth = cfg
3459 .auth
3460 .as_ref()
3461 .and_then(|auth| auth.oauth.as_ref())
3462 .unwrap();
3463 let err = oauth.validate().unwrap_err();
3464 assert!(err.to_string().contains("oauth.audience must not be empty"));
3465 },
3466 );
3467 }
3468
3469 #[cfg(feature = "oauth")]
3470 #[test]
3471 fn e5c_oauth_proxy_env_applies_to_declared_proxy() {
3472 with_env_vars(
3473 &[(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, Some("true"))],
3474 || {
3475 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3476 auth.oauth = Some(crate::oauth::OAuthConfig {
3477 proxy: Some(
3478 crate::oauth::OAuthProxyConfig::builder(
3479 "https://idp.example/authorize",
3480 "https://idp.example/token",
3481 "mcp",
3482 )
3483 .build(),
3484 ),
3485 ..crate::oauth::OAuthConfig::default()
3486 });
3487 let mut cfg = ServerConfig {
3488 auth: Some(auth),
3489 ..ServerConfig::default()
3490 };
3491
3492 let report = cfg.apply_env_overrides().unwrap();
3493 let proxy = cfg
3494 .auth
3495 .as_ref()
3496 .and_then(|auth| auth.oauth.as_ref())
3497 .and_then(|oauth| oauth.proxy.as_ref())
3498 .unwrap();
3499 assert!(proxy.strip_resource_param);
3500 assert_eq!(report.len(), 1);
3501 assert_eq!(
3502 report[0].env_var,
3503 SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV
3504 );
3505 },
3506 );
3507 }
3508
3509 #[cfg(feature = "oauth")]
3510 #[test]
3511 fn e5d_oauth_proxy_env_without_declared_proxy_fails_closed() {
3512 with_env_vars(
3516 &[(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, Some("true"))],
3517 || {
3518 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3519 auth.oauth = Some(crate::oauth::OAuthConfig::default());
3520 let mut cfg = ServerConfig {
3521 auth: Some(auth),
3522 ..ServerConfig::default()
3523 };
3524
3525 let err = cfg.apply_env_overrides().unwrap_err();
3526 let msg = err.to_string();
3527 assert!(msg.contains(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV));
3528 assert!(msg.contains("[server.auth.oauth.proxy]"));
3529 },
3530 );
3531 }
3532
3533 #[cfg(feature = "oauth")]
3534 #[test]
3535 fn e5e_oauth_proxy_env_rejects_non_bool() {
3536 with_env_vars(
3537 &[(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, Some("maybe"))],
3538 || {
3539 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3540 auth.oauth = Some(crate::oauth::OAuthConfig {
3541 proxy: Some(
3542 crate::oauth::OAuthProxyConfig::builder(
3543 "https://idp.example/authorize",
3544 "https://idp.example/token",
3545 "mcp",
3546 )
3547 .build(),
3548 ),
3549 ..crate::oauth::OAuthConfig::default()
3550 });
3551 let mut cfg = ServerConfig {
3552 auth: Some(auth),
3553 ..ServerConfig::default()
3554 };
3555
3556 let msg = cfg.apply_env_overrides().unwrap_err().to_string();
3557 assert!(msg.contains(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV));
3558 assert!(msg.contains("bool"));
3559 },
3560 );
3561 }
3562
3563 #[cfg(feature = "oauth")]
3564 #[test]
3565 fn e5f_oauth_allowed_algorithms_env_parses_comma_separated_list() {
3566 with_env_vars(
3567 &[(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV, Some("RS256, ES384"))],
3568 || {
3569 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3570 auth.oauth = Some(crate::oauth::OAuthConfig::default());
3571 let mut cfg = ServerConfig {
3572 auth: Some(auth),
3573 ..ServerConfig::default()
3574 };
3575
3576 let report = cfg.apply_env_overrides().unwrap();
3577 let oauth = cfg
3578 .auth
3579 .as_ref()
3580 .and_then(|auth| auth.oauth.as_ref())
3581 .unwrap();
3582 assert_eq!(
3583 oauth.allowed_algorithms.as_deref(),
3584 Some(["RS256".to_owned(), "ES384".to_owned()].as_slice())
3585 );
3586 assert_eq!(report.len(), 1);
3587 },
3588 );
3589 }
3590
3591 #[cfg(feature = "oauth")]
3592 #[test]
3593 fn e5g_oauth_allowed_algorithms_env_rejects_non_narrowing_value() {
3594 with_env_vars(
3597 &[(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV, Some("HS256"))],
3598 || {
3599 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3600 auth.oauth = Some(crate::oauth::OAuthConfig::default());
3601 let mut cfg = ServerConfig {
3602 auth: Some(auth),
3603 ..ServerConfig::default()
3604 };
3605
3606 let msg = cfg.apply_env_overrides().unwrap_err().to_string();
3607 assert!(msg.contains(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV));
3608 assert!(msg.contains("unsupported algorithm"));
3609 },
3610 );
3611 }
3612
3613 #[test]
3614 fn e9_bad_observability_bool_env_fails_closed() {
3615 with_env_vars(
3616 &[(OBSERVABILITY_METRICS_ENABLED_ENV, Some("maybe"))],
3617 || {
3618 let mut cfg = ObservabilityConfig::default();
3619 let err = cfg.apply_env_overrides().unwrap_err();
3620 let msg = err.to_string();
3621 assert!(msg.contains(OBSERVABILITY_METRICS_ENABLED_ENV));
3622 assert!(msg.contains("bool"));
3623 },
3624 );
3625 }
3626
3627 #[test]
3628 fn observability_diagnostic_env_overrides_win_over_toml() {
3629 with_env_vars(
3630 &[
3631 (OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV, Some("false")),
3632 (OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV, Some("false")),
3633 (OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV, Some("false")),
3634 ],
3635 || {
3636 let mut cfg: ObservabilityConfig = toml::from_str(
3637 r"
3638 log_plaintext_oauth_tokens = true
3639 log_oauth_claim_values = true
3640 log_tool_call_arguments = true
3641 ",
3642 )
3643 .unwrap();
3644
3645 let report = cfg.apply_env_overrides().unwrap();
3646
3647 assert!(!cfg.log_plaintext_oauth_tokens);
3648 assert!(!cfg.log_oauth_claim_values);
3649 assert!(!cfg.log_tool_call_arguments);
3650 assert_eq!(report.len(), 3);
3651 assert!(report.iter().any(|entry| {
3652 entry.env_var == OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV
3653 && entry.target_field == "observability.log_plaintext_oauth_tokens"
3654 && entry.value.as_deref() == Some("false")
3655 }));
3656 assert!(report.iter().any(|entry| {
3657 entry.env_var == OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV
3658 && entry.target_field == "observability.log_oauth_claim_values"
3659 && entry.value.as_deref() == Some("false")
3660 }));
3661 assert!(report.iter().any(|entry| {
3662 entry.env_var == OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV
3663 && entry.target_field == "observability.log_tool_call_arguments"
3664 && entry.value.as_deref() == Some("false")
3665 }));
3666 },
3667 );
3668 }
3669
3670 #[test]
3671 fn bad_observability_diagnostic_bool_env_fails_closed() {
3672 for env_var in [
3673 OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV,
3674 OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV,
3675 OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV,
3676 ] {
3677 with_env_vars(&[(env_var, Some("notabool"))], || {
3678 let mut cfg = ObservabilityConfig::default();
3679 let err = cfg.apply_env_overrides().unwrap_err();
3680 let msg = err.to_string();
3681 assert!(msg.contains(env_var));
3682 assert!(msg.contains("bool"));
3683 });
3684 }
3685 }
3686
3687 #[test]
3688 fn e10_env_port_reaches_mcp_bridge() {
3689 with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("9100"))], || {
3690 let mut server: ServerConfig = toml::from_str(r#"listen_addr = "127.0.0.2""#).unwrap();
3691 server.apply_env_overrides().unwrap();
3692 let mcp = server
3693 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
3694 .unwrap();
3695 assert_eq!(mcp.bind_addr, "127.0.0.2:9100");
3696 assert!(mcp.validate().is_ok());
3697 });
3698 }
3699
3700 #[test]
3701 fn key_eviction_policy_env_override_applies_and_reports() {
3702 with_env_vars(
3703 &[(SERVER_KEY_EVICTION_POLICY_ENV, Some("reject_new"))],
3704 || {
3705 let mut cfg: ServerConfig = toml::from_str(r#"key_eviction_policy = "evict_lru""#)
3706 .expect("TOML policy parses");
3707 let report = cfg.apply_env_overrides().unwrap();
3708 assert_eq!(cfg.key_eviction_policy, KeyEvictionPolicy::RejectNew);
3709 assert_eq!(report.len(), 1);
3710 assert_eq!(report[0].env_var, SERVER_KEY_EVICTION_POLICY_ENV);
3711 assert_eq!(report[0].target_field, "server.key_eviction_policy");
3712 assert_eq!(report[0].value.as_deref(), Some("reject_new"));
3713 },
3714 );
3715 }
3716
3717 #[test]
3718 fn bad_key_eviction_policy_env_fails_closed() {
3719 with_env_vars(
3720 &[(SERVER_KEY_EVICTION_POLICY_ENV, Some("drop_random"))],
3721 || {
3722 let mut cfg = ServerConfig::default();
3723 let err = cfg.apply_env_overrides().unwrap_err();
3724 let msg = err.to_string();
3725 assert!(msg.contains(SERVER_KEY_EVICTION_POLICY_ENV));
3726 assert!(msg.contains("KeyEvictionPolicy"));
3727 },
3728 );
3729 }
3730
3731 #[cfg(unix)]
3732 #[test]
3733 fn non_unicode_env_value_fails_closed() {
3734 use std::{ffi::OsString, os::unix::ffi::OsStringExt};
3735
3736 let bad = OsString::from_vec(vec![0x66, 0x80, 0x6f]);
3737 temp_env::with_var(SERVER_LISTEN_ADDR_ENV, Some(bad), || {
3738 let mut cfg = ServerConfig::default();
3739 let err = cfg.apply_env_overrides().unwrap_err();
3740 let msg = err.to_string();
3741 assert!(msg.contains(SERVER_LISTEN_ADDR_ENV));
3742 assert!(msg.contains("UTF-8"));
3743 });
3744 }
3745
3746 #[cfg(not(feature = "oauth"))]
3747 #[test]
3748 fn e11_oauth_env_feature_off_fails_closed() {
3749 with_env_vars(&[(SERVER_OAUTH_ISSUER_ENV, Some("https://idp/"))], || {
3750 let mut cfg = ServerConfig {
3751 auth: Some(crate::auth::AuthConfig::with_keys(vec![])),
3752 ..ServerConfig::default()
3753 };
3754 let err = cfg.apply_env_overrides().unwrap_err();
3755 let msg = err.to_string();
3756 assert!(msg.contains(SERVER_OAUTH_ISSUER_ENV));
3757 assert!(msg.contains("oauth` feature"));
3758 });
3759 }
3760
3761 #[test]
3762 fn env_override_spec_matches_expected_set() {
3763 let vars = ENV_OVERRIDE_SPECS
3764 .iter()
3765 .map(|spec| {
3766 (
3767 spec.env_var,
3768 spec.target_field,
3769 spec.required_feature,
3770 spec.redacted,
3771 )
3772 })
3773 .collect::<Vec<_>>();
3774 assert_eq!(vars.len(), EXPECTED_ENV_OVERRIDE_SPECS.len());
3775 for expected in EXPECTED_ENV_OVERRIDE_SPECS {
3776 assert!(vars.contains(expected), "missing env spec {expected:?}");
3777 }
3778 assert_eq!(
3779 ENV_OVERRIDE_SPECS
3780 .iter()
3781 .filter(|spec| spec.value_type == "Path")
3782 .count(),
3783 4
3784 );
3785 }
3786
3787 #[derive(Debug)]
3788 struct GuideEnvRow {
3789 env_var: String,
3790 target_field: String,
3791 value_type: String,
3792 notes: String,
3793 }
3794
3795 #[derive(Debug)]
3796 struct GuideEnvAnnotation {
3797 env_var: String,
3798 key: String,
3799 }
3800
3801 const INLINE_ENV_ANNOTATION_EXEMPTIONS: &[&str] = &[
3805 SERVER_SESSION_BINDING_SECRET_FILE_ENV,
3806 RBAC_REDACTION_SALT_FILE_ENV,
3807 ];
3808
3809 type EnvSpecTuple = (&'static str, &'static str, Option<&'static str>, bool);
3810
3811 const EXPECTED_ENV_OVERRIDE_SPECS: &[EnvSpecTuple] = &[
3812 (SERVER_LISTEN_ADDR_ENV, "server.listen_addr", None, false),
3813 (SERVER_LISTEN_PORT_ENV, "server.listen_port", None, false),
3814 (SERVER_PUBLIC_URL_ENV, "server.public_url", None, false),
3815 (
3816 SERVER_TLS_CERT_PATH_ENV,
3817 "server.tls_cert_path",
3818 None,
3819 false,
3820 ),
3821 (SERVER_TLS_KEY_PATH_ENV, "server.tls_key_path", None, false),
3822 (
3823 SERVER_ADMIN_ENABLED_ENV,
3824 "server.admin_enabled",
3825 None,
3826 false,
3827 ),
3828 (
3829 SERVER_KEY_EVICTION_POLICY_ENV,
3830 "server.key_eviction_policy",
3831 None,
3832 false,
3833 ),
3834 (
3835 SERVER_SESSION_BINDING_SECRET_ENV,
3836 "server.session_binding_secret",
3837 None,
3838 true,
3839 ),
3840 (
3841 SERVER_SESSION_BINDING_SECRET_FILE_ENV,
3842 "server.session_binding_secret",
3843 None,
3844 true,
3845 ),
3846 (
3847 SERVER_OAUTH_ISSUER_ENV,
3848 "server.auth.oauth.issuer",
3849 Some("oauth"),
3850 false,
3851 ),
3852 (
3853 SERVER_OAUTH_AUDIENCE_ENV,
3854 "server.auth.oauth.audience",
3855 Some("oauth"),
3856 false,
3857 ),
3858 (
3859 SERVER_OAUTH_JWKS_URI_ENV,
3860 "server.auth.oauth.jwks_uri",
3861 Some("oauth"),
3862 false,
3863 ),
3864 (
3865 SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV,
3866 "server.auth.oauth.allowed_algorithms",
3867 Some("oauth"),
3868 false,
3869 ),
3870 (
3871 SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV,
3872 "server.auth.oauth.proxy.strip_resource_param",
3873 Some("oauth"),
3874 false,
3875 ),
3876 (
3877 OBSERVABILITY_LOG_FORMAT_ENV,
3878 "observability.log_format",
3879 None,
3880 false,
3881 ),
3882 (
3883 OBSERVABILITY_METRICS_ENABLED_ENV,
3884 "observability.metrics_enabled",
3885 None,
3886 false,
3887 ),
3888 (
3889 OBSERVABILITY_METRICS_BIND_ENV,
3890 "observability.metrics_bind",
3891 None,
3892 false,
3893 ),
3894 (
3895 OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV,
3896 "observability.log_plaintext_oauth_tokens",
3897 None,
3898 false,
3899 ),
3900 (
3901 OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV,
3902 "observability.log_oauth_claim_values",
3903 None,
3904 false,
3905 ),
3906 (
3907 OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV,
3908 "observability.log_tool_call_arguments",
3909 None,
3910 false,
3911 ),
3912 (
3913 OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV,
3914 "observability.log_upstream_error_bodies",
3915 None,
3916 false,
3917 ),
3918 (RBAC_REDACTION_SALT_ENV, "rbac.redaction_salt", None, true),
3919 (
3920 RBAC_REDACTION_SALT_FILE_ENV,
3921 "rbac.redaction_salt",
3922 None,
3923 true,
3924 ),
3925 ];
3926
3927 #[test]
3932 fn guide_env_override_table_matches_code_spec() {
3933 let rows = parse_guide_env_override_table();
3934 assert_eq!(
3935 rows.len(),
3936 ENV_OVERRIDE_SPECS.len(),
3937 "GUIDE env override table row count {} must match ENV_OVERRIDE_SPECS row count {}",
3938 rows.len(),
3939 ENV_OVERRIDE_SPECS.len()
3940 );
3941
3942 for (idx, (row, spec)) in rows.iter().zip(ENV_OVERRIDE_SPECS.iter()).enumerate() {
3943 assert_eq!(
3944 row.env_var, spec.env_var,
3945 "row {idx} env var mismatch: GUIDE has {:?}, code has {:?}",
3946 row.env_var, spec.env_var
3947 );
3948 assert_eq!(
3949 row.target_field, spec.target_field,
3950 "{} target mismatch: GUIDE has {:?}, code has {:?}",
3951 spec.env_var, row.target_field, spec.target_field
3952 );
3953 assert_eq!(
3954 row.value_type, spec.value_type,
3955 "{} type mismatch: GUIDE has {:?}, code has {:?}",
3956 spec.env_var, row.value_type, spec.value_type
3957 );
3958
3959 let notes_lower = row.notes.to_ascii_lowercase();
3960 if let Some(feature) = spec.required_feature {
3961 assert!(
3962 notes_lower.contains(feature),
3963 "{} notes must mention required feature {:?}; notes were {:?}",
3964 spec.env_var,
3965 feature,
3966 row.notes
3967 );
3968 } else {
3969 assert!(
3970 !notes_lower.contains("requires") && !notes_lower.contains("feature"),
3971 "{} notes must not mention a required feature; notes were {:?}",
3972 spec.env_var,
3973 row.notes
3974 );
3975 }
3976
3977 if spec.redacted {
3978 assert!(
3979 notes_lower.contains("secret") && notes_lower.contains("redacted"),
3980 "{} notes must indicate secret/redacted handling; notes were {:?}",
3981 spec.env_var,
3982 row.notes
3983 );
3984 } else {
3985 assert!(
3986 !notes_lower.contains("secret") && !notes_lower.contains("redacted"),
3987 "{} notes must not indicate secret/redacted handling; notes were {:?}",
3988 spec.env_var,
3989 row.notes
3990 );
3991 }
3992 }
3993
3994 let spec_vars = ENV_OVERRIDE_SPECS
3995 .iter()
3996 .map(|spec| spec.env_var)
3997 .collect::<HashSet<_>>();
3998 for env_var in parse_rmcp_env_constants_from_config_source() {
3999 assert!(
4000 spec_vars.contains(env_var.as_str()),
4001 "env const {env_var} is defined in src/config.rs but missing from ENV_OVERRIDE_SPECS"
4002 );
4003 }
4004 }
4005
4006 #[test]
4012 fn guide_toml_example_env_annotations_match_code_spec() {
4013 let annotations = parse_guide_toml_env_annotations();
4014 assert!(
4015 !annotations.is_empty(),
4016 "canonical TOML example contains no `# env:` annotations"
4017 );
4018
4019 let spec_by_var = ENV_OVERRIDE_SPECS
4020 .iter()
4021 .map(|spec| (spec.env_var, spec))
4022 .collect::<std::collections::HashMap<_, _>>();
4023 let mut seen = HashSet::new();
4024
4025 for annotation in &annotations {
4026 let Some(spec) = spec_by_var.get(annotation.env_var.as_str()) else {
4027 panic!(
4028 "GUIDE inline env annotation {:?} is not present in ENV_OVERRIDE_SPECS",
4029 annotation.env_var
4030 );
4031 };
4032 assert!(
4033 seen.insert(annotation.env_var.as_str()),
4034 "GUIDE inline env annotation {:?} appears more than once",
4035 annotation.env_var
4036 );
4037 let expected_key = spec
4038 .target_field
4039 .rsplit('.')
4040 .next()
4041 .expect("target_field has at least one segment");
4042 assert_eq!(
4043 annotation.key, expected_key,
4044 "{} inline annotation is attached to TOML key {:?}, but code spec target {:?} ends in {:?}",
4045 annotation.env_var, annotation.key, spec.target_field, expected_key
4046 );
4047 }
4048
4049 let expected_count = ENV_OVERRIDE_SPECS.len() - INLINE_ENV_ANNOTATION_EXEMPTIONS.len();
4050 assert_eq!(
4051 annotations.len(),
4052 expected_count,
4053 "GUIDE inline env annotation count {} must equal ENV_OVERRIDE_SPECS count {} minus exemptions {:?}",
4054 annotations.len(),
4055 ENV_OVERRIDE_SPECS.len(),
4056 INLINE_ENV_ANNOTATION_EXEMPTIONS
4057 );
4058
4059 for spec in ENV_OVERRIDE_SPECS {
4060 if INLINE_ENV_ANNOTATION_EXEMPTIONS.contains(&spec.env_var) {
4061 assert!(
4062 !seen.contains(spec.env_var),
4063 "{} is deliberately exempt from inline annotation but was annotated",
4064 spec.env_var
4065 );
4066 } else {
4067 assert!(
4068 seen.contains(spec.env_var),
4069 "{} is missing from GUIDE canonical TOML inline `# env:` annotations",
4070 spec.env_var
4071 );
4072 }
4073 }
4074 }
4075
4076 fn guide_markdown() -> &'static str {
4077 include_str!("../docs/GUIDE.md")
4078 }
4079
4080 fn parse_guide_env_override_table() -> Vec<GuideEnvRow> {
4081 let guide = guide_markdown();
4082 let (_, after_begin) = guide
4083 .split_once("<!-- BEGIN ENV_OVERRIDE_TABLE -->")
4084 .expect("docs/GUIDE.md is missing <!-- BEGIN ENV_OVERRIDE_TABLE --> marker");
4085 let (table, _) = after_begin
4086 .split_once("<!-- END ENV_OVERRIDE_TABLE -->")
4087 .expect("docs/GUIDE.md is missing <!-- END ENV_OVERRIDE_TABLE --> marker");
4088 let rows = table
4089 .lines()
4090 .filter_map(parse_guide_env_override_row)
4091 .collect::<Vec<_>>();
4092 assert!(
4093 !rows.is_empty(),
4094 "docs/GUIDE.md ENV_OVERRIDE_TABLE markers were found but no data rows parsed"
4095 );
4096 rows
4097 }
4098
4099 fn parse_guide_env_override_row(line: &str) -> Option<GuideEnvRow> {
4100 let trimmed = line.trim();
4101 if !trimmed.starts_with('|')
4102 || trimmed.contains("|---")
4103 || trimmed.contains("Environment variable")
4104 {
4105 return None;
4106 }
4107 let cells = trimmed
4108 .trim_matches('|')
4109 .split('|')
4110 .map(str::trim)
4111 .collect::<Vec<_>>();
4112 assert_eq!(
4113 cells.len(),
4114 4,
4115 "env override GUIDE table row must have four cells, got {} in line {:?}",
4116 cells.len(),
4117 line
4118 );
4119 Some(GuideEnvRow {
4120 env_var: unwrap_markdown_code(cells[0], "Environment variable", line),
4121 target_field: unwrap_markdown_code(cells[1], "Target TOML path", line),
4122 value_type: cells[2].trim().to_owned(),
4123 notes: cells[3].trim().to_owned(),
4124 })
4125 }
4126
4127 fn unwrap_markdown_code(cell: &str, column: &str, row: &str) -> String {
4128 let inner = cell
4129 .strip_prefix('`')
4130 .and_then(|value| value.strip_suffix('`'))
4131 .unwrap_or_else(|| panic!("{column} cell must be backtick-wrapped in row {row:?}"));
4132 inner.trim().to_owned()
4133 }
4134
4135 fn parse_guide_toml_env_annotations() -> Vec<GuideEnvAnnotation> {
4136 let guide = guide_markdown();
4137 let (_, after_heading) = guide
4138 .split_once("### Complete TOML configuration reference")
4139 .expect("docs/GUIDE.md is missing canonical TOML configuration heading");
4140 let (section, _) = after_heading
4141 .split_once("### Bridging TOML config to `McpServerConfig`")
4142 .expect("docs/GUIDE.md is missing bridge heading after canonical TOML example");
4143 let (_, after_fence_start) = section
4144 .split_once("```toml")
4145 .expect("canonical TOML section is missing opening ```toml fence");
4146 let (toml_block, _) = after_fence_start
4147 .split_once("```")
4148 .expect("canonical TOML section is missing closing code fence");
4149
4150 toml_block
4151 .lines()
4152 .filter_map(parse_guide_toml_env_annotation_line)
4153 .collect()
4154 }
4155
4156 fn parse_guide_toml_env_annotation_line(line: &str) -> Option<GuideEnvAnnotation> {
4157 let (before_marker, after_marker) = line.split_once("# env: ")?;
4158 let env_var = after_marker
4159 .split_whitespace()
4160 .next()
4161 .unwrap_or_else(|| panic!("missing env var after `# env:` in line {line:?}"));
4162 let key_source = before_marker
4163 .trim_end()
4164 .strip_prefix('#')
4165 .map_or_else(|| before_marker.trim_end(), str::trim);
4166 let key = key_source
4167 .split_once('=')
4168 .unwrap_or_else(|| panic!("missing TOML key before `# env:` in line {line:?}"))
4169 .0
4170 .trim();
4171
4172 Some(GuideEnvAnnotation {
4173 env_var: env_var.to_owned(),
4174 key: key.to_owned(),
4175 })
4176 }
4177
4178 fn parse_rmcp_env_constants_from_config_source() -> Vec<String> {
4179 include_str!("config.rs")
4180 .lines()
4181 .filter(|line| {
4182 let trimmed = line.trim_start();
4183 trimmed.starts_with("pub(crate) const ")
4184 && trimmed
4185 .strip_prefix("pub(crate) const ")
4186 .and_then(|rest| rest.split_once(':'))
4187 .is_some_and(|(name, _)| name.ends_with("_ENV"))
4188 && trimmed.contains("RMCP_SERVER_KIT__")
4189 })
4190 .filter_map(|line| {
4191 line.split_once('"')
4192 .and_then(|(_, rest)| rest.split_once('"'))
4193 .map(|(value, _)| value.to_owned())
4194 })
4195 .collect()
4196 }
4197}