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