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