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