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 let Some(auth) = &server.auth {
1361 auth.validate_api_key_names()?;
1362 }
1363
1364 if server.max_concurrent_requests == Some(0) {
1365 return Err(RmcpServerKitError::Config(
1366 "max_concurrent_requests must be nonzero when set".into(),
1367 ));
1368 }
1369
1370 if server.extra_route_rate_limit == Some(0) {
1371 return Err(RmcpServerKitError::Config(
1372 "server.extra_route_rate_limit must be greater than zero".into(),
1373 ));
1374 }
1375
1376 validate_rate_limit_knobs(server)?;
1377 validate_mtls_knobs(server)?;
1378 validate_trusted_forwarder_config(server)?;
1379
1380 if server.admin_enabled && server.admin_role.trim().is_empty() {
1381 return Err(RmcpServerKitError::Config(
1382 "admin_role must not be empty".into(),
1383 ));
1384 }
1385
1386 if let Some(secret) = &server.session_binding_secret {
1387 crate::session_binding::validate_configured_secret(
1388 "server.session_binding_secret",
1389 secret.expose_secret(),
1390 )?;
1391 }
1392
1393 for (field, value) in [
1394 ("server.shutdown_timeout", server.shutdown_timeout.as_str()),
1395 ("server.request_timeout", server.request_timeout.as_str()),
1396 (
1397 "server.session_idle_timeout",
1398 server.session_idle_timeout.as_str(),
1399 ),
1400 ("server.sse_keep_alive", server.sse_keep_alive.as_str()),
1401 (
1402 "server.tls_handshake_timeout",
1403 server.tls_handshake_timeout.as_str(),
1404 ),
1405 ] {
1406 if humantime::parse_duration(value).is_err() {
1407 return Err(RmcpServerKitError::Config(format!(
1408 "invalid duration for {field}: {value:?}"
1409 )));
1410 }
1411 }
1412
1413 if humantime::parse_duration(&server.tls_handshake_timeout).is_ok_and(|d| d == Duration::ZERO) {
1417 return Err(RmcpServerKitError::Config(
1418 "server.tls_handshake_timeout must be greater than zero".into(),
1419 ));
1420 }
1421
1422 if server.max_concurrent_tls_handshakes == 0 {
1426 return Err(RmcpServerKitError::Config(
1427 "server.max_concurrent_tls_handshakes must be greater than zero".into(),
1428 ));
1429 }
1430
1431 Ok(())
1432}
1433
1434fn validate_rate_limit_knobs(server: &ServerConfig) -> crate::error::Result<()> {
1438 use crate::error::RmcpServerKitError;
1439
1440 if server.tool_rate_limit_burst == Some(0) {
1441 return Err(RmcpServerKitError::Config(
1442 "server.tool_rate_limit_burst must be greater than zero".into(),
1443 ));
1444 }
1445 if server.extra_route_rate_limit_burst == Some(0) {
1446 return Err(RmcpServerKitError::Config(
1447 "server.extra_route_rate_limit_burst must be greater than zero".into(),
1448 ));
1449 }
1450 if server.tool_rate_limit_burst.is_some() && server.tool_rate_limit.is_none() {
1451 return Err(RmcpServerKitError::Config(
1452 "server.tool_rate_limit_burst requires server.tool_rate_limit".into(),
1453 ));
1454 }
1455 if server.extra_route_rate_limit_burst.is_some() && server.extra_route_rate_limit.is_none() {
1456 return Err(RmcpServerKitError::Config(
1457 "server.extra_route_rate_limit_burst requires server.extra_route_rate_limit".into(),
1458 ));
1459 }
1460 if !server.extra_route_rate_limit_exempt_paths.is_empty()
1461 && server.extra_route_rate_limit.is_none()
1462 {
1463 return Err(RmcpServerKitError::Config(
1464 "server.extra_route_rate_limit_exempt_paths requires server.extra_route_rate_limit"
1465 .into(),
1466 ));
1467 }
1468 for path in &server.extra_route_rate_limit_exempt_paths {
1469 if path.is_empty() || !path.starts_with('/') {
1470 return Err(RmcpServerKitError::Config(format!(
1471 "server.extra_route_rate_limit_exempt_paths entries must be non-empty and start with '/': {path:?}"
1472 )));
1473 }
1474 }
1475 if let Some(auth) = server.auth.as_ref() {
1476 auth.check_oauth_feature()?;
1477 }
1478 if let Some(rl) = server.auth.as_ref().and_then(|a| a.rate_limit.as_ref()) {
1479 (rl.max_attempts_per_minute != 0).ok_or_else(|| {
1480 RmcpServerKitError::Config(
1481 "auth.rate_limit.max_attempts_per_minute must be nonzero".into(),
1482 )
1483 })?;
1484 if rl.burst == Some(0) {
1485 return Err(RmcpServerKitError::Config(
1486 "auth.rate_limit.burst must be greater than zero".into(),
1487 ));
1488 }
1489 if rl.pre_auth_burst == Some(0) {
1490 return Err(RmcpServerKitError::Config(
1491 "auth.rate_limit.pre_auth_burst must be greater than zero".into(),
1492 ));
1493 }
1494 (rl.pre_auth_max_per_minute != Some(0)).ok_or_else(|| {
1499 RmcpServerKitError::Config(
1500 "auth.rate_limit.pre_auth_max_per_minute must be nonzero when set".into(),
1501 )
1502 })?;
1503 }
1504 Ok(())
1505}
1506
1507fn validate_mtls_knobs(server: &ServerConfig) -> crate::error::Result<()> {
1508 use crate::error::RmcpServerKitError;
1509
1510 if let Some(mtls) = server.auth.as_ref().and_then(|a| a.mtls.as_ref()) {
1511 (mtls.crl_max_concurrent_fetches != 0).ok_or_else(|| {
1512 RmcpServerKitError::Config(
1513 "auth.mtls.crl_max_concurrent_fetches must be nonzero".into(),
1514 )
1515 })?;
1516 (mtls.crl_discovery_rate_per_min != 0).ok_or_else(|| {
1517 RmcpServerKitError::Config(
1518 "auth.mtls.crl_discovery_rate_per_min must be nonzero".into(),
1519 )
1520 })?;
1521 (mtls.crl_max_host_semaphores != 0).ok_or_else(|| {
1522 RmcpServerKitError::Config("auth.mtls.crl_max_host_semaphores must be nonzero".into())
1523 })?;
1524 (mtls.crl_max_seen_urls != 0).ok_or_else(|| {
1525 RmcpServerKitError::Config("auth.mtls.crl_max_seen_urls must be nonzero".into())
1526 })?;
1527 (mtls.crl_max_cache_entries != 0).ok_or_else(|| {
1528 RmcpServerKitError::Config("auth.mtls.crl_max_cache_entries must be nonzero".into())
1529 })?;
1530 (mtls.crl_max_response_bytes != 0).ok_or_else(|| {
1535 RmcpServerKitError::Config("auth.mtls.crl_max_response_bytes must be nonzero".into())
1536 })?;
1537 }
1538 Ok(())
1539}
1540
1541fn validate_trusted_forwarder_config(server: &ServerConfig) -> crate::error::Result<()> {
1544 use crate::error::RmcpServerKitError;
1545
1546 for entry in &server.trusted_proxies {
1547 crate::transport::validate_trusted_proxy_entry(entry)
1548 .map_err(RmcpServerKitError::Config)?;
1549 }
1550 if server.forwarded_header.is_some() && server.trusted_proxies.is_empty() {
1551 return Err(RmcpServerKitError::Config(
1552 "server.forwarded_header requires server.trusted_proxies to be nonempty".into(),
1553 ));
1554 }
1555 if server.trusted_forwarder_max_entries == 0
1556 || server.trusted_forwarder_max_entries > crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES
1557 {
1558 return Err(RmcpServerKitError::Config(format!(
1559 "server.trusted_forwarder_max_entries must be in 1..={}, got {}",
1560 crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES,
1561 server.trusted_forwarder_max_entries
1562 )));
1563 }
1564 Ok(())
1565}
1566
1567pub fn validate_observability_config(obs: &ObservabilityConfig) -> crate::error::Result<()> {
1573 use tracing_subscriber::EnvFilter;
1574
1575 use crate::error::RmcpServerKitError;
1576
1577 if EnvFilter::try_new(&obs.log_level).is_err() {
1578 return Err(RmcpServerKitError::Config(format!(
1579 "invalid log_level: {:?} (expected a valid tracing filter directive, e.g. \"info\", \"debug,hyper=warn\")",
1580 obs.log_level
1581 )));
1582 }
1583 let valid_formats = ["json", "pretty", "text"];
1584 if !valid_formats.contains(&obs.log_format.as_str()) {
1585 return Err(RmcpServerKitError::Config(format!(
1586 "invalid log_format: {:?} (expected one of: {valid_formats:?})",
1587 obs.log_format
1588 )));
1589 }
1590
1591 Ok(())
1592}
1593
1594fn default_listen_addr() -> String {
1597 "127.0.0.1".into()
1598}
1599fn default_listen_port() -> u16 {
1600 8443
1601}
1602fn default_shutdown_timeout() -> String {
1603 "30s".into()
1604}
1605fn default_request_timeout() -> String {
1606 "120s".into()
1607}
1608const fn default_max_request_body() -> usize {
1609 1024 * 1024
1610}
1611const fn default_trusted_forwarder_max_entries() -> usize {
1612 crate::forwarded::MAX_SCANNED_ENTRIES
1613}
1614const fn default_expose_build_metadata() -> bool {
1615 false
1616}
1617const fn default_tool_list_filtering() -> bool {
1618 true
1619}
1620fn default_security_headers() -> SecurityHeadersConfig {
1621 SecurityHeadersConfig::default()
1622}
1623fn default_log_level() -> String {
1624 "info,rmcp=warn".into()
1625}
1626fn default_log_format() -> String {
1627 "pretty".into()
1628}
1629fn default_metrics_bind() -> String {
1630 "127.0.0.1:9090".into()
1631}
1632fn default_session_idle_timeout() -> String {
1633 "20m".into()
1634}
1635const fn default_session_binding() -> bool {
1636 true
1637}
1638fn default_tls_handshake_timeout() -> String {
1639 "10s".into()
1640}
1641const fn default_max_concurrent_tls_handshakes() -> usize {
1642 256
1643}
1644fn default_admin_role() -> String {
1645 "admin".into()
1646}
1647fn default_compression_min_size() -> u16 {
1648 1024
1649}
1650fn default_sse_keep_alive() -> String {
1651 "15s".into()
1652}
1653
1654#[cfg(test)]
1655mod tests {
1656 #![allow(
1657 clippy::unwrap_used,
1658 clippy::expect_used,
1659 clippy::panic,
1660 clippy::indexing_slicing,
1661 clippy::unwrap_in_result,
1662 clippy::print_stdout,
1663 clippy::print_stderr,
1664 deprecated,
1665 reason = "test-only relaxations; production code uses ? and tracing"
1666 )]
1667 use std::{collections::HashSet, time::Duration};
1668
1669 use super::*;
1670 use crate::transport::McpServerConfig;
1671
1672 #[derive(Debug, Deserialize)]
1673 #[serde(deny_unknown_fields)]
1674 struct RootConfig {
1675 server: ServerConfig,
1676 }
1677
1678 fn server_from_root_toml(toml: &str) -> ServerConfig {
1679 toml::from_str::<RootConfig>(toml).unwrap().server
1680 }
1681
1682 #[test]
1685 fn server_config_defaults() {
1686 let cfg = ServerConfig::default();
1687 assert_eq!(cfg.listen_addr, "127.0.0.1");
1688 assert_eq!(cfg.listen_port, 8443);
1689 assert!(cfg.tls_cert_path.is_none());
1690 assert!(cfg.tls_key_path.is_none());
1691 assert_eq!(cfg.shutdown_timeout, "30s");
1692 assert_eq!(cfg.request_timeout, "120s");
1693 assert!(cfg.allowed_origins.is_empty());
1694 assert!(!cfg.stdio_enabled);
1695 assert!(cfg.tool_rate_limit.is_none());
1696 assert_eq!(cfg.key_eviction_policy, KeyEvictionPolicy::EvictLru);
1697 assert_eq!(cfg.session_idle_timeout, "20m");
1698 assert_eq!(cfg.sse_keep_alive, "15s");
1699 assert!(cfg.public_url.is_none());
1700 assert!(cfg.tool_list_filtering);
1701 }
1702
1703 #[test]
1704 fn observability_config_defaults() {
1705 let cfg = ObservabilityConfig::default();
1706 assert_eq!(cfg.log_level, "info,rmcp=warn");
1707 assert_eq!(cfg.log_format, "pretty");
1708 assert!(cfg.audit_log_path.is_none());
1709 assert!(!cfg.log_request_headers);
1710 assert!(!cfg.metrics_enabled);
1711 assert_eq!(cfg.metrics_bind, "127.0.0.1:9090");
1712 assert!(!cfg.log_plaintext_oauth_tokens);
1713 assert!(!cfg.log_oauth_claim_values);
1714 assert!(!cfg.log_tool_call_arguments);
1715 }
1716
1717 #[test]
1720 fn valid_server_config_passes() {
1721 let cfg = ServerConfig::default();
1722 assert!(validate_server_config(&cfg).is_ok());
1723 }
1724
1725 #[test]
1726 fn validate_server_config_rejects_blank_api_key_name() {
1727 let blank = ServerConfig {
1728 auth: Some(crate::auth::AuthConfig::with_keys(vec![
1729 crate::auth::ApiKeyEntry::new("", "hash", "viewer"),
1730 ])),
1731 ..ServerConfig::default()
1732 };
1733 let err = validate_server_config(&blank).unwrap_err().to_string();
1734 assert!(
1735 err.contains("api_keys[0]"),
1736 "must name offending index: {err}"
1737 );
1738
1739 let whitespace = ServerConfig {
1740 auth: Some(crate::auth::AuthConfig::with_keys(vec![
1741 crate::auth::ApiKeyEntry::new(" ", "hash", "viewer"),
1742 ])),
1743 ..ServerConfig::default()
1744 };
1745 assert!(validate_server_config(&whitespace).is_err());
1746
1747 let ok = ServerConfig {
1748 auth: Some(crate::auth::AuthConfig::with_keys(vec![
1749 crate::auth::ApiKeyEntry::new("viewer-key", "hash", "viewer"),
1750 ])),
1751 ..ServerConfig::default()
1752 };
1753 assert!(validate_server_config(&ok).is_ok());
1754 }
1755
1756 #[test]
1757 fn admin_auth_check_precedes_tls_and_mtls_like_the_builder() {
1758 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
1762 auth.enabled = false;
1763 auth.mtls = Some(valid_mtls_config());
1764 let cfg = ServerConfig {
1765 admin_enabled: true,
1766 auth: Some(auth),
1767 tls_cert_path: None,
1768 tls_key_path: None,
1769 ..ServerConfig::default()
1770 };
1771 let err = validate_server_config(&cfg).unwrap_err().to_string();
1772 assert!(
1773 err.contains("admin_enabled=true requires auth"),
1774 "admin/auth must fire before TLS and mTLS checks; got {err}"
1775 );
1776 }
1777
1778 fn classify_shared_check(err: RmcpServerKitError) -> SharedCheck {
1779 match err {
1780 RmcpServerKitError::Config(msg) => {
1781 if msg.contains("admin_enabled=true requires auth") {
1782 SharedCheck::AdminAuth
1783 } else if msg.contains("must both be set or both omitted")
1784 || msg.contains("tls_cert_path is set but tls_key_path is missing")
1785 || msg.contains("tls_key_path is set but tls_cert_path is missing")
1786 {
1787 SharedCheck::TlsPairing
1788 } else if msg.contains("auth.mtls requires TLS") {
1789 SharedCheck::MtlsRequiresTls
1790 } else {
1791 panic!("unclassified shared-check config error: {msg}");
1792 }
1793 }
1794 RmcpServerKitError::Auth(msg) => {
1795 panic!("expected Config error, got Auth({msg})");
1796 }
1797 RmcpServerKitError::Rbac(msg) => {
1798 panic!("expected Config error, got Rbac({msg})");
1799 }
1800 RmcpServerKitError::RateLimited(msg) => {
1801 panic!("expected Config error, got RateLimited({msg})");
1802 }
1803 RmcpServerKitError::RateLimitedFor {
1804 message,
1805 retry_after,
1806 } => {
1807 panic!("expected Config error, got RateLimitedFor({message}, {retry_after:?})");
1808 }
1809 RmcpServerKitError::Io(error) => {
1810 panic!("expected Config error, got Io({error})");
1811 }
1812 RmcpServerKitError::Json(error) => {
1813 panic!("expected Config error, got Json({error})");
1814 }
1815 RmcpServerKitError::Toml(error) => {
1816 panic!("expected Config error, got Toml({error})");
1817 }
1818 RmcpServerKitError::Tls(msg) => {
1819 panic!("expected Config error, got Tls({msg})");
1820 }
1821 RmcpServerKitError::Startup(msg) => {
1822 panic!("expected Config error, got Startup({msg})");
1823 }
1824 RmcpServerKitError::Internal(msg) => {
1825 panic!("expected Config error, got Internal({msg})");
1826 }
1827 #[cfg(feature = "metrics")]
1828 RmcpServerKitError::Metrics(msg) => {
1829 panic!("expected Config error, got Metrics({msg})");
1830 }
1831 }
1832 }
1833
1834 #[derive(Debug, Clone, Copy)]
1835 enum AdminSetting {
1836 Valid,
1837 EnabledWithDisabledAuth,
1838 }
1839
1840 #[derive(Debug, Clone, Copy)]
1841 enum TlsSetting {
1842 Absent,
1843 CertOnly,
1844 KeyOnly,
1845 }
1846
1847 #[derive(Debug, Clone, Copy)]
1848 enum MtlsSetting {
1849 Absent,
1850 WithoutTls,
1851 WithoutTlsAndInvalidCapacity,
1852 }
1853
1854 #[derive(Debug)]
1855 struct SharedCheckCase {
1856 name: &'static str,
1857 admin: AdminSetting,
1858 tls_variants: &'static [TlsSetting],
1859 mtls: MtlsSetting,
1860 expected: SharedCheck,
1861 }
1862
1863 const ABSENT_TLS: &[TlsSetting] = &[TlsSetting::Absent];
1864 const BOTH_PARTIAL_TLS_DIRECTIONS: &[TlsSetting] = &[TlsSetting::CertOnly, TlsSetting::KeyOnly];
1865
1866 #[test]
1867 fn toml_and_builder_validators_report_the_expected_shared_check_order() {
1868 let cases = [
1869 SharedCheckCase {
1870 name: "case 1: admin/auth dependency only",
1871 admin: AdminSetting::EnabledWithDisabledAuth,
1872 tls_variants: ABSENT_TLS,
1873 mtls: MtlsSetting::Absent,
1874 expected: SharedCheck::AdminAuth,
1875 },
1876 SharedCheckCase {
1877 name: "case 2: TLS pairing only",
1878 admin: AdminSetting::Valid,
1879 tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1880 mtls: MtlsSetting::Absent,
1881 expected: SharedCheck::TlsPairing,
1882 },
1883 SharedCheckCase {
1884 name: "case 3: mTLS without TLS only",
1885 admin: AdminSetting::Valid,
1886 tls_variants: ABSENT_TLS,
1887 mtls: MtlsSetting::WithoutTls,
1888 expected: SharedCheck::MtlsRequiresTls,
1889 },
1890 SharedCheckCase {
1891 name: "case 4: admin/auth dependency before TLS pairing",
1892 admin: AdminSetting::EnabledWithDisabledAuth,
1893 tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1894 mtls: MtlsSetting::Absent,
1895 expected: SharedCheck::AdminAuth,
1896 },
1897 SharedCheckCase {
1898 name: "case 5: admin/auth dependency before mTLS without TLS",
1899 admin: AdminSetting::EnabledWithDisabledAuth,
1900 tls_variants: ABSENT_TLS,
1901 mtls: MtlsSetting::WithoutTls,
1902 expected: SharedCheck::AdminAuth,
1903 },
1904 SharedCheckCase {
1905 name: "case 6: TLS pairing before mTLS without TLS",
1906 admin: AdminSetting::Valid,
1907 tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1908 mtls: MtlsSetting::WithoutTls,
1909 expected: SharedCheck::TlsPairing,
1910 },
1911 SharedCheckCase {
1912 name: "case 7: admin/auth dependency before TLS pairing and mTLS without TLS",
1913 admin: AdminSetting::EnabledWithDisabledAuth,
1914 tls_variants: BOTH_PARTIAL_TLS_DIRECTIONS,
1915 mtls: MtlsSetting::WithoutTls,
1916 expected: SharedCheck::AdminAuth,
1917 },
1918 SharedCheckCase {
1919 name: "case 8: mTLS without TLS before mTLS capacity knobs",
1920 admin: AdminSetting::Valid,
1921 tls_variants: ABSENT_TLS,
1922 mtls: MtlsSetting::WithoutTlsAndInvalidCapacity,
1923 expected: SharedCheck::MtlsRequiresTls,
1924 },
1925 ];
1926
1927 for case in cases {
1928 for tls in case.tls_variants {
1929 let config = shared_check_config(case.admin, *tls, case.mtls);
1930
1931 let toml_class = classify_toml_validator_error(&config);
1932 assert_eq!(
1933 toml_class, case.expected,
1934 "{} with {:?} must fail TOML validation at {:?}",
1935 case.name, tls, case.expected
1936 );
1937
1938 let builder_class = classify_builder_validator_error(&config);
1939 assert_eq!(
1940 builder_class, case.expected,
1941 "{} with {:?} must fail builder validation at {:?}",
1942 case.name, tls, case.expected
1943 );
1944 }
1945 }
1946 }
1947
1948 fn classify_toml_validator_error(config: &ServerConfig) -> SharedCheck {
1949 let err = validate_server_config(config).expect_err("config must fail TOML validation");
1950 classify_shared_check(err)
1951 }
1952
1953 fn classify_builder_validator_error(config: &ServerConfig) -> SharedCheck {
1954 let builder_config = config
1955 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:1", "t", "0.0.0"))
1956 .expect("valid durations must bridge into McpServerConfig");
1957 let err = builder_config
1958 .validate()
1959 .expect_err("config must fail builder validation");
1960 classify_shared_check(err)
1961 }
1962
1963 fn shared_check_config(
1964 admin: AdminSetting,
1965 tls: TlsSetting,
1966 mtls: MtlsSetting,
1967 ) -> ServerConfig {
1968 let mut config = ServerConfig::default();
1969 apply_admin_setting(&mut config, admin);
1970 apply_tls_setting(&mut config, tls);
1971 apply_mtls_setting(&mut config, admin, mtls);
1972 config
1973 }
1974
1975 fn apply_admin_setting(config: &mut ServerConfig, admin: AdminSetting) {
1976 match admin {
1977 AdminSetting::Valid => {}
1978 AdminSetting::EnabledWithDisabledAuth => {
1979 config.admin_enabled = true;
1980 let auth = config
1981 .auth
1982 .get_or_insert_with(|| crate::auth::AuthConfig::with_keys(vec![]));
1983 auth.enabled = false;
1984 }
1985 }
1986 }
1987
1988 fn apply_tls_setting(config: &mut ServerConfig, tls: TlsSetting) {
1989 match tls {
1990 TlsSetting::Absent => {}
1991 TlsSetting::CertOnly => {
1992 config.tls_cert_path = Some("/tmp/cert.pem".into());
1993 }
1994 TlsSetting::KeyOnly => {
1995 config.tls_key_path = Some("/tmp/key.pem".into());
1996 }
1997 }
1998 }
1999
2000 fn apply_mtls_setting(config: &mut ServerConfig, admin: AdminSetting, mtls: MtlsSetting) {
2001 match mtls {
2002 MtlsSetting::Absent => {}
2003 MtlsSetting::WithoutTls => {
2004 let enabled = matches!(admin, AdminSetting::Valid);
2005 let auth = config
2006 .auth
2007 .get_or_insert_with(|| crate::auth::AuthConfig::with_keys(vec![]));
2008 auth.enabled = enabled;
2009 auth.mtls = Some(valid_mtls_config());
2010 }
2011 MtlsSetting::WithoutTlsAndInvalidCapacity => {
2012 let auth = config
2013 .auth
2014 .get_or_insert_with(|| crate::auth::AuthConfig::with_keys(vec![]));
2015 auth.enabled = true;
2016 let mut mtls_config = valid_mtls_config();
2017 mtls_config.crl_max_concurrent_fetches = 0;
2018 auth.mtls = Some(mtls_config);
2019 }
2020 }
2021 }
2022
2023 #[test]
2024 fn mtls_without_tls_rejected() {
2025 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
2026 auth.mtls = Some(valid_mtls_config());
2027 let cfg = ServerConfig {
2028 auth: Some(auth),
2029 tls_cert_path: None,
2030 tls_key_path: None,
2031 ..ServerConfig::default()
2032 };
2033 let err = validate_server_config(&cfg).unwrap_err();
2034 let msg = err.to_string();
2035 assert!(
2036 msg.contains("tls_cert_path") && msg.contains("tls_key_path"),
2037 "{msg}"
2038 );
2039 }
2040
2041 #[test]
2042 fn mtls_with_tls_accepted() {
2043 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
2044 auth.mtls = Some(valid_mtls_config());
2045 let cfg = ServerConfig {
2046 auth: Some(auth),
2047 tls_cert_path: Some("cert.pem".into()),
2048 tls_key_path: Some("key.pem".into()),
2049 ..ServerConfig::default()
2050 };
2051 assert!(validate_server_config(&cfg).is_ok());
2052 }
2053
2054 #[test]
2055 fn zero_port_rejected() {
2056 let cfg = ServerConfig {
2057 listen_port: 0,
2058 ..ServerConfig::default()
2059 };
2060 let err = validate_server_config(&cfg).unwrap_err();
2061 assert!(err.to_string().contains("listen_port"));
2062 }
2063
2064 #[test]
2065 fn zero_extra_route_rate_limit_rejected() {
2066 let cfg = ServerConfig {
2067 extra_route_rate_limit: Some(0),
2068 ..ServerConfig::default()
2069 };
2070 let err = validate_server_config(&cfg).unwrap_err();
2071 assert!(err.to_string().contains("extra_route_rate_limit"));
2072 }
2073
2074 #[test]
2075 fn zero_burst_knobs_rejected() {
2076 let cfg = ServerConfig {
2077 tool_rate_limit: Some(10),
2078 tool_rate_limit_burst: Some(0),
2079 ..ServerConfig::default()
2080 };
2081 let err = validate_server_config(&cfg).unwrap_err();
2082 assert!(err.to_string().contains("tool_rate_limit_burst"));
2083
2084 let cfg = ServerConfig {
2085 extra_route_rate_limit: Some(10),
2086 extra_route_rate_limit_burst: Some(0),
2087 ..ServerConfig::default()
2088 };
2089 let err = validate_server_config(&cfg).unwrap_err();
2090 assert!(err.to_string().contains("extra_route_rate_limit_burst"));
2091 }
2092
2093 #[test]
2094 fn orphan_burst_knobs_rejected() {
2095 let cfg = ServerConfig {
2096 tool_rate_limit_burst: Some(5),
2097 ..ServerConfig::default()
2098 };
2099 let err = validate_server_config(&cfg).unwrap_err();
2100 assert!(err.to_string().contains("requires server.tool_rate_limit"));
2101
2102 let cfg = ServerConfig {
2103 extra_route_rate_limit_burst: Some(5),
2104 ..ServerConfig::default()
2105 };
2106 let err = validate_server_config(&cfg).unwrap_err();
2107 assert!(
2108 err.to_string()
2109 .contains("requires server.extra_route_rate_limit")
2110 );
2111 }
2112
2113 #[test]
2114 fn exempt_paths_toml_roundtrip_and_validation() {
2115 let cfg: ServerConfig = toml::from_str(
2116 r#"
2117 extra_route_rate_limit = 60
2118 extra_route_rate_limit_exempt_paths = ["/.well-known/oauth-authorization-server"]
2119 "#,
2120 )
2121 .unwrap();
2122 assert_eq!(
2123 cfg.extra_route_rate_limit_exempt_paths,
2124 vec!["/.well-known/oauth-authorization-server".to_owned()]
2125 );
2126 assert!(validate_server_config(&cfg).is_ok());
2127 }
2128
2129 #[test]
2130 fn orphan_exempt_paths_rejected() {
2131 let cfg = ServerConfig {
2132 extra_route_rate_limit_exempt_paths: vec!["/ok".into()],
2133 ..ServerConfig::default()
2134 };
2135 let err = validate_server_config(&cfg).unwrap_err();
2136 assert!(
2137 err.to_string()
2138 .contains("requires server.extra_route_rate_limit")
2139 );
2140 }
2141
2142 #[test]
2143 fn malformed_exempt_paths_rejected() {
2144 for bad in ["", "no-slash"] {
2145 let cfg = ServerConfig {
2146 extra_route_rate_limit: Some(10),
2147 extra_route_rate_limit_exempt_paths: vec![bad.into()],
2148 ..ServerConfig::default()
2149 };
2150 let err = validate_server_config(&cfg).unwrap_err();
2151 assert!(
2152 err.to_string()
2153 .contains("must be non-empty and start with '/'"),
2154 "entry {bad:?}: {err}"
2155 );
2156 }
2157 }
2158
2159 #[test]
2160 fn bad_trusted_proxy_entry_rejected() {
2161 let cfg = ServerConfig {
2162 trusted_proxies: vec!["not-a-cidr".into()],
2163 ..ServerConfig::default()
2164 };
2165 let err = validate_server_config(&cfg).unwrap_err();
2166 assert!(err.to_string().contains("trusted_proxies"));
2167 }
2168
2169 #[test]
2170 fn zero_prefix_trusted_proxy_rejected() {
2171 for entry in ["0.0.0.0/0", "::/0"] {
2172 let cfg = ServerConfig {
2173 trusted_proxies: vec![entry.into()],
2174 ..ServerConfig::default()
2175 };
2176 let err = validate_server_config(&cfg).unwrap_err();
2177 assert!(
2178 err.to_string().contains("prefix length 0"),
2179 "entry {entry:?}: {err}"
2180 );
2181 }
2182 }
2183
2184 #[test]
2185 fn toml_trusted_forwarder_max_entries_bounds_are_enforced() {
2186 let parse = |v: usize| -> crate::error::Result<()> {
2187 let cfg: ServerConfig =
2188 toml::from_str(&format!("trusted_forwarder_max_entries = {v}")).unwrap();
2189 validate_server_config(&cfg)
2190 };
2191 assert!(parse(0).is_err());
2192 assert!(parse(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES + 1).is_err());
2193 assert!(parse(1).is_ok());
2194 assert!(parse(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES).is_ok());
2195 }
2196
2197 #[test]
2198 fn toml_trusted_forwarder_max_entries_defaults_and_bridges() {
2199 let cfg: ServerConfig = toml::from_str("").unwrap();
2200 assert_eq!(
2201 cfg.trusted_forwarder_max_entries,
2202 crate::forwarded::MAX_SCANNED_ENTRIES
2203 );
2204 let base = crate::transport::McpServerConfig::new("127.0.0.1:8080", "t", "0");
2205 let src: ServerConfig =
2206 toml::from_str("trusted_forwarder_max_entries = 32").expect("parses");
2207 let bridged = src.apply_to_mcp_config(base).expect("bridges");
2208 assert_eq!(bridged.trusted_forwarder_max_entries, 32);
2209 }
2210
2211 #[test]
2212 fn cidr_and_bare_ip_proxy_entries_accepted() {
2213 let cfg = ServerConfig {
2214 trusted_proxies: vec!["10.0.0.0/8".into(), "192.0.2.1".into()],
2215 ..ServerConfig::default()
2216 };
2217 assert!(validate_server_config(&cfg).is_ok());
2218 }
2219
2220 #[test]
2221 fn forwarded_header_without_proxies_rejected() {
2222 let cfg = ServerConfig {
2223 forwarded_header: Some(crate::transport::ForwardedHeaderMode::Forwarded),
2224 ..ServerConfig::default()
2225 };
2226 let err = validate_server_config(&cfg).unwrap_err();
2227 assert!(err.to_string().contains("requires server.trusted_proxies"));
2228 }
2229
2230 #[test]
2231 fn zero_auth_bursts_rejected() {
2232 let auth = crate::auth::AuthConfig::with_keys(vec![])
2233 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_burst(0));
2234 let cfg = ServerConfig {
2235 auth: Some(auth),
2236 ..ServerConfig::default()
2237 };
2238 let err = validate_server_config(&cfg).unwrap_err();
2239 assert!(err.to_string().contains("rate_limit.burst"));
2240
2241 let auth = crate::auth::AuthConfig::with_keys(vec![])
2242 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(0));
2243 let cfg = ServerConfig {
2244 auth: Some(auth),
2245 ..ServerConfig::default()
2246 };
2247 let err = validate_server_config(&cfg).unwrap_err();
2248 assert!(err.to_string().contains("pre_auth_burst"));
2249 }
2250
2251 fn valid_mtls_config() -> crate::auth::MtlsConfig {
2252 crate::auth::MtlsConfig {
2253 ca_cert_path: "memory://ca.pem".into(),
2254 required: true,
2255 default_role: "viewer".into(),
2256 crl_enabled: true,
2257 crl_refresh_interval: None,
2258 crl_fetch_timeout: Duration::from_secs(30),
2259 crl_stale_grace: Duration::from_secs(24 * 60 * 60),
2260 crl_deny_on_unavailable: false,
2261 crl_end_entity_only: false,
2262 crl_allow_http: true,
2263 crl_enforce_expiration: true,
2264 crl_max_concurrent_fetches: 4,
2265 crl_max_response_bytes: 5 * 1024 * 1024,
2266 crl_discovery_rate_per_min: 60,
2267 crl_max_host_semaphores: 1024,
2268 crl_max_seen_urls: 4096,
2269 crl_max_cache_entries: 1024,
2270 }
2271 }
2272
2273 fn assert_config_nonzero_error(err: RmcpServerKitError, field: &str) {
2274 let RmcpServerKitError::Config(msg) = err else {
2275 panic!("expected Config error for {field}");
2276 };
2277 assert!(
2278 msg.contains(field) && msg.contains("must be nonzero"),
2279 "error must name {field} and say must be nonzero; got {msg:?}"
2280 );
2281 }
2282
2283 fn server_config_with_mtls(mtls: crate::auth::MtlsConfig) -> ServerConfig {
2284 ServerConfig {
2285 auth: Some(crate::auth::AuthConfig {
2286 enabled: true,
2287 api_keys: Vec::new(),
2288 mtls: Some(mtls),
2289 rate_limit: None,
2290 #[cfg(feature = "oauth")]
2291 oauth: None,
2292 #[cfg(not(feature = "oauth"))]
2293 oauth: None,
2294 }),
2295 tls_cert_path: Some("cert.pem".into()),
2299 tls_key_path: Some("key.pem".into()),
2300 ..ServerConfig::default()
2301 }
2302 }
2303
2304 #[test]
2305 fn rejects_zero_crl_max_cache_entries() {
2306 let mut mtls = valid_mtls_config();
2307 mtls.crl_max_cache_entries = 0;
2308 let err = validate_server_config(&server_config_with_mtls(mtls))
2309 .expect_err("zero crl_max_cache_entries must be rejected");
2310 assert_config_nonzero_error(err, "auth.mtls.crl_max_cache_entries");
2311 }
2312
2313 #[test]
2314 fn rejects_zero_crl_max_concurrent_fetches() {
2315 let mut mtls = valid_mtls_config();
2316 mtls.crl_max_concurrent_fetches = 0;
2317 let err = validate_server_config(&server_config_with_mtls(mtls))
2318 .expect_err("zero crl_max_concurrent_fetches must be rejected");
2319 assert_config_nonzero_error(err, "auth.mtls.crl_max_concurrent_fetches");
2320 }
2321
2322 #[test]
2323 fn rejects_zero_crl_discovery_rate_per_min() {
2324 let mut mtls = valid_mtls_config();
2325 mtls.crl_discovery_rate_per_min = 0;
2326 let err = validate_server_config(&server_config_with_mtls(mtls))
2327 .expect_err("zero crl_discovery_rate_per_min must be rejected");
2328 assert_config_nonzero_error(err, "auth.mtls.crl_discovery_rate_per_min");
2329 }
2330
2331 #[test]
2332 fn rejects_zero_crl_max_host_semaphores() {
2333 let mut mtls = valid_mtls_config();
2334 mtls.crl_max_host_semaphores = 0;
2335 let err = validate_server_config(&server_config_with_mtls(mtls))
2336 .expect_err("zero crl_max_host_semaphores must be rejected");
2337 assert_config_nonzero_error(err, "auth.mtls.crl_max_host_semaphores");
2338 }
2339
2340 #[test]
2341 fn rejects_zero_crl_max_seen_urls() {
2342 let mut mtls = valid_mtls_config();
2343 mtls.crl_max_seen_urls = 0;
2344 let err = validate_server_config(&server_config_with_mtls(mtls))
2345 .expect_err("zero crl_max_seen_urls must be rejected");
2346 assert_config_nonzero_error(err, "auth.mtls.crl_max_seen_urls");
2347 }
2348
2349 #[test]
2350 fn rejects_zero_crl_max_response_bytes() {
2351 let mut mtls = valid_mtls_config();
2352 mtls.crl_max_response_bytes = 0;
2353 let err = validate_server_config(&server_config_with_mtls(mtls))
2354 .expect_err("zero crl_max_response_bytes must be rejected");
2355 assert_config_nonzero_error(err, "auth.mtls.crl_max_response_bytes");
2356 }
2357
2358 #[test]
2359 fn rejects_zero_auth_rate_limit() {
2360 let auth = crate::auth::AuthConfig::with_keys(vec![])
2361 .with_rate_limit(crate::auth::RateLimitConfig::new(0));
2362 let cfg = ServerConfig {
2363 auth: Some(auth),
2364 ..ServerConfig::default()
2365 };
2366 let err = validate_server_config(&cfg).expect_err("zero auth rate limit must be rejected");
2367 assert_config_nonzero_error(err, "auth.rate_limit.max_attempts_per_minute");
2368 }
2369
2370 #[test]
2371 fn rejects_zero_pre_auth_max_per_minute() {
2372 let mut rl = crate::auth::RateLimitConfig::new(30);
2376 rl.pre_auth_max_per_minute = Some(0);
2377 let cfg = ServerConfig {
2378 auth: Some(crate::auth::AuthConfig::with_keys(vec![]).with_rate_limit(rl)),
2379 ..ServerConfig::default()
2380 };
2381 let err = validate_server_config(&cfg)
2382 .expect_err("zero pre_auth_max_per_minute must be rejected");
2383 assert_config_nonzero_error(err, "auth.rate_limit.pre_auth_max_per_minute");
2384 }
2385
2386 #[test]
2387 fn tls_cert_without_key_rejected() {
2388 let cfg = ServerConfig {
2389 tls_cert_path: Some("/tmp/cert.pem".into()),
2390 ..ServerConfig::default()
2391 };
2392 let err = validate_server_config(&cfg).unwrap_err();
2393 assert!(err.to_string().contains("tls_cert_path"));
2394 }
2395
2396 #[test]
2397 fn tls_key_without_cert_rejected() {
2398 let cfg = ServerConfig {
2399 tls_key_path: Some("/tmp/key.pem".into()),
2400 ..ServerConfig::default()
2401 };
2402 let err = validate_server_config(&cfg).unwrap_err();
2403 assert!(err.to_string().contains("tls_cert_path"));
2404 }
2405
2406 #[test]
2407 fn tls_both_set_passes() {
2408 let cfg = ServerConfig {
2409 tls_cert_path: Some("/tmp/cert.pem".into()),
2410 tls_key_path: Some("/tmp/key.pem".into()),
2411 ..ServerConfig::default()
2412 };
2413 assert!(validate_server_config(&cfg).is_ok());
2414 }
2415
2416 #[test]
2417 fn invalid_tls_handshake_timeout_rejected() {
2418 let cfg = ServerConfig {
2419 tls_handshake_timeout: "not-a-duration".into(),
2420 ..ServerConfig::default()
2421 };
2422 let err = validate_server_config(&cfg).unwrap_err();
2423 assert!(err.to_string().contains("tls_handshake_timeout"));
2424 }
2425
2426 #[test]
2427 fn zero_tls_handshake_timeout_rejected() {
2428 let cfg = ServerConfig {
2429 tls_handshake_timeout: "0s".into(),
2430 ..ServerConfig::default()
2431 };
2432 let err = validate_server_config(&cfg).unwrap_err();
2433 assert!(err.to_string().contains("tls_handshake_timeout"));
2434 }
2435
2436 #[test]
2437 fn zero_max_concurrent_tls_handshakes_rejected() {
2438 let cfg = ServerConfig {
2439 max_concurrent_tls_handshakes: 0,
2440 ..ServerConfig::default()
2441 };
2442 let err = validate_server_config(&cfg).unwrap_err();
2443 assert!(err.to_string().contains("max_concurrent_tls_handshakes"));
2444 }
2445
2446 #[test]
2447 fn invalid_shutdown_timeout_rejected() {
2448 let cfg = ServerConfig {
2449 shutdown_timeout: "not-a-duration".into(),
2450 ..ServerConfig::default()
2451 };
2452 let err = validate_server_config(&cfg).unwrap_err();
2453 assert!(err.to_string().contains("shutdown_timeout"));
2454 }
2455
2456 #[test]
2457 fn invalid_request_timeout_rejected() {
2458 let cfg = ServerConfig {
2459 request_timeout: "xyz".into(),
2460 ..ServerConfig::default()
2461 };
2462 let err = validate_server_config(&cfg).unwrap_err();
2463 assert!(err.to_string().contains("request_timeout"));
2464 }
2465
2466 #[test]
2469 fn valid_observability_config_passes() {
2470 let cfg = ObservabilityConfig::default();
2471 assert!(validate_observability_config(&cfg).is_ok());
2472 }
2473
2474 #[test]
2475 fn invalid_log_level_rejected() {
2476 let cfg = ObservabilityConfig {
2477 log_level: "[invalid".into(),
2478 ..ObservabilityConfig::default()
2479 };
2480 let err = validate_observability_config(&cfg).unwrap_err();
2481 assert!(err.to_string().contains("log_level"));
2482 }
2483
2484 #[test]
2485 fn invalid_log_format_rejected() {
2486 let cfg = ObservabilityConfig {
2487 log_format: "yaml".into(),
2488 ..ObservabilityConfig::default()
2489 };
2490 let err = validate_observability_config(&cfg).unwrap_err();
2491 assert!(err.to_string().contains("log_format"));
2492 }
2493
2494 #[test]
2495 fn all_valid_log_levels_accepted() {
2496 for level in &[
2497 "trace",
2498 "debug",
2499 "info",
2500 "warn",
2501 "error",
2502 "info,rmcp=warn",
2503 "debug,hyper=error",
2504 ] {
2505 let cfg = ObservabilityConfig {
2506 log_level: (*level).into(),
2507 ..ObservabilityConfig::default()
2508 };
2509 assert!(
2510 validate_observability_config(&cfg).is_ok(),
2511 "level {level} should be valid"
2512 );
2513 }
2514 }
2515
2516 #[test]
2517 fn all_log_formats_accepted() {
2518 for fmt in &["json", "pretty", "text"] {
2519 let cfg = ObservabilityConfig {
2520 log_format: (*fmt).into(),
2521 ..ObservabilityConfig::default()
2522 };
2523 assert!(
2524 validate_observability_config(&cfg).is_ok(),
2525 "format {fmt} should be valid"
2526 );
2527 }
2528 }
2529
2530 #[test]
2533 fn server_config_deserialize_defaults() {
2534 let cfg: ServerConfig = toml::from_str("").unwrap();
2535 assert_eq!(cfg.listen_port, 8443);
2536 assert_eq!(cfg.listen_addr, "127.0.0.1");
2537 assert_eq!(cfg.tls_handshake_timeout, "10s");
2538 assert_eq!(cfg.max_concurrent_tls_handshakes, 256);
2539 }
2540
2541 #[test]
2542 fn t1_existing_server_example_deserializes_with_new_defaults() {
2543 let server = server_from_root_toml(
2544 r#"
2545 [server]
2546 listen_addr = "0.0.0.0"
2547 listen_port = 8443
2548 tls_cert_path = "/etc/certs/server.crt"
2549 tls_key_path = "/etc/certs/server.key"
2550 shutdown_timeout = "30s"
2551 request_timeout = "120s"
2552 allowed_origins = ["http://localhost:3000", "https://myapp.example.com"]
2553 tool_rate_limit = 120
2554 "#,
2555 );
2556
2557 assert_eq!(server.max_request_body, 1024 * 1024);
2558 assert!(!server.expose_build_metadata);
2559 assert_eq!(server.security_headers, SecurityHeadersConfig::default());
2560 }
2561
2562 #[test]
2563 fn t2_default_bridge_is_no_op_for_mcp_defaults() {
2564 let actual = ServerConfig::default()
2565 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2566 .unwrap();
2567 let expected = McpServerConfig::new("127.0.0.1:8443", "t", "0.0.0");
2568
2569 assert_default_bridge_core_fields(&actual, &expected);
2570 assert_default_bridge_limit_fields(&actual, &expected);
2571 assert_default_bridge_metadata_fields(&actual, &expected);
2572 }
2573
2574 fn assert_default_bridge_core_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
2575 assert_eq!(actual.bind_addr, expected.bind_addr);
2576 assert_eq!(actual.tls_cert_path, expected.tls_cert_path);
2577 assert_eq!(actual.tls_key_path, expected.tls_key_path);
2578 assert!(actual.auth.is_none());
2579 assert_eq!(actual.allowed_origins, expected.allowed_origins);
2580 assert_eq!(actual.trusted_proxies, expected.trusted_proxies);
2581 assert_eq!(actual.forwarded_header, expected.forwarded_header);
2582 assert_eq!(actual.public_url, expected.public_url);
2583 assert_eq!(actual.name, expected.name);
2584 assert_eq!(actual.version, expected.version);
2585 }
2586
2587 fn assert_default_bridge_limit_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
2588 assert_eq!(actual.tool_rate_limit, expected.tool_rate_limit);
2589 assert_eq!(actual.tool_rate_limit_burst, expected.tool_rate_limit_burst);
2590 assert_eq!(
2591 actual.extra_route_rate_limit,
2592 expected.extra_route_rate_limit
2593 );
2594 assert_eq!(
2595 actual.extra_route_rate_limit_burst,
2596 expected.extra_route_rate_limit_burst
2597 );
2598 assert_eq!(
2599 actual.extra_route_rate_limit_exempt_paths,
2600 expected.extra_route_rate_limit_exempt_paths
2601 );
2602 assert_eq!(actual.key_eviction_policy, expected.key_eviction_policy);
2603 assert_eq!(actual.max_request_body, expected.max_request_body);
2604 assert_eq!(
2605 actual.max_concurrent_requests,
2606 expected.max_concurrent_requests
2607 );
2608 }
2609
2610 fn assert_default_bridge_metadata_fields(actual: &McpServerConfig, expected: &McpServerConfig) {
2611 assert_eq!(actual.session_idle_timeout, expected.session_idle_timeout);
2612 assert_eq!(actual.session_binding, expected.session_binding);
2613 assert_eq!(actual.sse_keep_alive, expected.sse_keep_alive);
2614 assert_eq!(actual.request_timeout, expected.request_timeout);
2615 assert_eq!(actual.shutdown_timeout, expected.shutdown_timeout);
2616 assert_eq!(actual.tls_handshake_timeout, expected.tls_handshake_timeout);
2617 assert_eq!(
2618 actual.max_concurrent_tls_handshakes,
2619 expected.max_concurrent_tls_handshakes
2620 );
2621 assert_eq!(actual.compression_enabled, expected.compression_enabled);
2622 assert_eq!(actual.compression_min_size, expected.compression_min_size);
2623 assert_eq!(actual.admin_enabled, expected.admin_enabled);
2624 assert_eq!(actual.admin_role, expected.admin_role);
2625 assert_eq!(actual.tool_list_filtering, expected.tool_list_filtering);
2626 assert_eq!(actual.expose_build_metadata, expected.expose_build_metadata);
2627 assert_eq!(actual.security_headers, expected.security_headers);
2628 }
2629
2630 #[test]
2631 fn session_binding_toml_roundtrip_and_bridge() {
2632 let cfg = server_from_root_toml(
2633 r"
2634 [server]
2635 session_binding = false
2636 ",
2637 );
2638 let bridged = cfg
2639 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2640 .unwrap();
2641
2642 assert!(!cfg.session_binding);
2643 assert!(!bridged.session_binding);
2644 assert!(ServerConfig::default().session_binding);
2645 assert!(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0").session_binding);
2646 }
2647
2648 #[test]
2649 fn session_binding_secret_toml_roundtrip_and_bridge() {
2650 let cfg = server_from_root_toml(
2651 r#"
2652 [server]
2653 session_binding_secret = "0123456789abcdef0123456789abcdef"
2654 "#,
2655 );
2656 let bridged = cfg
2657 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2658 .unwrap();
2659
2660 assert!(cfg.session_binding_secret.is_some());
2661 assert!(bridged.session_binding_secret.is_some());
2662 assert!(validate_server_config(&cfg).is_ok());
2663 }
2664
2665 #[test]
2666 fn session_binding_secret_short_toml_rejected() {
2667 let cfg = server_from_root_toml(
2668 r#"
2669 [server]
2670 session_binding_secret = "too-short"
2671 "#,
2672 );
2673
2674 let err = validate_server_config(&cfg).expect_err("short binding secret fails");
2675
2676 assert!(err.to_string().contains("at least 32 UTF-8 bytes"));
2677 }
2678
2679 #[test]
2680 fn tool_list_filtering_toml_roundtrip_and_bridge() {
2681 let cfg = server_from_root_toml(
2682 r"
2683 [server]
2684 tool_list_filtering = false
2685 ",
2686 );
2687 let bridged = cfg
2688 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2689 .unwrap();
2690
2691 assert!(!cfg.tool_list_filtering);
2692 assert!(!bridged.tool_list_filtering);
2693 assert!(ServerConfig::default().tool_list_filtering);
2694 assert!(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0").tool_list_filtering);
2695 }
2696
2697 #[test]
2698 fn t5_hsts_preload_from_toml_rejected_by_mcp_validate() {
2699 let cfg = server_from_root_toml(
2700 r#"
2701 [server.security_headers]
2702 strict_transport_security = "max-age=1; preload"
2703 "#,
2704 );
2705 let mcp = cfg
2706 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2707 .unwrap();
2708
2709 let err = mcp.validate().unwrap_err();
2710 let msg = err.to_string();
2711 assert!(msg.contains("preload"), "error must mention preload: {msg}");
2712 }
2713
2714 #[test]
2715 fn t6_bad_security_header_from_toml_rejected_by_mcp_validate() {
2716 let cfg = server_from_root_toml(
2717 r#"
2718 [server.security_headers]
2719 content_security_policy = "bad\nvalue"
2720 "#,
2721 );
2722 let mcp = cfg
2723 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2724 .unwrap();
2725
2726 let err = mcp.validate().unwrap_err();
2727 let msg = err.to_string();
2728 assert!(
2729 msg.contains("invalid security_headers.content_security_policy"),
2730 "error must name invalid header field: {msg}"
2731 );
2732 }
2733
2734 #[test]
2735 fn t7_zero_max_request_body_rejected_by_mcp_validate() {
2736 let cfg: ServerConfig = toml::from_str("max_request_body = 0").unwrap();
2737 let mcp = cfg
2738 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
2739 .unwrap();
2740
2741 let err = mcp.validate().unwrap_err();
2742 assert!(
2743 err.to_string()
2744 .contains("max_request_body must be greater than zero")
2745 );
2746 }
2747
2748 #[test]
2749 fn t9_unknown_security_header_key_is_rejected() {
2750 let err = toml::from_str::<RootConfig>(
2751 r#"
2752 [server.security_headers]
2753 typo_content_security_policy = "default-src 'self'"
2754 "#,
2755 )
2756 .unwrap_err();
2757
2758 let msg = err.to_string();
2759 assert!(
2760 msg.contains("typo_content_security_policy"),
2761 "error must name the offending key: {msg}"
2762 );
2763 }
2764
2765 #[test]
2766 fn unknown_server_config_key_is_rejected() {
2767 let err = toml::from_str::<ServerConfig>(
2768 r#"
2769 tls_keypath = "/etc/certs/server.key"
2770 "#,
2771 )
2772 .unwrap_err();
2773
2774 let msg = err.to_string();
2775 assert!(
2776 msg.contains("tls_keypath"),
2777 "error must name the offending key: {msg}"
2778 );
2779 }
2780
2781 #[cfg(not(feature = "oauth"))]
2782 #[test]
2783 fn oauth_table_without_oauth_feature_is_rejected_with_actionable_message() {
2784 let server = toml::from_str::<ServerConfig>(
2789 r#"
2790 listen_port = 8080
2791
2792 [auth]
2793 enabled = true
2794
2795 [auth.oauth]
2796 issuer = "https://auth.example.com"
2797 "#,
2798 )
2799 .expect("[auth.oauth] must parse so validation can produce the real message");
2800
2801 let msg = validate_server_config(&server)
2802 .expect_err("auth.oauth without the oauth feature must be rejected")
2803 .to_string();
2804
2805 assert!(
2806 msg.contains("oauth") && msg.contains("--features oauth"),
2807 "error must name the missing cargo feature and how to fix it: {msg}"
2808 );
2809 }
2810
2811 #[test]
2812 fn all_twelve_security_header_keys_deserialize_from_server_toml() {
2813 let cfg = server_from_root_toml(
2814 r#"
2815 [server.security_headers]
2816 content_security_policy = "csp"
2817 strict_transport_security = "max-age=1"
2818 cross_origin_embedder_policy = "coep"
2819 cross_origin_resource_policy = "corp"
2820 cross_origin_opener_policy = "coop"
2821 permissions_policy = "permissions"
2822 referrer_policy = "referrer"
2823 x_frame_options = "frame"
2824 cache_control = "cache"
2825 x_content_type_options = "content-type"
2826 x_dns_prefetch_control = "dns"
2827 x_permitted_cross_domain_policies = "cross-domain"
2828 "#,
2829 );
2830
2831 let headers = cfg.security_headers;
2832 assert_eq!(headers.content_security_policy.as_deref(), Some("csp"));
2833 assert_eq!(
2834 headers.strict_transport_security.as_deref(),
2835 Some("max-age=1")
2836 );
2837 assert_eq!(
2838 headers.cross_origin_embedder_policy.as_deref(),
2839 Some("coep")
2840 );
2841 assert_eq!(
2842 headers.cross_origin_resource_policy.as_deref(),
2843 Some("corp")
2844 );
2845 assert_eq!(headers.cross_origin_opener_policy.as_deref(), Some("coop"));
2846 assert_eq!(headers.permissions_policy.as_deref(), Some("permissions"));
2847 assert_eq!(headers.referrer_policy.as_deref(), Some("referrer"));
2848 assert_eq!(headers.x_frame_options.as_deref(), Some("frame"));
2849 assert_eq!(headers.cache_control.as_deref(), Some("cache"));
2850 assert_eq!(
2851 headers.x_content_type_options.as_deref(),
2852 Some("content-type")
2853 );
2854 assert_eq!(headers.x_dns_prefetch_control.as_deref(), Some("dns"));
2855 assert_eq!(
2856 headers.x_permitted_cross_domain_policies.as_deref(),
2857 Some("cross-domain")
2858 );
2859 }
2860
2861 fn struct_pub_fields(marker: &str) -> Vec<String> {
2863 let source = include_str!("config.rs").replace("\r\n", "\n");
2864 let (_, after) = source
2865 .split_once(marker)
2866 .unwrap_or_else(|| panic!("struct start marker {marker:?} not found"));
2867 let (body, _) = after
2868 .split_once("\n}\n")
2869 .expect("struct end marker not found");
2870 body.lines()
2871 .filter_map(|line| {
2872 line.trim()
2873 .strip_prefix("pub ")
2874 .and_then(|rest| rest.split_once(':').map(|(name, _)| name.trim().to_owned()))
2875 })
2876 .collect()
2877 }
2878
2879 const ENV_OVERRIDE_EXCLUDED_FIELDS: &[&str] = &[
2887 "server.allowed_origins",
2889 "server.extra_route_rate_limit_exempt_paths",
2890 "server.trusted_proxies",
2891 "server.auth",
2892 "server.security_headers",
2893 "server.tls_handshake_timeout",
2896 "server.max_concurrent_tls_handshakes",
2897 "server.shutdown_timeout",
2898 "server.request_timeout",
2899 "server.max_request_body",
2900 "server.stdio_enabled",
2901 "server.tool_rate_limit",
2902 "server.tool_rate_limit_burst",
2903 "server.extra_route_rate_limit",
2904 "server.extra_route_rate_limit_burst",
2905 "server.trusted_forwarder_max_entries",
2906 "server.forwarded_header",
2907 "server.session_idle_timeout",
2908 "server.session_binding",
2909 "server.sse_keep_alive",
2910 "server.compression_enabled",
2911 "server.compression_min_size",
2912 "server.max_concurrent_requests",
2913 "server.admin_role",
2914 "server.tool_list_filtering",
2915 "server.expose_build_metadata",
2916 "observability.log_level",
2919 "observability.audit_log_path",
2920 "observability.log_request_headers",
2921 ];
2922
2923 #[test]
2924 fn every_config_field_is_env_overridable_or_excluded() {
2925 for (marker, prefix) in [
2926 ("pub struct ServerConfig {", "server"),
2927 ("pub struct ObservabilityConfig {", "observability"),
2928 ] {
2929 for field in struct_pub_fields(marker) {
2930 let target = format!("{prefix}.{field}");
2931 let overridable = ENV_OVERRIDE_SPECS
2932 .iter()
2933 .any(|spec| spec.target_field == target);
2934 let excluded = ENV_OVERRIDE_EXCLUDED_FIELDS.contains(&target.as_str());
2935 assert!(
2936 overridable || excluded,
2937 "`{target}` is neither env-overridable nor listed in \
2938 ENV_OVERRIDE_EXCLUDED_FIELDS; classify it deliberately"
2939 );
2940 assert!(
2941 !(overridable && excluded),
2942 "`{target}` is both env-overridable and excluded; remove one"
2943 );
2944 }
2945 }
2946 }
2947
2948 #[test]
2949 fn shared_invariants_report_a_fixed_precedence() {
2950 assert!(matches!(
2953 check_shared_config_invariants(true, false, true, false, true),
2954 Err(SharedConfigViolation::AdminRequiresAuth)
2955 ));
2956 assert!(matches!(
2958 check_shared_config_invariants(false, true, true, false, true),
2959 Err(SharedConfigViolation::TlsCertWithoutKey)
2960 ));
2961 assert!(matches!(
2962 check_shared_config_invariants(false, true, false, true, true),
2963 Err(SharedConfigViolation::TlsKeyWithoutCert)
2964 ));
2965 assert!(matches!(
2967 check_shared_config_invariants(false, true, false, false, true),
2968 Err(SharedConfigViolation::MtlsRequiresTls)
2969 ));
2970 check_shared_config_invariants(true, true, true, true, true)
2972 .unwrap_or_else(|_| panic!("admin+auth with full TLS and mTLS must be valid"));
2973 check_shared_config_invariants(false, false, false, false, false)
2974 .unwrap_or_else(|_| panic!("an empty config must be valid"));
2975 }
2976
2977 #[test]
2978 fn toml_validator_surfaces_the_shared_precedence() {
2979 let server = ServerConfig {
2980 admin_enabled: true,
2981 tls_cert_path: Some(PathBuf::from("/etc/certs/server.crt")),
2982 ..Default::default()
2983 };
2984
2985 let err = validate_server_config(&server)
2986 .expect_err("admin without auth must fail")
2987 .to_string();
2988 assert!(
2989 err.contains("admin_enabled=true requires auth"),
2990 "admin must be reported before the TLS pairing failure; got {err:?}"
2991 );
2992 }
2993
2994 #[test]
2995 fn server_config_debug_redacts_tls_key_path() {
2996 let cfg = ServerConfig {
2997 tls_cert_path: Some(PathBuf::from("/etc/certs/server.crt")),
2998 tls_key_path: Some(PathBuf::from("/etc/secrets/server.key")),
2999 ..Default::default()
3000 };
3001
3002 let rendered = format!("{cfg:?}");
3003 assert!(
3004 !rendered.contains("server.key") && !rendered.contains("/etc/secrets"),
3005 "the private-key path must never render; got {rendered}"
3006 );
3007 assert!(
3008 rendered.contains("tls_key_path: Some(\"[REDACTED]\")"),
3009 "presence must still be reported for diagnostics; got {rendered}"
3010 );
3011 assert!(
3012 rendered.contains("server.crt"),
3013 "the certificate path is not secret and must remain visible"
3014 );
3015 }
3016
3017 #[test]
3018 fn observability_config_debug_redacts_audit_log_path() {
3019 let cfg = ObservabilityConfig {
3020 audit_log_path: Some(PathBuf::from("/var/log/rmcp/audit.log")),
3021 ..Default::default()
3022 };
3023
3024 let rendered = format!("{cfg:?}");
3025 assert!(
3026 !rendered.contains("audit.log") && !rendered.contains("/var/log"),
3027 "the audit log location must never render; got {rendered}"
3028 );
3029 assert!(rendered.contains("audit_log_path: Some(\"[REDACTED]\")"));
3030 }
3031
3032 #[test]
3033 fn server_config_debug_lists_every_field() {
3034 let rendered = format!("{:?}", ServerConfig::default());
3035 for field in struct_pub_fields("pub struct ServerConfig {") {
3036 assert!(
3037 rendered.contains(&format!("{field}:")),
3038 "hand-written Debug omits `{field}`; add it (redacted if sensitive)"
3039 );
3040 }
3041 }
3042
3043 #[test]
3044 fn observability_config_debug_lists_every_field() {
3045 let rendered = format!("{:?}", ObservabilityConfig::default());
3046 for field in struct_pub_fields("pub struct ObservabilityConfig {") {
3047 assert!(
3048 rendered.contains(&format!("{field}:")),
3049 "hand-written Debug omits `{field}`; add it (redacted if sensitive)"
3050 );
3051 }
3052 }
3053
3054 #[test]
3055 fn t10_every_server_config_field_is_classified_for_bridge() {
3056 let source = include_str!("config.rs").replace("\r\n", "\n");
3057 let (_, after_struct_start) = source
3058 .split_once("pub struct ServerConfig {")
3059 .expect("ServerConfig struct start marker");
3060 let (struct_body, _) = after_struct_start
3061 .split_once("\n}\n\nimpl ServerConfig")
3062 .expect("ServerConfig struct end marker");
3063 let actual_fields: HashSet<&str> = struct_body
3064 .lines()
3065 .filter_map(|line| {
3066 line.trim()
3067 .strip_prefix("pub ")
3068 .and_then(|rest| rest.split_once(':').map(|(name, _)| name.trim()))
3069 })
3070 .collect();
3071 let bridged_fields: HashSet<&str> = SERVER_CONFIG_BRIDGED_FIELDS.iter().copied().collect();
3072 let not_bridged_fields: HashSet<&str> =
3073 SERVER_CONFIG_NOT_BRIDGED_FIELDS.iter().copied().collect();
3074 let runtime_only_fields: HashSet<&str> = MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS
3075 .iter()
3076 .copied()
3077 .collect();
3078 let classified_fields: HashSet<&str> =
3079 bridged_fields.union(¬_bridged_fields).copied().collect();
3080
3081 assert_eq!(actual_fields, classified_fields);
3082 assert!(bridged_fields.is_disjoint(¬_bridged_fields));
3083 assert!(runtime_only_fields.is_disjoint(&actual_fields));
3084 assert!(SERVER_CONFIG_NOT_BRIDGED_FIELDS.contains(&"stdio_enabled"));
3085 assert!(MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS.contains(&"rbac"));
3086 assert!(MCP_SERVER_CONFIG_RUNTIME_ONLY_FIELDS.contains(&"metrics_bind"));
3087 }
3088
3089 #[test]
3090 fn replacement_semantics_clear_base_option_and_false_bool_fields() {
3091 let (_token, hash) = crate::auth::generate_api_key().unwrap();
3092 let base = McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
3093 .with_tls("/tmp/base.crt", "/tmp/base.key")
3094 .with_auth(crate::auth::AuthConfig::with_keys(vec![
3095 crate::auth::ApiKeyEntry::new("base-key", hash, "admin"),
3096 ]))
3097 .with_tool_rate_limit(10)
3098 .with_tool_rate_limit_burst(20)
3099 .with_extra_route_rate_limit(30)
3100 .with_extra_route_rate_limit_burst(40)
3101 .with_trusted_proxies(["127.0.0.1/32"])
3102 .with_forwarded_header(crate::transport::ForwardedHeaderMode::Forwarded)
3103 .with_public_url("https://base.example")
3104 .enable_compression(512)
3105 .with_max_concurrent_requests(99)
3106 .enable_admin("admin")
3107 .expose_build_metadata();
3108
3109 let actual = ServerConfig::default().apply_to_mcp_config(base).unwrap();
3110
3111 assert!(actual.tls_cert_path.is_none());
3112 assert!(actual.tls_key_path.is_none());
3113 assert!(actual.auth.is_none());
3114 assert!(actual.tool_rate_limit.is_none());
3115 assert!(actual.tool_rate_limit_burst.is_none());
3116 assert!(actual.extra_route_rate_limit.is_none());
3117 assert!(actual.extra_route_rate_limit_burst.is_none());
3118 assert_eq!(actual.key_eviction_policy, KeyEvictionPolicy::EvictLru);
3119 assert!(actual.forwarded_header.is_none());
3120 assert!(actual.public_url.is_none());
3121 assert!(!actual.compression_enabled);
3122 assert_eq!(actual.compression_min_size, 1024);
3123 assert!(actual.max_concurrent_requests.is_none());
3124 assert!(!actual.admin_enabled);
3125 assert_eq!(actual.admin_role, "admin");
3126 assert!(!actual.expose_build_metadata);
3127 }
3128
3129 #[test]
3130 fn partial_tls_toml_does_not_inherit_base_key() {
3131 let cfg = ServerConfig {
3132 tls_cert_path: Some("/tmp/toml.crt".into()),
3133 tls_key_path: None,
3134 ..ServerConfig::default()
3135 };
3136 let mcp = cfg
3137 .apply_to_mcp_config(
3138 McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
3139 .with_tls("/tmp/base.crt", "/tmp/base.key"),
3140 )
3141 .unwrap();
3142
3143 assert_eq!(mcp.tls_cert_path, Some(PathBuf::from("/tmp/toml.crt")));
3144 assert!(mcp.tls_key_path.is_none());
3145 let err = mcp.validate().unwrap_err();
3146 assert!(err.to_string().contains("tls_key_path"));
3147 }
3148
3149 #[test]
3150 fn partial_tls_toml_does_not_inherit_base_cert() {
3151 let cfg = ServerConfig {
3152 tls_cert_path: None,
3153 tls_key_path: Some("/tmp/toml.key".into()),
3154 ..ServerConfig::default()
3155 };
3156 let mcp = cfg
3157 .apply_to_mcp_config(
3158 McpServerConfig::new("127.0.0.1:0", "t", "0.0.0")
3159 .with_tls("/tmp/base.crt", "/tmp/base.key"),
3160 )
3161 .unwrap();
3162
3163 assert!(mcp.tls_cert_path.is_none());
3164 assert_eq!(mcp.tls_key_path, Some(PathBuf::from("/tmp/toml.key")));
3165 let err = mcp.validate().unwrap_err();
3166 assert!(err.to_string().contains("tls_cert_path"));
3167 }
3168
3169 #[test]
3170 fn t11_bridge_maps_bind_addr_and_request_timeout() {
3171 let cfg: ServerConfig = toml::from_str(
3172 r#"
3173 listen_addr = "127.0.0.2"
3174 listen_port = 9000
3175 request_timeout = "5s"
3176 "#,
3177 )
3178 .unwrap();
3179
3180 let mcp = cfg
3181 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
3182 .unwrap();
3183
3184 assert_eq!(mcp.bind_addr, "127.0.0.2:9000");
3185 assert_eq!(mcp.request_timeout, Duration::from_secs(5));
3186 }
3187
3188 #[test]
3189 fn key_eviction_policy_toml_defaults_and_overrides() {
3190 let default_cfg: ServerConfig = toml::from_str("").unwrap();
3191 assert_eq!(default_cfg.key_eviction_policy, KeyEvictionPolicy::EvictLru);
3192
3193 let reject_new: ServerConfig = toml::from_str(r#"key_eviction_policy = "reject_new""#)
3194 .expect("reject_new policy parses");
3195 assert_eq!(reject_new.key_eviction_policy, KeyEvictionPolicy::RejectNew);
3196 let bridged = reject_new
3197 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
3198 .unwrap();
3199 assert_eq!(bridged.key_eviction_policy, KeyEvictionPolicy::RejectNew);
3200 }
3201
3202 #[test]
3203 fn t12_bridge_rejects_invalid_request_timeout() {
3204 let cfg: ServerConfig = toml::from_str(r#"request_timeout = "not-a-duration""#).unwrap();
3205
3206 let Err(err) = cfg.apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
3207 else {
3208 panic!("invalid request_timeout must fail");
3209 };
3210
3211 assert!(err.to_string().contains("request_timeout"));
3212 }
3213
3214 #[test]
3215 fn observability_config_deserialize_defaults() {
3216 let cfg: ObservabilityConfig = toml::from_str("").unwrap();
3217 assert_eq!(cfg.log_level, "info,rmcp=warn");
3218 assert_eq!(cfg.log_format, "pretty");
3219 assert!(!cfg.log_request_headers);
3220 assert!(!cfg.metrics_enabled);
3221 assert!(!cfg.log_plaintext_oauth_tokens);
3222 assert!(!cfg.log_oauth_claim_values);
3223 assert!(!cfg.log_tool_call_arguments);
3224 }
3225
3226 #[test]
3227 fn observability_diagnostic_knobs_deserialize_true() {
3228 let cfg: ObservabilityConfig = toml::from_str(
3229 r"
3230 log_plaintext_oauth_tokens = true
3231 log_oauth_claim_values = true
3232 log_tool_call_arguments = true
3233 ",
3234 )
3235 .unwrap();
3236
3237 assert!(cfg.log_plaintext_oauth_tokens);
3238 assert!(cfg.log_oauth_claim_values);
3239 assert!(cfg.log_tool_call_arguments);
3240 }
3241
3242 fn all_env_vars() -> Vec<&'static str> {
3243 ENV_OVERRIDE_SPECS.iter().map(|spec| spec.env_var).collect()
3244 }
3245
3246 fn with_env_vars<R>(vars: &[(&str, Option<&str>)], f: impl FnOnce() -> R) -> R {
3247 let mut all = all_env_vars()
3248 .into_iter()
3249 .map(|var| (var, None::<&str>))
3250 .collect::<Vec<_>>();
3251 all.extend(vars.iter().copied());
3252 temp_env::with_vars(all, f)
3253 }
3254
3255 #[test]
3256 fn e1_server_env_overrides_absent_keeps_defaults() {
3257 with_env_vars(&[], || {
3258 let mut cfg = ServerConfig::default();
3259 let report = cfg.apply_env_overrides().unwrap();
3260 assert!(report.is_empty());
3261 assert_eq!(cfg.listen_addr, "127.0.0.1");
3262 assert_eq!(cfg.listen_port, 8443);
3263 assert!(cfg.tls_cert_path.is_none());
3264 assert!(cfg.tls_key_path.is_none());
3265 assert!(cfg.public_url.is_none());
3266 assert!(!cfg.admin_enabled);
3267 assert!(cfg.auth.is_none());
3268 });
3269 }
3270
3271 #[test]
3272 fn e2_listen_port_env_override_applies_and_reports() {
3273 with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("9000"))], || {
3274 let mut cfg = ServerConfig::default();
3275 let report = cfg.apply_env_overrides().unwrap();
3276 assert_eq!(cfg.listen_port, 9000);
3277 assert_eq!(report.len(), 1);
3278 assert_eq!(report[0].env_var, SERVER_LISTEN_PORT_ENV);
3279 assert_eq!(report[0].target_field, "server.listen_port");
3280 assert_eq!(report[0].source, EnvOverrideSource::Env);
3281 assert_eq!(report[0].value.as_deref(), Some("9000"));
3282 });
3283 }
3284
3285 #[test]
3286 fn e3_bad_listen_port_env_fails_closed() {
3287 with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("not-a-number"))], || {
3288 let mut cfg = ServerConfig::default();
3289 let err = cfg.apply_env_overrides().unwrap_err();
3290 let msg = err.to_string();
3291 assert!(msg.contains(SERVER_LISTEN_PORT_ENV));
3292 assert!(msg.contains("u16"));
3293 });
3294 }
3295
3296 #[test]
3297 fn session_binding_secret_env_and_file_conflict_rejected() {
3298 with_env_vars(
3299 &[
3300 (
3301 SERVER_SESSION_BINDING_SECRET_ENV,
3302 Some("0123456789abcdef0123456789abcdef"),
3303 ),
3304 (SERVER_SESSION_BINDING_SECRET_FILE_ENV, Some("/tmp/secret")),
3305 ],
3306 || {
3307 let mut cfg = ServerConfig::default();
3308 let err = cfg.apply_env_overrides().unwrap_err();
3309 let msg = err.to_string();
3310 assert!(msg.contains(SERVER_SESSION_BINDING_SECRET_ENV));
3311 assert!(msg.contains(SERVER_SESSION_BINDING_SECRET_FILE_ENV));
3312 },
3313 );
3314 }
3315
3316 #[test]
3317 fn session_binding_secret_blank_rejected() {
3318 for value in ["", "\n", " "] {
3319 with_env_vars(&[(SERVER_SESSION_BINDING_SECRET_ENV, Some(value))], || {
3320 let mut cfg = ServerConfig::default();
3321 let err = cfg.apply_env_overrides().unwrap_err();
3322 assert!(err.to_string().contains(SERVER_SESSION_BINDING_SECRET_ENV));
3323 });
3324 }
3325 }
3326
3327 #[test]
3328 fn session_binding_secret_file_normalizes_newline_and_reports_file_source() {
3329 let path = std::env::temp_dir().join(format!(
3330 "rmcp-server-kit-session-binding-secret-{}.txt",
3331 std::time::SystemTime::now()
3332 .duration_since(std::time::UNIX_EPOCH)
3333 .expect("clock after epoch")
3334 .as_nanos()
3335 ));
3336 std::fs::write(&path, "0123456789abcdef0123456789abcdef\n").expect("write secret file");
3337 let path_string = path.to_string_lossy().to_string();
3338 let report = with_env_vars(
3339 &[(
3340 SERVER_SESSION_BINDING_SECRET_FILE_ENV,
3341 Some(path_string.as_str()),
3342 )],
3343 || {
3344 let mut cfg = ServerConfig::default();
3345 let report = cfg.apply_env_overrides().unwrap();
3346 assert_eq!(
3347 cfg.session_binding_secret
3348 .as_ref()
3349 .map(SecretString::expose_secret),
3350 Some("0123456789abcdef0123456789abcdef")
3351 );
3352 report
3353 },
3354 );
3355 std::fs::remove_file(path).expect("remove secret file");
3356
3357 assert_eq!(report.len(), 1);
3358 assert_eq!(report[0].env_var, SERVER_SESSION_BINDING_SECRET_FILE_ENV);
3359 assert_eq!(report[0].target_field, "server.session_binding_secret");
3360 assert_eq!(report[0].source, EnvOverrideSource::File);
3361 assert!(report[0].value.is_none());
3362 }
3363
3364 #[test]
3365 fn e4_oauth_env_without_auth_parent_fails_closed() {
3366 with_env_vars(&[(SERVER_OAUTH_ISSUER_ENV, Some("https://idp/"))], || {
3367 let mut cfg = ServerConfig::default();
3368 let err = cfg.apply_env_overrides().unwrap_err();
3369 let msg = err.to_string();
3370 assert!(msg.contains(SERVER_OAUTH_ISSUER_ENV));
3371 #[cfg(feature = "oauth")]
3372 assert!(msg.contains("[server.auth.oauth]"));
3373 #[cfg(not(feature = "oauth"))]
3374 assert!(msg.contains("oauth` feature"));
3375 });
3376 }
3377
3378 #[cfg(feature = "oauth")]
3379 #[test]
3380 fn e5_oauth_env_populates_declared_parent_and_validates() {
3381 with_env_vars(
3382 &[
3383 (SERVER_OAUTH_ISSUER_ENV, Some("https://idp.example/")),
3384 (SERVER_OAUTH_AUDIENCE_ENV, Some("mcp")),
3385 (
3386 SERVER_OAUTH_JWKS_URI_ENV,
3387 Some("https://idp.example/.well-known/jwks.json"),
3388 ),
3389 ],
3390 || {
3391 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3392 auth.oauth = Some(crate::oauth::OAuthConfig {
3393 role_claim: Some("roles".into()),
3394 ..crate::oauth::OAuthConfig::default()
3395 });
3396 let mut cfg = ServerConfig {
3397 auth: Some(auth),
3398 ..ServerConfig::default()
3399 };
3400
3401 let report = cfg.apply_env_overrides().unwrap();
3402 let oauth = cfg
3403 .auth
3404 .as_ref()
3405 .and_then(|auth| auth.oauth.as_ref())
3406 .unwrap();
3407 assert_eq!(oauth.issuer, "https://idp.example/");
3408 assert_eq!(oauth.audience, "mcp");
3409 assert_eq!(oauth.jwks_uri, "https://idp.example/.well-known/jwks.json");
3410 assert!(oauth.validate().is_ok());
3411 assert_eq!(report.len(), 3);
3412 },
3413 );
3414 }
3415
3416 #[cfg(feature = "oauth")]
3417 #[test]
3418 fn e5b_oauth_env_missing_audience_fails_validate() {
3419 with_env_vars(
3420 &[
3421 (SERVER_OAUTH_ISSUER_ENV, Some("https://idp.example/")),
3422 (
3423 SERVER_OAUTH_JWKS_URI_ENV,
3424 Some("https://idp.example/.well-known/jwks.json"),
3425 ),
3426 ],
3427 || {
3428 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3429 auth.oauth = Some(crate::oauth::OAuthConfig {
3430 role_claim: Some("roles".into()),
3431 ..crate::oauth::OAuthConfig::default()
3432 });
3433 let mut cfg = ServerConfig {
3434 auth: Some(auth),
3435 ..ServerConfig::default()
3436 };
3437
3438 cfg.apply_env_overrides().unwrap();
3439 let oauth = cfg
3440 .auth
3441 .as_ref()
3442 .and_then(|auth| auth.oauth.as_ref())
3443 .unwrap();
3444 let err = oauth.validate().unwrap_err();
3445 assert!(err.to_string().contains("oauth.audience must not be empty"));
3446 },
3447 );
3448 }
3449
3450 #[cfg(feature = "oauth")]
3451 #[test]
3452 fn e5c_oauth_proxy_env_applies_to_declared_proxy() {
3453 with_env_vars(
3454 &[(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, Some("true"))],
3455 || {
3456 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3457 auth.oauth = Some(crate::oauth::OAuthConfig {
3458 proxy: Some(
3459 crate::oauth::OAuthProxyConfig::builder(
3460 "https://idp.example/authorize",
3461 "https://idp.example/token",
3462 "mcp",
3463 )
3464 .build(),
3465 ),
3466 ..crate::oauth::OAuthConfig::default()
3467 });
3468 let mut cfg = ServerConfig {
3469 auth: Some(auth),
3470 ..ServerConfig::default()
3471 };
3472
3473 let report = cfg.apply_env_overrides().unwrap();
3474 let proxy = cfg
3475 .auth
3476 .as_ref()
3477 .and_then(|auth| auth.oauth.as_ref())
3478 .and_then(|oauth| oauth.proxy.as_ref())
3479 .unwrap();
3480 assert!(proxy.strip_resource_param);
3481 assert_eq!(report.len(), 1);
3482 assert_eq!(
3483 report[0].env_var,
3484 SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV
3485 );
3486 },
3487 );
3488 }
3489
3490 #[cfg(feature = "oauth")]
3491 #[test]
3492 fn e5d_oauth_proxy_env_without_declared_proxy_fails_closed() {
3493 with_env_vars(
3497 &[(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, Some("true"))],
3498 || {
3499 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3500 auth.oauth = Some(crate::oauth::OAuthConfig::default());
3501 let mut cfg = ServerConfig {
3502 auth: Some(auth),
3503 ..ServerConfig::default()
3504 };
3505
3506 let err = cfg.apply_env_overrides().unwrap_err();
3507 let msg = err.to_string();
3508 assert!(msg.contains(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV));
3509 assert!(msg.contains("[server.auth.oauth.proxy]"));
3510 },
3511 );
3512 }
3513
3514 #[cfg(feature = "oauth")]
3515 #[test]
3516 fn e5e_oauth_proxy_env_rejects_non_bool() {
3517 with_env_vars(
3518 &[(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV, Some("maybe"))],
3519 || {
3520 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3521 auth.oauth = Some(crate::oauth::OAuthConfig {
3522 proxy: Some(
3523 crate::oauth::OAuthProxyConfig::builder(
3524 "https://idp.example/authorize",
3525 "https://idp.example/token",
3526 "mcp",
3527 )
3528 .build(),
3529 ),
3530 ..crate::oauth::OAuthConfig::default()
3531 });
3532 let mut cfg = ServerConfig {
3533 auth: Some(auth),
3534 ..ServerConfig::default()
3535 };
3536
3537 let msg = cfg.apply_env_overrides().unwrap_err().to_string();
3538 assert!(msg.contains(SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV));
3539 assert!(msg.contains("bool"));
3540 },
3541 );
3542 }
3543
3544 #[cfg(feature = "oauth")]
3545 #[test]
3546 fn e5f_oauth_allowed_algorithms_env_parses_comma_separated_list() {
3547 with_env_vars(
3548 &[(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV, Some("RS256, ES384"))],
3549 || {
3550 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3551 auth.oauth = Some(crate::oauth::OAuthConfig::default());
3552 let mut cfg = ServerConfig {
3553 auth: Some(auth),
3554 ..ServerConfig::default()
3555 };
3556
3557 let report = cfg.apply_env_overrides().unwrap();
3558 let oauth = cfg
3559 .auth
3560 .as_ref()
3561 .and_then(|auth| auth.oauth.as_ref())
3562 .unwrap();
3563 assert_eq!(
3564 oauth.allowed_algorithms.as_deref(),
3565 Some(["RS256".to_owned(), "ES384".to_owned()].as_slice())
3566 );
3567 assert_eq!(report.len(), 1);
3568 },
3569 );
3570 }
3571
3572 #[cfg(feature = "oauth")]
3573 #[test]
3574 fn e5g_oauth_allowed_algorithms_env_rejects_non_narrowing_value() {
3575 with_env_vars(
3578 &[(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV, Some("HS256"))],
3579 || {
3580 let mut auth = crate::auth::AuthConfig::with_keys(vec![]);
3581 auth.oauth = Some(crate::oauth::OAuthConfig::default());
3582 let mut cfg = ServerConfig {
3583 auth: Some(auth),
3584 ..ServerConfig::default()
3585 };
3586
3587 let msg = cfg.apply_env_overrides().unwrap_err().to_string();
3588 assert!(msg.contains(SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV));
3589 assert!(msg.contains("unsupported algorithm"));
3590 },
3591 );
3592 }
3593
3594 #[test]
3595 fn e9_bad_observability_bool_env_fails_closed() {
3596 with_env_vars(
3597 &[(OBSERVABILITY_METRICS_ENABLED_ENV, Some("maybe"))],
3598 || {
3599 let mut cfg = ObservabilityConfig::default();
3600 let err = cfg.apply_env_overrides().unwrap_err();
3601 let msg = err.to_string();
3602 assert!(msg.contains(OBSERVABILITY_METRICS_ENABLED_ENV));
3603 assert!(msg.contains("bool"));
3604 },
3605 );
3606 }
3607
3608 #[test]
3609 fn observability_diagnostic_env_overrides_win_over_toml() {
3610 with_env_vars(
3611 &[
3612 (OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV, Some("false")),
3613 (OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV, Some("false")),
3614 (OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV, Some("false")),
3615 ],
3616 || {
3617 let mut cfg: ObservabilityConfig = toml::from_str(
3618 r"
3619 log_plaintext_oauth_tokens = true
3620 log_oauth_claim_values = true
3621 log_tool_call_arguments = true
3622 ",
3623 )
3624 .unwrap();
3625
3626 let report = cfg.apply_env_overrides().unwrap();
3627
3628 assert!(!cfg.log_plaintext_oauth_tokens);
3629 assert!(!cfg.log_oauth_claim_values);
3630 assert!(!cfg.log_tool_call_arguments);
3631 assert_eq!(report.len(), 3);
3632 assert!(report.iter().any(|entry| {
3633 entry.env_var == OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV
3634 && entry.target_field == "observability.log_plaintext_oauth_tokens"
3635 && entry.value.as_deref() == Some("false")
3636 }));
3637 assert!(report.iter().any(|entry| {
3638 entry.env_var == OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV
3639 && entry.target_field == "observability.log_oauth_claim_values"
3640 && entry.value.as_deref() == Some("false")
3641 }));
3642 assert!(report.iter().any(|entry| {
3643 entry.env_var == OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV
3644 && entry.target_field == "observability.log_tool_call_arguments"
3645 && entry.value.as_deref() == Some("false")
3646 }));
3647 },
3648 );
3649 }
3650
3651 #[test]
3652 fn bad_observability_diagnostic_bool_env_fails_closed() {
3653 for env_var in [
3654 OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV,
3655 OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV,
3656 OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV,
3657 ] {
3658 with_env_vars(&[(env_var, Some("notabool"))], || {
3659 let mut cfg = ObservabilityConfig::default();
3660 let err = cfg.apply_env_overrides().unwrap_err();
3661 let msg = err.to_string();
3662 assert!(msg.contains(env_var));
3663 assert!(msg.contains("bool"));
3664 });
3665 }
3666 }
3667
3668 #[test]
3669 fn e10_env_port_reaches_mcp_bridge() {
3670 with_env_vars(&[(SERVER_LISTEN_PORT_ENV, Some("9100"))], || {
3671 let mut server: ServerConfig = toml::from_str(r#"listen_addr = "127.0.0.2""#).unwrap();
3672 server.apply_env_overrides().unwrap();
3673 let mcp = server
3674 .apply_to_mcp_config(McpServerConfig::new("127.0.0.1:0", "t", "0.0.0"))
3675 .unwrap();
3676 assert_eq!(mcp.bind_addr, "127.0.0.2:9100");
3677 assert!(mcp.validate().is_ok());
3678 });
3679 }
3680
3681 #[test]
3682 fn key_eviction_policy_env_override_applies_and_reports() {
3683 with_env_vars(
3684 &[(SERVER_KEY_EVICTION_POLICY_ENV, Some("reject_new"))],
3685 || {
3686 let mut cfg: ServerConfig = toml::from_str(r#"key_eviction_policy = "evict_lru""#)
3687 .expect("TOML policy parses");
3688 let report = cfg.apply_env_overrides().unwrap();
3689 assert_eq!(cfg.key_eviction_policy, KeyEvictionPolicy::RejectNew);
3690 assert_eq!(report.len(), 1);
3691 assert_eq!(report[0].env_var, SERVER_KEY_EVICTION_POLICY_ENV);
3692 assert_eq!(report[0].target_field, "server.key_eviction_policy");
3693 assert_eq!(report[0].value.as_deref(), Some("reject_new"));
3694 },
3695 );
3696 }
3697
3698 #[test]
3699 fn bad_key_eviction_policy_env_fails_closed() {
3700 with_env_vars(
3701 &[(SERVER_KEY_EVICTION_POLICY_ENV, Some("drop_random"))],
3702 || {
3703 let mut cfg = ServerConfig::default();
3704 let err = cfg.apply_env_overrides().unwrap_err();
3705 let msg = err.to_string();
3706 assert!(msg.contains(SERVER_KEY_EVICTION_POLICY_ENV));
3707 assert!(msg.contains("KeyEvictionPolicy"));
3708 },
3709 );
3710 }
3711
3712 #[cfg(unix)]
3713 #[test]
3714 fn non_unicode_env_value_fails_closed() {
3715 use std::{ffi::OsString, os::unix::ffi::OsStringExt};
3716
3717 let bad = OsString::from_vec(vec![0x66, 0x80, 0x6f]);
3718 temp_env::with_var(SERVER_LISTEN_ADDR_ENV, Some(bad), || {
3719 let mut cfg = ServerConfig::default();
3720 let err = cfg.apply_env_overrides().unwrap_err();
3721 let msg = err.to_string();
3722 assert!(msg.contains(SERVER_LISTEN_ADDR_ENV));
3723 assert!(msg.contains("UTF-8"));
3724 });
3725 }
3726
3727 #[cfg(not(feature = "oauth"))]
3728 #[test]
3729 fn e11_oauth_env_feature_off_fails_closed() {
3730 with_env_vars(&[(SERVER_OAUTH_ISSUER_ENV, Some("https://idp/"))], || {
3731 let mut cfg = ServerConfig {
3732 auth: Some(crate::auth::AuthConfig::with_keys(vec![])),
3733 ..ServerConfig::default()
3734 };
3735 let err = cfg.apply_env_overrides().unwrap_err();
3736 let msg = err.to_string();
3737 assert!(msg.contains(SERVER_OAUTH_ISSUER_ENV));
3738 assert!(msg.contains("oauth` feature"));
3739 });
3740 }
3741
3742 #[test]
3743 fn env_override_spec_matches_expected_set() {
3744 let vars = ENV_OVERRIDE_SPECS
3745 .iter()
3746 .map(|spec| {
3747 (
3748 spec.env_var,
3749 spec.target_field,
3750 spec.required_feature,
3751 spec.redacted,
3752 )
3753 })
3754 .collect::<Vec<_>>();
3755 assert_eq!(vars.len(), EXPECTED_ENV_OVERRIDE_SPECS.len());
3756 for expected in EXPECTED_ENV_OVERRIDE_SPECS {
3757 assert!(vars.contains(expected), "missing env spec {expected:?}");
3758 }
3759 assert_eq!(
3760 ENV_OVERRIDE_SPECS
3761 .iter()
3762 .filter(|spec| spec.value_type == "Path")
3763 .count(),
3764 4
3765 );
3766 }
3767
3768 #[derive(Debug)]
3769 struct GuideEnvRow {
3770 env_var: String,
3771 target_field: String,
3772 value_type: String,
3773 notes: String,
3774 }
3775
3776 #[derive(Debug)]
3777 struct GuideEnvAnnotation {
3778 env_var: String,
3779 key: String,
3780 }
3781
3782 const INLINE_ENV_ANNOTATION_EXEMPTIONS: &[&str] = &[
3786 SERVER_SESSION_BINDING_SECRET_FILE_ENV,
3787 RBAC_REDACTION_SALT_FILE_ENV,
3788 ];
3789
3790 type EnvSpecTuple = (&'static str, &'static str, Option<&'static str>, bool);
3791
3792 const EXPECTED_ENV_OVERRIDE_SPECS: &[EnvSpecTuple] = &[
3793 (SERVER_LISTEN_ADDR_ENV, "server.listen_addr", None, false),
3794 (SERVER_LISTEN_PORT_ENV, "server.listen_port", None, false),
3795 (SERVER_PUBLIC_URL_ENV, "server.public_url", None, false),
3796 (
3797 SERVER_TLS_CERT_PATH_ENV,
3798 "server.tls_cert_path",
3799 None,
3800 false,
3801 ),
3802 (SERVER_TLS_KEY_PATH_ENV, "server.tls_key_path", None, false),
3803 (
3804 SERVER_ADMIN_ENABLED_ENV,
3805 "server.admin_enabled",
3806 None,
3807 false,
3808 ),
3809 (
3810 SERVER_KEY_EVICTION_POLICY_ENV,
3811 "server.key_eviction_policy",
3812 None,
3813 false,
3814 ),
3815 (
3816 SERVER_SESSION_BINDING_SECRET_ENV,
3817 "server.session_binding_secret",
3818 None,
3819 true,
3820 ),
3821 (
3822 SERVER_SESSION_BINDING_SECRET_FILE_ENV,
3823 "server.session_binding_secret",
3824 None,
3825 true,
3826 ),
3827 (
3828 SERVER_OAUTH_ISSUER_ENV,
3829 "server.auth.oauth.issuer",
3830 Some("oauth"),
3831 false,
3832 ),
3833 (
3834 SERVER_OAUTH_AUDIENCE_ENV,
3835 "server.auth.oauth.audience",
3836 Some("oauth"),
3837 false,
3838 ),
3839 (
3840 SERVER_OAUTH_JWKS_URI_ENV,
3841 "server.auth.oauth.jwks_uri",
3842 Some("oauth"),
3843 false,
3844 ),
3845 (
3846 SERVER_OAUTH_ALLOWED_ALGORITHMS_ENV,
3847 "server.auth.oauth.allowed_algorithms",
3848 Some("oauth"),
3849 false,
3850 ),
3851 (
3852 SERVER_OAUTH_PROXY_STRIP_RESOURCE_PARAM_ENV,
3853 "server.auth.oauth.proxy.strip_resource_param",
3854 Some("oauth"),
3855 false,
3856 ),
3857 (
3858 OBSERVABILITY_LOG_FORMAT_ENV,
3859 "observability.log_format",
3860 None,
3861 false,
3862 ),
3863 (
3864 OBSERVABILITY_METRICS_ENABLED_ENV,
3865 "observability.metrics_enabled",
3866 None,
3867 false,
3868 ),
3869 (
3870 OBSERVABILITY_METRICS_BIND_ENV,
3871 "observability.metrics_bind",
3872 None,
3873 false,
3874 ),
3875 (
3876 OBSERVABILITY_LOG_PLAINTEXT_OAUTH_TOKENS_ENV,
3877 "observability.log_plaintext_oauth_tokens",
3878 None,
3879 false,
3880 ),
3881 (
3882 OBSERVABILITY_LOG_OAUTH_CLAIM_VALUES_ENV,
3883 "observability.log_oauth_claim_values",
3884 None,
3885 false,
3886 ),
3887 (
3888 OBSERVABILITY_LOG_TOOL_CALL_ARGUMENTS_ENV,
3889 "observability.log_tool_call_arguments",
3890 None,
3891 false,
3892 ),
3893 (
3894 OBSERVABILITY_LOG_UPSTREAM_ERROR_BODIES_ENV,
3895 "observability.log_upstream_error_bodies",
3896 None,
3897 false,
3898 ),
3899 (RBAC_REDACTION_SALT_ENV, "rbac.redaction_salt", None, true),
3900 (
3901 RBAC_REDACTION_SALT_FILE_ENV,
3902 "rbac.redaction_salt",
3903 None,
3904 true,
3905 ),
3906 ];
3907
3908 #[test]
3913 fn guide_env_override_table_matches_code_spec() {
3914 let rows = parse_guide_env_override_table();
3915 assert_eq!(
3916 rows.len(),
3917 ENV_OVERRIDE_SPECS.len(),
3918 "GUIDE env override table row count {} must match ENV_OVERRIDE_SPECS row count {}",
3919 rows.len(),
3920 ENV_OVERRIDE_SPECS.len()
3921 );
3922
3923 for (idx, (row, spec)) in rows.iter().zip(ENV_OVERRIDE_SPECS.iter()).enumerate() {
3924 assert_eq!(
3925 row.env_var, spec.env_var,
3926 "row {idx} env var mismatch: GUIDE has {:?}, code has {:?}",
3927 row.env_var, spec.env_var
3928 );
3929 assert_eq!(
3930 row.target_field, spec.target_field,
3931 "{} target mismatch: GUIDE has {:?}, code has {:?}",
3932 spec.env_var, row.target_field, spec.target_field
3933 );
3934 assert_eq!(
3935 row.value_type, spec.value_type,
3936 "{} type mismatch: GUIDE has {:?}, code has {:?}",
3937 spec.env_var, row.value_type, spec.value_type
3938 );
3939
3940 let notes_lower = row.notes.to_ascii_lowercase();
3941 if let Some(feature) = spec.required_feature {
3942 assert!(
3943 notes_lower.contains(feature),
3944 "{} notes must mention required feature {:?}; notes were {:?}",
3945 spec.env_var,
3946 feature,
3947 row.notes
3948 );
3949 } else {
3950 assert!(
3951 !notes_lower.contains("requires") && !notes_lower.contains("feature"),
3952 "{} notes must not mention a required feature; notes were {:?}",
3953 spec.env_var,
3954 row.notes
3955 );
3956 }
3957
3958 if spec.redacted {
3959 assert!(
3960 notes_lower.contains("secret") && notes_lower.contains("redacted"),
3961 "{} notes must indicate secret/redacted handling; notes were {:?}",
3962 spec.env_var,
3963 row.notes
3964 );
3965 } else {
3966 assert!(
3967 !notes_lower.contains("secret") && !notes_lower.contains("redacted"),
3968 "{} notes must not indicate secret/redacted handling; notes were {:?}",
3969 spec.env_var,
3970 row.notes
3971 );
3972 }
3973 }
3974
3975 let spec_vars = ENV_OVERRIDE_SPECS
3976 .iter()
3977 .map(|spec| spec.env_var)
3978 .collect::<HashSet<_>>();
3979 for env_var in parse_rmcp_env_constants_from_config_source() {
3980 assert!(
3981 spec_vars.contains(env_var.as_str()),
3982 "env const {env_var} is defined in src/config.rs but missing from ENV_OVERRIDE_SPECS"
3983 );
3984 }
3985 }
3986
3987 #[test]
3993 fn guide_toml_example_env_annotations_match_code_spec() {
3994 let annotations = parse_guide_toml_env_annotations();
3995 assert!(
3996 !annotations.is_empty(),
3997 "canonical TOML example contains no `# env:` annotations"
3998 );
3999
4000 let spec_by_var = ENV_OVERRIDE_SPECS
4001 .iter()
4002 .map(|spec| (spec.env_var, spec))
4003 .collect::<std::collections::HashMap<_, _>>();
4004 let mut seen = HashSet::new();
4005
4006 for annotation in &annotations {
4007 let Some(spec) = spec_by_var.get(annotation.env_var.as_str()) else {
4008 panic!(
4009 "GUIDE inline env annotation {:?} is not present in ENV_OVERRIDE_SPECS",
4010 annotation.env_var
4011 );
4012 };
4013 assert!(
4014 seen.insert(annotation.env_var.as_str()),
4015 "GUIDE inline env annotation {:?} appears more than once",
4016 annotation.env_var
4017 );
4018 let expected_key = spec
4019 .target_field
4020 .rsplit('.')
4021 .next()
4022 .expect("target_field has at least one segment");
4023 assert_eq!(
4024 annotation.key, expected_key,
4025 "{} inline annotation is attached to TOML key {:?}, but code spec target {:?} ends in {:?}",
4026 annotation.env_var, annotation.key, spec.target_field, expected_key
4027 );
4028 }
4029
4030 let expected_count = ENV_OVERRIDE_SPECS.len() - INLINE_ENV_ANNOTATION_EXEMPTIONS.len();
4031 assert_eq!(
4032 annotations.len(),
4033 expected_count,
4034 "GUIDE inline env annotation count {} must equal ENV_OVERRIDE_SPECS count {} minus exemptions {:?}",
4035 annotations.len(),
4036 ENV_OVERRIDE_SPECS.len(),
4037 INLINE_ENV_ANNOTATION_EXEMPTIONS
4038 );
4039
4040 for spec in ENV_OVERRIDE_SPECS {
4041 if INLINE_ENV_ANNOTATION_EXEMPTIONS.contains(&spec.env_var) {
4042 assert!(
4043 !seen.contains(spec.env_var),
4044 "{} is deliberately exempt from inline annotation but was annotated",
4045 spec.env_var
4046 );
4047 } else {
4048 assert!(
4049 seen.contains(spec.env_var),
4050 "{} is missing from GUIDE canonical TOML inline `# env:` annotations",
4051 spec.env_var
4052 );
4053 }
4054 }
4055 }
4056
4057 fn guide_markdown() -> &'static str {
4058 include_str!("../docs/GUIDE.md")
4059 }
4060
4061 fn parse_guide_env_override_table() -> Vec<GuideEnvRow> {
4062 let guide = guide_markdown();
4063 let (_, after_begin) = guide
4064 .split_once("<!-- BEGIN ENV_OVERRIDE_TABLE -->")
4065 .expect("docs/GUIDE.md is missing <!-- BEGIN ENV_OVERRIDE_TABLE --> marker");
4066 let (table, _) = after_begin
4067 .split_once("<!-- END ENV_OVERRIDE_TABLE -->")
4068 .expect("docs/GUIDE.md is missing <!-- END ENV_OVERRIDE_TABLE --> marker");
4069 let rows = table
4070 .lines()
4071 .filter_map(parse_guide_env_override_row)
4072 .collect::<Vec<_>>();
4073 assert!(
4074 !rows.is_empty(),
4075 "docs/GUIDE.md ENV_OVERRIDE_TABLE markers were found but no data rows parsed"
4076 );
4077 rows
4078 }
4079
4080 fn parse_guide_env_override_row(line: &str) -> Option<GuideEnvRow> {
4081 let trimmed = line.trim();
4082 if !trimmed.starts_with('|')
4083 || trimmed.contains("|---")
4084 || trimmed.contains("Environment variable")
4085 {
4086 return None;
4087 }
4088 let cells = trimmed
4089 .trim_matches('|')
4090 .split('|')
4091 .map(str::trim)
4092 .collect::<Vec<_>>();
4093 assert_eq!(
4094 cells.len(),
4095 4,
4096 "env override GUIDE table row must have four cells, got {} in line {:?}",
4097 cells.len(),
4098 line
4099 );
4100 Some(GuideEnvRow {
4101 env_var: unwrap_markdown_code(cells[0], "Environment variable", line),
4102 target_field: unwrap_markdown_code(cells[1], "Target TOML path", line),
4103 value_type: cells[2].trim().to_owned(),
4104 notes: cells[3].trim().to_owned(),
4105 })
4106 }
4107
4108 fn unwrap_markdown_code(cell: &str, column: &str, row: &str) -> String {
4109 let inner = cell
4110 .strip_prefix('`')
4111 .and_then(|value| value.strip_suffix('`'))
4112 .unwrap_or_else(|| panic!("{column} cell must be backtick-wrapped in row {row:?}"));
4113 inner.trim().to_owned()
4114 }
4115
4116 fn parse_guide_toml_env_annotations() -> Vec<GuideEnvAnnotation> {
4117 let guide = guide_markdown();
4118 let (_, after_heading) = guide
4119 .split_once("### Complete TOML configuration reference")
4120 .expect("docs/GUIDE.md is missing canonical TOML configuration heading");
4121 let (section, _) = after_heading
4122 .split_once("### Bridging TOML config to `McpServerConfig`")
4123 .expect("docs/GUIDE.md is missing bridge heading after canonical TOML example");
4124 let (_, after_fence_start) = section
4125 .split_once("```toml")
4126 .expect("canonical TOML section is missing opening ```toml fence");
4127 let (toml_block, _) = after_fence_start
4128 .split_once("```")
4129 .expect("canonical TOML section is missing closing code fence");
4130
4131 toml_block
4132 .lines()
4133 .filter_map(parse_guide_toml_env_annotation_line)
4134 .collect()
4135 }
4136
4137 fn parse_guide_toml_env_annotation_line(line: &str) -> Option<GuideEnvAnnotation> {
4138 let (before_marker, after_marker) = line.split_once("# env: ")?;
4139 let env_var = after_marker
4140 .split_whitespace()
4141 .next()
4142 .unwrap_or_else(|| panic!("missing env var after `# env:` in line {line:?}"));
4143 let key_source = before_marker
4144 .trim_end()
4145 .strip_prefix('#')
4146 .map_or_else(|| before_marker.trim_end(), str::trim);
4147 let key = key_source
4148 .split_once('=')
4149 .unwrap_or_else(|| panic!("missing TOML key before `# env:` in line {line:?}"))
4150 .0
4151 .trim();
4152
4153 Some(GuideEnvAnnotation {
4154 env_var: env_var.to_owned(),
4155 key: key.to_owned(),
4156 })
4157 }
4158
4159 fn parse_rmcp_env_constants_from_config_source() -> Vec<String> {
4160 include_str!("config.rs")
4161 .lines()
4162 .filter(|line| {
4163 let trimmed = line.trim_start();
4164 trimmed.starts_with("pub(crate) const ")
4165 && trimmed
4166 .strip_prefix("pub(crate) const ")
4167 .and_then(|rest| rest.split_once(':'))
4168 .is_some_and(|(name, _)| name.ends_with("_ENV"))
4169 && trimmed.contains("RMCP_SERVER_KIT__")
4170 })
4171 .filter_map(|line| {
4172 line.split_once('"')
4173 .and_then(|(_, rest)| rest.split_once('"'))
4174 .map(|(value, _)| value.to_owned())
4175 })
4176 .collect()
4177 }
4178}