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