1use std::{
2 future::Future,
3 net::{IpAddr, SocketAddr},
4 num::NonZeroUsize,
5 path::{Path, PathBuf},
6 pin::Pin,
7 sync::Arc,
8 time::Duration,
9};
10
11use arc_swap::ArcSwap;
12use axum::{
13 body::Body,
14 extract::{ConnectInfo, Request},
15 middleware::Next,
16 response::IntoResponse,
17};
18use rmcp::{
19 ServerHandler,
20 transport::streamable_http_server::{
21 StreamableHttpServerConfig, StreamableHttpService,
22 session::{EventStore, SessionStore, local::LocalSessionManager},
23 },
24};
25use rustls::RootCertStore;
26use secrecy::{ExposeSecret as _, SecretString};
27use tokio::{
28 net::TcpListener,
29 sync::{Semaphore, mpsc},
30};
31use tokio_util::sync::CancellationToken;
32
33use crate::{
34 auth::{
35 AuthConfig, AuthIdentity, AuthState, MtlsConfig, TlsConnInfo, auth_middleware,
36 build_rate_limiter, extract_mtls_identity,
37 },
38 bounded_limiter::{BoundedKeyedLimiter, BoundedLimiterDeny, KeyEvictionPolicy},
39 error::RmcpServerKitError,
40 mtls_revocation::{self, CrlSet, DynamicClientCertVerifier},
41 rbac::{RbacPolicy, ToolRateLimiter, build_tool_rate_limiter_with_policy, rbac_middleware},
42 rbac_context::RbacContextHandler,
43 session_binding::{
44 configured_session_binding_secret, process_session_binding_secret,
45 session_binding_middleware,
46 },
47};
48
49#[allow(
53 clippy::needless_pass_by_value,
54 reason = "consumed at .map_err(anyhow_to_startup) call sites; by-value matches the closure shape"
55)]
56fn anyhow_to_startup(e: anyhow::Error) -> RmcpServerKitError {
57 RmcpServerKitError::Startup(format!("{e:#}"))
58}
59
60#[allow(
66 clippy::needless_pass_by_value,
67 reason = "consumed at .map_err(|e| io_to_startup(...)) call sites; by-value matches the closure shape"
68)]
69fn io_to_startup(op: &str, e: std::io::Error) -> RmcpServerKitError {
70 RmcpServerKitError::Startup(format!("{op}: {e}"))
71}
72
73pub type ReadinessCheck =
78 Arc<dyn Fn() -> Pin<Box<dyn Future<Output = serde_json::Value> + Send>> + Send + Sync>;
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
125#[non_exhaustive]
126pub struct PeerAddr {
127 pub addr: SocketAddr,
129}
130
131impl PeerAddr {
132 #[must_use]
135 pub(crate) const fn new(addr: SocketAddr) -> Self {
136 Self { addr }
137 }
138}
139
140impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
149 type Rejection = (axum::http::StatusCode, &'static str);
150
151 #[allow(
152 clippy::unused_async_trait_impl,
153 reason = "async is mandated by the axum FromRequestParts trait signature; this impl only reads a request extension synchronously"
154 )]
155 async fn from_request_parts(
156 parts: &mut axum::http::request::Parts,
157 _state: &S,
158 ) -> Result<Self, Self::Rejection> {
159 parts.extensions.get::<Self>().copied().ok_or((
160 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
161 "peer address unavailable: not running under rmcp-server-kit serve()",
162 ))
163 }
164}
165
166#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
189#[non_exhaustive]
190pub struct ClientIp {
191 pub ip: IpAddr,
193}
194
195impl ClientIp {
196 #[must_use]
199 pub(crate) const fn new(ip: IpAddr) -> Self {
200 Self { ip }
201 }
202}
203
204#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
209#[serde(rename_all = "kebab-case")]
210#[non_exhaustive]
211pub enum ForwardedHeaderMode {
212 XForwardedFor,
214 Forwarded,
216}
217
218struct ForwardResolver {
221 trusted: Vec<ipnet::IpNet>,
222 mode: ForwardedHeaderMode,
223 max_scanned_entries: usize,
224}
225
226#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
247#[serde(default)]
248#[serde(deny_unknown_fields)]
249#[non_exhaustive]
250pub struct SecurityHeadersConfig {
251 pub x_content_type_options: Option<String>,
253 pub x_frame_options: Option<String>,
255 pub cache_control: Option<String>,
257 pub referrer_policy: Option<String>,
259 pub cross_origin_opener_policy: Option<String>,
261 pub cross_origin_resource_policy: Option<String>,
263 pub cross_origin_embedder_policy: Option<String>,
265 pub permissions_policy: Option<String>,
268 pub x_permitted_cross_domain_policies: Option<String>,
270 pub content_security_policy: Option<String>,
273 pub x_dns_prefetch_control: Option<String>,
275 pub strict_transport_security: Option<String>,
280}
281
282#[allow(
284 missing_debug_implementations,
285 reason = "contains callback/trait objects that don't impl Debug"
286)]
287#[allow(
288 clippy::struct_excessive_bools,
289 reason = "server configuration naturally has many boolean feature flags"
290)]
291#[non_exhaustive]
292pub struct McpServerConfig {
293 #[deprecated(
295 since = "0.13.0",
296 note = "use McpServerConfig::new() / with_bind_addr(); direct field access will become pub(crate) in a future major release"
297 )]
298 pub bind_addr: String,
299 #[deprecated(
301 since = "0.13.0",
302 note = "set via McpServerConfig::new(); direct field access will become pub(crate) in a future major release"
303 )]
304 pub name: String,
305 #[deprecated(
307 since = "0.13.0",
308 note = "set via McpServerConfig::new(); direct field access will become pub(crate) in a future major release"
309 )]
310 pub version: String,
311 #[deprecated(
313 since = "0.13.0",
314 note = "use McpServerConfig::with_tls(); direct field access will become pub(crate) in a future major release"
315 )]
316 pub tls_cert_path: Option<PathBuf>,
317 #[deprecated(
319 since = "0.13.0",
320 note = "use McpServerConfig::with_tls(); direct field access will become pub(crate) in a future major release"
321 )]
322 pub tls_key_path: Option<PathBuf>,
323 #[deprecated(
326 since = "0.13.0",
327 note = "use McpServerConfig::with_auth(); direct field access will become pub(crate) in a future major release"
328 )]
329 pub auth: Option<AuthConfig>,
330 #[deprecated(
333 since = "0.13.0",
334 note = "use McpServerConfig::with_rbac(); direct field access will become pub(crate) in a future major release"
335 )]
336 pub rbac: Option<Arc<RbacPolicy>>,
337 pub tool_list_filtering: bool,
340 #[deprecated(
346 since = "0.13.0",
347 note = "use McpServerConfig::with_allowed_origins(); direct field access will become pub(crate) in a future major release"
348 )]
349 pub allowed_origins: Vec<String>,
350 #[deprecated(
353 since = "0.13.0",
354 note = "use McpServerConfig::with_tool_rate_limit(); direct field access will become pub(crate) in a future major release"
355 )]
356 pub tool_rate_limit: Option<u32>,
357 #[deprecated(
363 since = "1.12.0",
364 note = "use McpServerConfig::with_tool_rate_limit_burst(); direct field access will become pub(crate) in a future major release"
365 )]
366 pub tool_rate_limit_burst: Option<u32>,
367 #[deprecated(
380 since = "1.11.0",
381 note = "use McpServerConfig::with_extra_route_rate_limit(); direct field access will become pub(crate) in a future major release"
382 )]
383 pub extra_route_rate_limit: Option<u32>,
384 #[deprecated(
391 since = "1.12.0",
392 note = "use McpServerConfig::with_extra_route_rate_limit_burst(); direct field access will become pub(crate) in a future major release"
393 )]
394 pub extra_route_rate_limit_burst: Option<u32>,
395 #[deprecated(
408 since = "1.14.0",
409 note = "use McpServerConfig::with_extra_route_rate_limit_exempt_paths(); direct field access will become pub(crate) in a future major release"
410 )]
411 pub extra_route_rate_limit_exempt_paths: Vec<String>,
412
413 pub key_eviction_policy: KeyEvictionPolicy,
415
416 pub trusted_forwarder_max_entries: usize,
423 #[deprecated(
431 since = "1.13.0",
432 note = "use McpServerConfig::with_trusted_proxies(); direct field access will become pub(crate) in a future major release"
433 )]
434 pub trusted_proxies: Vec<String>,
435 #[deprecated(
440 since = "1.13.0",
441 note = "use McpServerConfig::with_forwarded_header(); direct field access will become pub(crate) in a future major release"
442 )]
443 pub forwarded_header: Option<ForwardedHeaderMode>,
444 #[deprecated(
447 since = "0.13.0",
448 note = "use McpServerConfig::with_readiness_check(); direct field access will become pub(crate) in a future major release"
449 )]
450 pub readiness_check: Option<ReadinessCheck>,
451 #[deprecated(
458 since = "0.13.0",
459 note = "use McpServerConfig::with_max_request_body(); direct field access will become pub(crate) in a future major release"
460 )]
461 pub max_request_body: usize,
462 #[deprecated(
469 since = "0.13.0",
470 note = "use McpServerConfig::with_request_timeout(); direct field access will become pub(crate) in a future major release"
471 )]
472 pub request_timeout: Duration,
473 #[deprecated(
476 since = "0.13.0",
477 note = "use McpServerConfig::with_shutdown_timeout(); direct field access will become pub(crate) in a future major release"
478 )]
479 pub shutdown_timeout: Duration,
480 #[deprecated(
483 since = "0.13.0",
484 note = "use McpServerConfig::with_session_idle_timeout(); direct field access will become pub(crate) in a future major release"
485 )]
486 pub session_idle_timeout: Duration,
487 pub session_binding: bool,
495 pub session_binding_secret: Option<SecretString>,
501 pub task_binding: bool,
514 pub session_store: Option<Arc<dyn SessionStore>>,
516 pub event_store: Option<Arc<dyn EventStore>>,
529 #[deprecated(
532 since = "0.13.0",
533 note = "use McpServerConfig::with_sse_keep_alive(); direct field access will become pub(crate) in a future major release"
534 )]
535 pub sse_keep_alive: Duration,
536 #[deprecated(
540 since = "0.13.0",
541 note = "use McpServerConfig::with_reload_callback(); direct field access will become pub(crate) in a future major release"
542 )]
543 pub on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
544 #[deprecated(
551 since = "0.13.0",
552 note = "use McpServerConfig::with_extra_router(); direct field access will become pub(crate) in a future major release"
553 )]
554 pub extra_router: Option<axum::Router>,
555 #[deprecated(
560 since = "0.13.0",
561 note = "use McpServerConfig::with_public_url(); direct field access will become pub(crate) in a future major release"
562 )]
563 pub public_url: Option<String>,
564 #[deprecated(
567 since = "0.13.0",
568 note = "use McpServerConfig::enable_request_header_logging(); direct field access will become pub(crate) in a future major release"
569 )]
570 pub log_request_headers: bool,
571 pub expose_build_metadata: bool,
578 #[deprecated(
581 since = "0.13.0",
582 note = "use McpServerConfig::enable_compression(); direct field access will become pub(crate) in a future major release"
583 )]
584 pub compression_enabled: bool,
585 #[deprecated(
588 since = "0.13.0",
589 note = "use McpServerConfig::enable_compression(); direct field access will become pub(crate) in a future major release"
590 )]
591 pub compression_min_size: u16,
592 #[deprecated(
596 since = "0.13.0",
597 note = "use McpServerConfig::with_max_concurrent_requests(); direct field access will become pub(crate) in a future major release"
598 )]
599 pub max_concurrent_requests: Option<usize>,
600 #[deprecated(
603 since = "0.13.0",
604 note = "use McpServerConfig::enable_admin(); direct field access will become pub(crate) in a future major release"
605 )]
606 pub admin_enabled: bool,
607 #[deprecated(
609 since = "0.13.0",
610 note = "use McpServerConfig::enable_admin(); direct field access will become pub(crate) in a future major release"
611 )]
612 pub admin_role: String,
613 #[cfg(feature = "metrics")]
616 #[deprecated(
617 since = "0.13.0",
618 note = "use McpServerConfig::with_metrics(); direct field access will become pub(crate) in a future major release"
619 )]
620 pub metrics_enabled: bool,
621 #[cfg(feature = "metrics")]
623 #[deprecated(
624 since = "0.13.0",
625 note = "use McpServerConfig::with_metrics(); direct field access will become pub(crate) in a future major release"
626 )]
627 pub metrics_bind: String,
628 #[cfg(feature = "metrics")]
637 pub(crate) metrics_handle: Option<Arc<crate::metrics::McpMetrics>>,
638 #[deprecated(
642 since = "1.5.0",
643 note = "use McpServerConfig::with_security_headers(); direct field access will become pub(crate) in a future major release"
644 )]
645 pub security_headers: SecurityHeadersConfig,
646 #[deprecated(
652 since = "1.9.0",
653 note = "use McpServerConfig::with_tls_handshake_timeout(); direct field access will become pub(crate) in a future major release"
654 )]
655 pub tls_handshake_timeout: Duration,
656 #[deprecated(
663 since = "1.9.0",
664 note = "use McpServerConfig::with_max_concurrent_tls_handshakes(); direct field access will become pub(crate) in a future major release"
665 )]
666 pub max_concurrent_tls_handshakes: usize,
667}
668
669#[allow(
727 missing_debug_implementations,
728 reason = "wraps T which may not implement Debug; manual impl below avoids leaking inner contents into logs"
729)]
730pub struct Validated<T>(T);
731
732impl<T> std::fmt::Debug for Validated<T> {
733 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
734 f.debug_struct("Validated").finish_non_exhaustive()
735 }
736}
737
738impl<T> Validated<T> {
739 #[must_use]
741 pub fn as_inner(&self) -> &T {
742 &self.0
743 }
744
745 #[must_use]
750 pub fn into_inner(self) -> T {
751 self.0
752 }
753}
754
755#[allow(
756 deprecated,
757 reason = "internal builders/validators legitimately read/write the deprecated `pub` fields they were designed to manage"
758)]
759impl McpServerConfig {
760 #[must_use]
768 pub fn new(
769 bind_addr: impl Into<String>,
770 name: impl Into<String>,
771 version: impl Into<String>,
772 ) -> Self {
773 Self {
774 bind_addr: bind_addr.into(),
775 name: name.into(),
776 version: version.into(),
777 tls_cert_path: None,
778 tls_key_path: None,
779 auth: None,
780 rbac: None,
781 tool_list_filtering: true,
782 allowed_origins: Vec::new(),
783 tool_rate_limit: None,
784 readiness_check: None,
785 max_request_body: 1024 * 1024,
786 request_timeout: Duration::from_mins(2),
787 shutdown_timeout: Duration::from_secs(30),
788 session_idle_timeout: Duration::from_mins(20),
789 session_binding: true,
790 session_binding_secret: None,
791 task_binding: false,
792 session_store: None,
793 event_store: None,
794 sse_keep_alive: Duration::from_secs(15),
795 on_reload_ready: None,
796 extra_router: None,
797 public_url: None,
798 log_request_headers: false,
799 expose_build_metadata: false,
800 compression_enabled: false,
801 compression_min_size: 1024,
802 max_concurrent_requests: None,
803 admin_enabled: false,
804 admin_role: "admin".to_owned(),
805 #[cfg(feature = "metrics")]
806 metrics_enabled: false,
807 #[cfg(feature = "metrics")]
808 metrics_bind: "127.0.0.1:9090".into(),
809 #[cfg(feature = "metrics")]
810 metrics_handle: None,
811 security_headers: SecurityHeadersConfig::default(),
812 tls_handshake_timeout: DEFAULT_TLS_HANDSHAKE_TIMEOUT,
813 max_concurrent_tls_handshakes: DEFAULT_MAX_CONCURRENT_TLS_HANDSHAKES,
814 extra_route_rate_limit: None,
815 tool_rate_limit_burst: None,
816 extra_route_rate_limit_burst: None,
817 extra_route_rate_limit_exempt_paths: Vec::new(),
818 key_eviction_policy: KeyEvictionPolicy::default(),
819 trusted_forwarder_max_entries: crate::forwarded::MAX_SCANNED_ENTRIES,
820 trusted_proxies: Vec::new(),
821 forwarded_header: None,
822 }
823 }
824
825 #[must_use]
835 pub fn with_auth(mut self, auth: AuthConfig) -> Self {
836 self.auth = Some(auth);
837 self
838 }
839
840 #[must_use]
845 pub fn with_security_headers(mut self, headers: SecurityHeadersConfig) -> Self {
846 self.security_headers = headers;
847 self
848 }
849
850 #[must_use]
854 pub fn with_bind_addr(mut self, addr: impl Into<String>) -> Self {
855 self.bind_addr = addr.into();
856 self
857 }
858
859 #[must_use]
862 pub fn with_rbac(mut self, rbac: Arc<RbacPolicy>) -> Self {
863 self.rbac = Some(rbac);
864 self
865 }
866
867 #[must_use]
873 pub const fn with_tool_list_filtering(mut self, enabled: bool) -> Self {
874 self.tool_list_filtering = enabled;
875 self
876 }
877
878 #[must_use]
882 pub fn with_tls(mut self, cert_path: impl Into<PathBuf>, key_path: impl Into<PathBuf>) -> Self {
883 self.tls_cert_path = Some(cert_path.into());
884 self.tls_key_path = Some(key_path.into());
885 self
886 }
887
888 #[must_use]
892 pub fn with_public_url(mut self, url: impl Into<String>) -> Self {
893 self.public_url = Some(url.into());
894 self
895 }
896
897 #[must_use]
901 pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Self
902 where
903 I: IntoIterator<Item = S>,
904 S: Into<String>,
905 {
906 self.allowed_origins = origins.into_iter().map(Into::into).collect();
907 self
908 }
909
910 #[must_use]
943 pub fn with_extra_router(mut self, router: axum::Router) -> Self {
944 self.extra_router = Some(router);
945 self
946 }
947
948 #[must_use]
954 pub const fn with_trusted_forwarder_max_entries(mut self, max_entries: usize) -> Self {
955 self.trusted_forwarder_max_entries = max_entries;
956 self
957 }
958
959 #[must_use]
962 pub fn with_readiness_check(mut self, check: ReadinessCheck) -> Self {
963 self.readiness_check = Some(check);
964 self
965 }
966
967 #[must_use]
973 pub fn with_max_request_body(mut self, bytes: usize) -> Self {
974 self.max_request_body = bytes;
975 self
976 }
977
978 #[must_use]
984 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
985 self.request_timeout = timeout;
986 self
987 }
988
989 #[must_use]
991 pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
992 self.shutdown_timeout = timeout;
993 self
994 }
995
996 #[must_use]
998 pub fn with_session_idle_timeout(mut self, timeout: Duration) -> Self {
999 self.session_idle_timeout = timeout;
1000 self
1001 }
1002
1003 #[must_use]
1007 pub const fn with_session_binding(mut self, enabled: bool) -> Self {
1008 self.session_binding = enabled;
1009 self
1010 }
1011
1012 #[must_use]
1015 pub fn with_session_binding_secret(mut self, secret: SecretString) -> Self {
1016 self.session_binding_secret = Some(secret);
1017 self
1018 }
1019
1020 #[must_use]
1027 pub const fn with_task_binding(mut self, enabled: bool) -> Self {
1028 self.task_binding = enabled;
1029 self
1030 }
1031
1032 #[must_use]
1036 pub fn with_session_store(mut self, session_store: Arc<dyn SessionStore>) -> Self {
1037 self.session_store = Some(session_store);
1038 self
1039 }
1040
1041 #[must_use]
1046 pub fn with_event_store(mut self, event_store: Arc<dyn EventStore>) -> Self {
1047 self.event_store = Some(event_store);
1048 self
1049 }
1050
1051 #[must_use]
1053 pub fn with_sse_keep_alive(mut self, interval: Duration) -> Self {
1054 self.sse_keep_alive = interval;
1055 self
1056 }
1057
1058 #[must_use]
1062 pub fn with_max_concurrent_requests(mut self, limit: usize) -> Self {
1063 self.max_concurrent_requests = Some(limit);
1064 self
1065 }
1066
1067 #[must_use]
1075 pub fn with_tls_handshake_timeout(mut self, timeout: Duration) -> Self {
1076 self.tls_handshake_timeout = timeout;
1077 self
1078 }
1079
1080 #[must_use]
1089 pub fn with_max_concurrent_tls_handshakes(mut self, limit: usize) -> Self {
1090 self.max_concurrent_tls_handshakes = limit;
1091 self
1092 }
1093
1094 #[must_use]
1097 pub fn with_tool_rate_limit(mut self, per_minute: u32) -> Self {
1098 self.tool_rate_limit = Some(per_minute);
1099 self
1100 }
1101
1102 #[must_use]
1113 pub fn with_extra_route_rate_limit(mut self, per_minute: u32) -> Self {
1114 self.extra_route_rate_limit = Some(per_minute);
1115 self
1116 }
1117
1118 #[must_use]
1123 pub fn with_tool_rate_limit_burst(mut self, burst: u32) -> Self {
1124 self.tool_rate_limit_burst = Some(burst);
1125 self
1126 }
1127
1128 #[must_use]
1134 pub fn with_extra_route_rate_limit_burst(mut self, burst: u32) -> Self {
1135 self.extra_route_rate_limit_burst = Some(burst);
1136 self
1137 }
1138
1139 #[must_use]
1159 pub fn with_extra_route_rate_limit_exempt_paths<I, S>(mut self, paths: I) -> Self
1160 where
1161 I: IntoIterator<Item = S>,
1162 S: Into<String>,
1163 {
1164 self.extra_route_rate_limit_exempt_paths = paths.into_iter().map(Into::into).collect();
1165 self
1166 }
1167
1168 #[must_use]
1170 pub const fn with_key_eviction_policy(mut self, policy: KeyEvictionPolicy) -> Self {
1171 self.key_eviction_policy = policy;
1172 self
1173 }
1174
1175 #[must_use]
1187 pub fn with_trusted_proxies<I, S>(mut self, proxies: I) -> Self
1188 where
1189 I: IntoIterator<Item = S>,
1190 S: Into<String>,
1191 {
1192 self.trusted_proxies = proxies.into_iter().map(Into::into).collect();
1193 self
1194 }
1195
1196 #[must_use]
1201 pub fn with_forwarded_header(mut self, mode: ForwardedHeaderMode) -> Self {
1202 self.forwarded_header = Some(mode);
1203 self
1204 }
1205
1206 #[must_use]
1210 pub fn with_reload_callback<F>(mut self, callback: F) -> Self
1211 where
1212 F: FnOnce(ReloadHandle) + Send + 'static,
1213 {
1214 self.on_reload_ready = Some(Box::new(callback));
1215 self
1216 }
1217
1218 #[must_use]
1222 pub fn enable_compression(mut self, min_size: u16) -> Self {
1223 self.compression_enabled = true;
1224 self.compression_min_size = min_size;
1225 self
1226 }
1227
1228 #[must_use]
1233 pub fn enable_admin(mut self, role: impl Into<String>) -> Self {
1234 self.admin_enabled = true;
1235 self.admin_role = role.into();
1236 self
1237 }
1238
1239 #[must_use]
1242 pub fn enable_request_header_logging(mut self) -> Self {
1243 self.log_request_headers = true;
1244 self
1245 }
1246
1247 #[must_use]
1252 pub fn expose_build_metadata(mut self) -> Self {
1253 self.expose_build_metadata = true;
1254 self
1255 }
1256
1257 #[cfg(feature = "metrics")]
1260 #[must_use]
1261 pub fn with_metrics(mut self, bind: impl Into<String>) -> Self {
1262 self.metrics_enabled = true;
1263 self.metrics_bind = bind.into();
1264 self
1265 }
1266
1267 #[cfg(feature = "metrics")]
1281 #[must_use]
1282 pub fn with_metrics_handle(mut self, handle: Arc<crate::metrics::McpMetrics>) -> Self {
1283 self.metrics_handle = Some(handle);
1284 self
1285 }
1286
1287 pub fn validate(self) -> Result<Validated<Self>, RmcpServerKitError> {
1321 self.check()?;
1322 Ok(Validated(self))
1323 }
1324
1325 #[cfg(feature = "metrics")]
1328 fn check_metrics_handle(&self) -> Result<(), RmcpServerKitError> {
1329 if self.metrics_handle.is_some() && !self.metrics_enabled {
1330 return Err(RmcpServerKitError::Config(
1331 "metrics_handle supplied but metrics listener is disabled; call with_metrics(...) as well".into(),
1332 ));
1333 }
1334 Ok(())
1335 }
1336
1337 fn check_admin_role(&self) -> Result<(), RmcpServerKitError> {
1341 if self.admin_enabled && self.admin_role.trim().is_empty() {
1342 return Err(RmcpServerKitError::Config(
1343 "admin_role must not be empty".into(),
1344 ));
1345 }
1346 Ok(())
1347 }
1348
1349 fn check_burst_knobs(&self) -> Result<(), RmcpServerKitError> {
1356 if self.tool_rate_limit_burst == Some(0) {
1357 return Err(RmcpServerKitError::Config(
1358 "tool_rate_limit_burst must be greater than zero".into(),
1359 ));
1360 }
1361 if self.extra_route_rate_limit_burst == Some(0) {
1362 return Err(RmcpServerKitError::Config(
1363 "extra_route_rate_limit_burst must be greater than zero".into(),
1364 ));
1365 }
1366 if self.trusted_forwarder_max_entries == 0
1367 || self.trusted_forwarder_max_entries
1368 > crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES
1369 {
1370 return Err(RmcpServerKitError::Config(format!(
1371 "trusted_forwarder_max_entries must be in 1..={}, got {}",
1372 crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES,
1373 self.trusted_forwarder_max_entries
1374 )));
1375 }
1376 if self.tool_rate_limit_burst.is_some() && self.tool_rate_limit.is_none() {
1377 return Err(RmcpServerKitError::Config(
1378 "tool_rate_limit_burst requires tool_rate_limit to be set".into(),
1379 ));
1380 }
1381 if self.extra_route_rate_limit_burst.is_some() && self.extra_route_rate_limit.is_none() {
1382 return Err(RmcpServerKitError::Config(
1383 "extra_route_rate_limit_burst requires extra_route_rate_limit to be set".into(),
1384 ));
1385 }
1386 if !self.extra_route_rate_limit_exempt_paths.is_empty()
1387 && self.extra_route_rate_limit.is_none()
1388 {
1389 return Err(RmcpServerKitError::Config(
1390 "extra_route_rate_limit_exempt_paths requires extra_route_rate_limit to be set"
1391 .into(),
1392 ));
1393 }
1394 for path in &self.extra_route_rate_limit_exempt_paths {
1395 if path.is_empty() || !path.starts_with('/') {
1396 return Err(RmcpServerKitError::Config(format!(
1397 "extra_route_rate_limit_exempt_paths entries must be non-empty and start with '/': {path:?}"
1398 )));
1399 }
1400 }
1401 if let Some(rl) = self.auth.as_ref().and_then(|a| a.rate_limit.as_ref()) {
1402 if rl.burst == Some(0) {
1403 return Err(RmcpServerKitError::Config(
1404 "auth rate_limit.burst must be greater than zero".into(),
1405 ));
1406 }
1407 if rl.pre_auth_burst == Some(0) {
1408 return Err(RmcpServerKitError::Config(
1409 "auth rate_limit.pre_auth_burst must be greater than zero".into(),
1410 ));
1411 }
1412 }
1413 Ok(())
1414 }
1415
1416 fn check_trusted_forwarder(&self) -> Result<(), RmcpServerKitError> {
1421 for entry in &self.trusted_proxies {
1422 validate_trusted_proxy_entry(entry).map_err(RmcpServerKitError::Config)?;
1423 }
1424 if self.forwarded_header.is_some() && self.trusted_proxies.is_empty() {
1425 return Err(RmcpServerKitError::Config(
1426 "forwarded_header requires trusted_proxies to be nonempty".into(),
1427 ));
1428 }
1429 Ok(())
1430 }
1431
1432 fn check_session_binding_config(&self) -> Result<(), RmcpServerKitError> {
1433 if self.session_store.is_some()
1434 && self.session_binding
1435 && self.auth.as_ref().is_some_and(|auth| auth.enabled)
1436 && self.session_binding_secret.is_none()
1437 {
1438 return Err(RmcpServerKitError::Config(
1439 "session_store with session_binding enabled and auth configured requires \
1440 session_binding_secret: a shared secret is required for cross-instance \
1441 session verification"
1442 .into(),
1443 ));
1444 }
1445
1446 if let Some(secret) = &self.session_binding_secret {
1447 crate::session_binding::validate_configured_secret(
1448 "session_binding_secret",
1449 secret.expose_secret(),
1450 )?;
1451 }
1452 Ok(())
1453 }
1454
1455 fn check(&self) -> Result<(), RmcpServerKitError> {
1459 if let Err(violation) = crate::config::check_shared_config_invariants(
1474 self.admin_enabled,
1475 self.auth.as_ref().is_some_and(|a| a.enabled),
1476 self.tls_cert_path.is_some(),
1477 self.tls_key_path.is_some(),
1478 self.auth.as_ref().is_some_and(|a| a.mtls.is_some()),
1479 ) {
1480 return Err(RmcpServerKitError::Config(
1481 match violation {
1482 crate::config::SharedConfigViolation::AdminRequiresAuth => {
1483 "admin_enabled=true requires auth to be configured and enabled"
1484 }
1485 crate::config::SharedConfigViolation::TlsCertWithoutKey => {
1486 "tls_cert_path is set but tls_key_path is missing"
1487 }
1488 crate::config::SharedConfigViolation::TlsKeyWithoutCert => {
1489 "tls_key_path is set but tls_cert_path is missing"
1490 }
1491 crate::config::SharedConfigViolation::MtlsRequiresTls => {
1492 "auth.mtls requires TLS: set both tls_cert_path and tls_key_path \
1493 (mTLS client certificates cannot be verified on a plaintext listener)"
1494 }
1495 }
1496 .into(),
1497 ));
1498 }
1499
1500 if let Some(auth) = &self.auth {
1501 auth.validate_api_key_names()?;
1502 }
1503
1504 if self.bind_addr.parse::<SocketAddr>().is_err() {
1506 return Err(RmcpServerKitError::Config(format!(
1507 "bind_addr {:?} is not a valid socket address (expected e.g. 127.0.0.1:8080)",
1508 self.bind_addr
1509 )));
1510 }
1511
1512 if let Some(url) = &self.public_url
1514 && let Err(message) = validate_public_url_value(url)
1515 {
1516 return Err(RmcpServerKitError::Config(message));
1517 }
1518
1519 for origin in &self.allowed_origins {
1525 validate_allowed_origin_entry(origin).map_err(RmcpServerKitError::Config)?;
1526 }
1527
1528 if self.max_request_body == 0 {
1530 return Err(RmcpServerKitError::Config(
1531 "max_request_body must be greater than zero".into(),
1532 ));
1533 }
1534
1535 if self.extra_route_rate_limit == Some(0) {
1539 return Err(RmcpServerKitError::Config(
1540 "extra_route_rate_limit must be greater than zero".into(),
1541 ));
1542 }
1543
1544 #[cfg(feature = "metrics")]
1546 self.check_metrics_handle()?;
1547
1548 self.check_burst_knobs()?;
1550
1551 self.check_admin_role()?;
1553
1554 self.check_trusted_forwarder()?;
1556
1557 #[cfg(feature = "oauth")]
1559 if let Some(auth_cfg) = &self.auth
1560 && let Some(oauth_cfg) = &auth_cfg.oauth
1561 {
1562 oauth_cfg.validate()?;
1563 }
1564
1565 self.check_session_binding_config()?;
1566
1567 validate_security_headers(&self.security_headers)?;
1570
1571 if self.max_concurrent_requests == Some(0) {
1575 return Err(RmcpServerKitError::Config(
1576 "max_concurrent_requests must be greater than zero when set".into(),
1577 ));
1578 }
1579
1580 if let Some(auth_cfg) = &self.auth
1584 && let Some(rl) = &auth_cfg.rate_limit
1585 && rl.max_tracked_keys == 0
1586 {
1587 return Err(RmcpServerKitError::Config(
1588 "auth.rate_limit.max_tracked_keys must be greater than zero".into(),
1589 ));
1590 }
1591
1592 check_auth_capacity_knobs(self.auth.as_ref())?;
1593
1594 if self.tls_handshake_timeout == Duration::ZERO {
1599 return Err(RmcpServerKitError::Config(
1600 "tls_handshake_timeout must be greater than zero".into(),
1601 ));
1602 }
1603
1604 if self.max_concurrent_tls_handshakes == 0 {
1609 return Err(RmcpServerKitError::Config(
1610 "max_concurrent_tls_handshakes must be greater than zero".into(),
1611 ));
1612 }
1613
1614 Ok(())
1615 }
1616}
1617
1618#[allow(
1624 missing_debug_implementations,
1625 reason = "contains Arc<AuthState> with non-Debug fields"
1626)]
1627pub struct ReloadHandle {
1628 auth: Option<Arc<AuthState>>,
1629 rbac: Option<Arc<ArcSwap<RbacPolicy>>>,
1630 crl_set: Option<Arc<CrlSet>>,
1631}
1632
1633impl ReloadHandle {
1634 pub fn try_reload_auth_keys(
1651 &self,
1652 keys: Vec<crate::auth::ApiKeyEntry>,
1653 ) -> Result<(), RmcpServerKitError> {
1654 if let Some(ref auth) = self.auth {
1655 auth.try_reload_keys(keys)
1656 } else {
1657 Ok(())
1658 }
1659 }
1660
1661 pub fn reload_auth_keys(&self, keys: Vec<crate::auth::ApiKeyEntry>) {
1670 if let Err(error) = self.try_reload_auth_keys(keys) {
1671 tracing::error!(%error, "API key hot reload rejected: keys left unchanged");
1672 }
1673 }
1674
1675 pub fn reload_rbac(&self, policy: RbacPolicy) {
1677 if let Some(ref rbac) = self.rbac {
1678 rbac.store(Arc::new(policy));
1679 tracing::info!("RBAC policy reloaded");
1680 }
1681 }
1682
1683 pub async fn refresh_crls(&self) -> Result<(), RmcpServerKitError> {
1693 let Some(ref crl_set) = self.crl_set else {
1694 return Err(RmcpServerKitError::Config(
1695 "CRL refresh requested but mTLS CRL support is not configured".into(),
1696 ));
1697 };
1698
1699 crl_set.force_refresh().await
1700 }
1701}
1702
1703#[allow(
1720 clippy::too_many_lines,
1721 clippy::cognitive_complexity,
1722 reason = "middleware layer order is security-critical and must remain visible at one glance; extracting `&mut Router` helpers would obscure the auth/RBAC/origin/rate-limit ordering"
1723)]
1724struct AppRunParams {
1728 tls_paths: Option<(PathBuf, PathBuf)>,
1730 tls_handshake_timeout: Duration,
1732 max_concurrent_tls_handshakes: usize,
1734 mtls_config: Option<MtlsConfig>,
1736 shutdown_timeout: Duration,
1738 auth_state: Option<Arc<AuthState>>,
1740 rbac_swap: Arc<ArcSwap<RbacPolicy>>,
1742 on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
1744 ct: CancellationToken,
1748 session_ct: CancellationToken,
1758 scheme: &'static str,
1760 name: String,
1762}
1763
1764type BindingSecrets = (
1781 Option<crate::session_binding::SessionBindingSecret>,
1782 Option<crate::session_binding::SessionBindingSecret>,
1783);
1784
1785fn resolve_binding_secret(config: &McpServerConfig) -> anyhow::Result<BindingSecrets> {
1786 #[allow(
1787 deprecated,
1788 reason = "internal router assembly reads deprecated `pub` config fields by design until 1.0 makes them pub(crate)"
1789 )]
1790 if !config.session_binding && !config.task_binding {
1791 return Ok((None, None));
1792 }
1793 #[allow(
1794 deprecated,
1795 reason = "internal router assembly reads deprecated `pub` config fields by design until 1.0 makes them pub(crate)"
1796 )]
1797 let secret = match config.session_binding_secret.as_ref() {
1798 Some(configured) => configured_session_binding_secret(configured)?,
1799 None => process_session_binding_secret().clone(),
1800 };
1801 #[allow(
1802 deprecated,
1803 reason = "internal router assembly reads deprecated `pub` config fields by design until 1.0 makes them pub(crate)"
1804 )]
1805 let pair = (
1806 config.session_binding.then(|| secret.clone()),
1807 config.task_binding.then_some(secret),
1808 );
1809 Ok(pair)
1810}
1811
1812#[allow(
1813 clippy::cognitive_complexity,
1814 reason = "router assembly is intrinsically sequential; splitting harms readability"
1815)]
1816#[allow(
1817 deprecated,
1818 reason = "internal router assembly reads deprecated `pub` config fields by design until 1.0 makes them pub(crate)"
1819)]
1820fn build_app_router<H, F>(
1821 mut config: McpServerConfig,
1822 handler_factory: F,
1823) -> anyhow::Result<(axum::Router, AppRunParams)>
1824where
1825 H: ServerHandler + 'static,
1826 F: Fn() -> H + Send + Sync + Clone + 'static,
1827{
1828 let ct = CancellationToken::new();
1829 let session_ct = CancellationToken::new();
1830
1831 let allowed_hosts = derive_allowed_hosts(&config.bind_addr, config.public_url.as_deref());
1832 tracing::info!(allowed_hosts = %allowed_hosts.join(", "), "configured Streamable HTTP allowed hosts");
1833
1834 if config.max_concurrent_requests.is_none() {
1835 tracing::warn!(
1836 "max_concurrent_requests is unset: in-flight HTTP requests are unlimited; \
1837 set McpServerConfig::with_max_concurrent_requests or front the server with \
1838 an external concurrency limit"
1839 );
1840 }
1841
1842 let rbac_swap = Arc::new(ArcSwap::new(
1845 config
1846 .rbac
1847 .clone()
1848 .unwrap_or_else(|| Arc::new(RbacPolicy::disabled())),
1849 ));
1850
1851 let rbac_for_handler = Arc::clone(&rbac_swap);
1852 let tool_list_filtering = config.tool_list_filtering;
1853 let session_store = config.session_store.take();
1854 let mut rmcp_config = StreamableHttpServerConfig::default()
1859 .with_allowed_hosts(allowed_hosts)
1860 .with_sse_keep_alive(Some(config.sse_keep_alive))
1861 .with_max_request_body_bytes(config.max_request_body)
1866 .with_cancellation_token(session_ct.clone());
1867 rmcp_config.session_store = session_store;
1868 let event_store = config.event_store.take();
1869 let (binding_secret, task_binding_secret) = resolve_binding_secret(&config)?;
1870 let mcp_service = StreamableHttpService::new(
1871 move || {
1872 Ok(RbacContextHandler::new(
1873 handler_factory(),
1874 Arc::clone(&rbac_for_handler),
1875 tool_list_filtering,
1876 )
1877 .with_task_binding(task_binding_secret.clone()))
1878 },
1879 {
1880 let mut mgr = LocalSessionManager::default();
1881 mgr.session_config.keep_alive = Some(config.session_idle_timeout);
1882 if let Some(event_store) = event_store {
1883 mgr = mgr.with_event_store(event_store);
1884 }
1885 mgr.into()
1886 },
1887 rmcp_config,
1888 );
1889
1890 let mut mcp_router = axum::Router::new().nest_service("/mcp", mcp_service);
1892
1893 let auth_state: Option<Arc<AuthState>> = match config.auth {
1897 Some(ref auth_config) if auth_config.enabled => {
1898 let rate_limiter = auth_config.rate_limit.as_ref().map(build_rate_limiter);
1899 let pre_auth_limiter = auth_config
1900 .rate_limit
1901 .as_ref()
1902 .map(crate::auth::build_pre_auth_limiter);
1903
1904 #[cfg(feature = "oauth")]
1905 let jwks_cache = auth_config
1906 .oauth
1907 .as_ref()
1908 .map(|c| crate::oauth::JwksCache::new(c).map(Arc::new))
1909 .transpose()
1910 .map_err(|e| std::io::Error::other(format!("JWKS HTTP client: {e}")))?;
1911
1912 Some(Arc::new(AuthState {
1913 api_keys: ArcSwap::new(Arc::new(auth_config.api_keys.clone())),
1914 rate_limiter,
1915 pre_auth_limiter,
1916 #[cfg(feature = "oauth")]
1917 jwks_cache,
1918 seen_identities: crate::auth::SeenIdentitySet::new(),
1919 counters: crate::auth::AuthCounters::default(),
1920 resource_metadata_url: config.public_url.as_ref().map(|url| {
1928 format!(
1929 "{}/.well-known/oauth-protected-resource/mcp",
1930 url.trim_end_matches('/')
1931 )
1932 }),
1933 }))
1934 }
1935 _ => None,
1936 };
1937
1938 if config.admin_enabled {
1941 let Some(ref auth_state_ref) = auth_state else {
1942 return Err(anyhow::anyhow!(
1943 "admin_enabled=true requires auth to be configured and enabled"
1944 ));
1945 };
1946 let admin_state = crate::admin::AdminState {
1947 started_at: std::time::Instant::now(),
1948 name: config.name.clone(),
1949 version: config.version.clone(),
1950 auth: Some(Arc::clone(auth_state_ref)),
1951 rbac: Arc::clone(&rbac_swap),
1952 };
1953 let admin_cfg = crate::admin::AdminConfig {
1954 role: config.admin_role.clone(),
1955 };
1956 mcp_router = mcp_router.merge(crate::admin::admin_router(admin_state, &admin_cfg));
1957 tracing::info!(role = %config.admin_role, "/admin/* endpoints enabled");
1958 }
1959
1960 if let Some(secret) = binding_secret {
1992 mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
1993 let secret = secret.clone();
1994 session_binding_middleware(secret, req, next)
1995 }));
1996 }
1997
1998 {
2002 let tool_limiter: Option<Arc<ToolRateLimiter>> = config.tool_rate_limit.map(|per_minute| {
2003 build_tool_rate_limiter_with_policy(
2004 per_minute,
2005 config.tool_rate_limit_burst,
2006 config.key_eviction_policy,
2007 )
2008 });
2009
2010 if rbac_swap.load().is_enabled() {
2011 tracing::info!("RBAC enforcement enabled on /mcp");
2012 }
2013 if let Some(limit) = config.tool_rate_limit {
2014 tracing::info!(limit, "tool rate limiting enabled (calls/min per IP)");
2015 }
2016
2017 let rbac_for_mw = Arc::clone(&rbac_swap);
2018 mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
2019 let p = rbac_for_mw.load_full();
2020 let tl = tool_limiter.clone();
2021 rbac_middleware(p, tl, req, next)
2022 }));
2023 }
2024
2025 if let Some(ref auth_config) = config.auth
2027 && auth_config.enabled
2028 {
2029 let Some(ref state) = auth_state else {
2030 return Err(anyhow::anyhow!("auth state missing despite enabled config"));
2031 };
2032
2033 let methods: Vec<&str> = [
2034 auth_config.mtls.is_some().then_some("mTLS"),
2035 (!auth_config.api_keys.is_empty()).then_some("bearer"),
2036 #[cfg(feature = "oauth")]
2037 auth_config.oauth.is_some().then_some("oauth-jwt"),
2038 ]
2039 .into_iter()
2040 .flatten()
2041 .collect();
2042
2043 tracing::info!(
2044 methods = %methods.join(", "),
2045 api_keys = auth_config.api_keys.len(),
2046 "auth enabled on /mcp"
2047 );
2048
2049 let state_for_mw = Arc::clone(state);
2050 mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
2051 let s = Arc::clone(&state_for_mw);
2052 auth_middleware(s, req, next)
2053 }));
2054 }
2055
2056 mcp_router = mcp_router.layer(tower_http::timeout::TimeoutLayer::with_status_code(
2060 axum::http::StatusCode::REQUEST_TIMEOUT,
2061 config.request_timeout,
2062 ));
2063
2064 mcp_router = mcp_router.layer(tower_http::limit::RequestBodyLimitLayer::new(
2068 config.max_request_body,
2069 ));
2070
2071 let mut effective_origins = config.allowed_origins.clone();
2078 if effective_origins.is_empty()
2079 && let Some(ref url) = config.public_url
2080 {
2081 if let Some(scheme_end) = url.find("://") {
2086 let scheme_with_sep = url.get(..scheme_end + 3).unwrap_or_default();
2087 let after_scheme = url.get(scheme_end + 3..).unwrap_or_default();
2088 let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
2089 let host = after_scheme.get(..host_end).unwrap_or_default();
2090 let origin = format!("{scheme_with_sep}{host}");
2091 tracing::info!(
2092 %origin,
2093 "auto-derived allowed origin from public_url"
2094 );
2095 effective_origins.push(origin);
2096 }
2097 }
2098 let allowed_origins: Arc<[AllowedOrigin]> = Arc::from(
2101 effective_origins
2102 .iter()
2103 .filter_map(|origin| parse_allowed_origin(origin))
2104 .collect::<Vec<_>>(),
2105 );
2106 let cors_origins = Arc::clone(&allowed_origins);
2107 let log_request_headers = config.log_request_headers;
2108
2109 let readyz_route = if let Some(check) = config.readiness_check.take() {
2110 axum::routing::get(move || readyz(Arc::clone(&check)))
2111 } else {
2112 axum::routing::get(healthz)
2113 };
2114
2115 #[allow(
2116 unused_mut,
2117 reason = "the binding is only reassigned when the `oauth` feature adds the \
2118 protected-resource-metadata route below"
2119 )]
2120 let mut router = axum::Router::new()
2121 .route("/healthz", axum::routing::get(healthz))
2122 .route("/readyz", readyz_route)
2123 .route(
2124 "/version",
2125 axum::routing::get({
2126 let payload_bytes: Arc<[u8]> = serialize_version_payload(
2131 &config.name,
2132 &config.version,
2133 config.expose_build_metadata,
2134 );
2135 move || {
2136 let p = Arc::clone(&payload_bytes);
2137 async move {
2138 (
2139 [(axum::http::header::CONTENT_TYPE, "application/json")],
2140 p.to_vec(),
2141 )
2142 }
2143 }
2144 }),
2145 )
2146 .merge(mcp_router);
2147
2148 if let Some(extra) = config.extra_router.take() {
2155 let extra = match config.extra_route_rate_limit {
2156 Some(per_minute) => {
2157 let max_tracked_keys =
2158 NonZeroUsize::new(EXTRA_ROUTE_MAX_TRACKED_KEYS).unwrap_or(NonZeroUsize::MIN);
2159 let limiter = build_extra_route_rate_limiter_with_policy(
2160 per_minute,
2161 config.extra_route_rate_limit_burst,
2162 config.key_eviction_policy,
2163 max_tracked_keys,
2164 );
2165 let exempt: Arc<std::collections::HashSet<String>> = Arc::new(
2166 config
2167 .extra_route_rate_limit_exempt_paths
2168 .iter()
2169 .cloned()
2170 .collect(),
2171 );
2172 tracing::info!(
2173 per_minute,
2174 exempt_paths = exempt.len(),
2175 "extra-route per-IP rate limit enabled"
2176 );
2177 extra.layer(axum::middleware::from_fn(move |req, next| {
2178 let l = Arc::clone(&limiter);
2179 let e = Arc::clone(&exempt);
2180 extra_route_rate_limit_middleware(l, e, req, next)
2181 }))
2182 }
2183 None => extra,
2184 };
2185 router = router.merge(extra);
2186 }
2187
2188 let server_url = derive_server_url(&config);
2195 let resource_url = format!("{server_url}/mcp");
2196
2197 #[cfg(feature = "oauth")]
2198 let prm_metadata = if let Some(ref auth_config) = config.auth
2199 && let Some(ref oauth_config) = auth_config.oauth
2200 {
2201 crate::oauth::protected_resource_metadata(&resource_url, &server_url, oauth_config)
2202 } else {
2203 serde_json::json!({ "resource": resource_url })
2204 };
2205 #[cfg(not(feature = "oauth"))]
2206 let prm_metadata = serde_json::json!({ "resource": resource_url });
2207
2208 let prm_root = prm_metadata.clone();
2214 router = router.route(
2215 "/.well-known/oauth-protected-resource",
2216 axum::routing::get(move || {
2217 let m = prm_root.clone();
2218 async move { axum::Json(m) }
2219 }),
2220 );
2221 router = router.route(
2222 "/.well-known/oauth-protected-resource/mcp",
2223 axum::routing::get(move || {
2224 let m = prm_metadata.clone();
2225 async move { axum::Json(m) }
2226 }),
2227 );
2228
2229 #[cfg(feature = "oauth")]
2234 if let Some(ref auth_config) = config.auth
2235 && let Some(ref oauth_config) = auth_config.oauth
2236 && oauth_config.proxy.is_some()
2237 {
2238 router = install_oauth_proxy_routes(
2239 router,
2240 &server_url,
2241 oauth_config,
2242 auth_state.as_ref(),
2243 config.max_request_body,
2244 &config.admin_role,
2245 )?;
2246 }
2247
2248 if !cors_origins.is_empty() {
2257 let cors_allowed = Arc::clone(&cors_origins);
2262 let allow_origin = tower_http::cors::AllowOrigin::predicate(
2263 move |origin: &axum::http::HeaderValue, _parts: &axum::http::request::Parts| {
2264 origin
2265 .to_str()
2266 .is_ok_and(|value| request_origin_allowed(value, &cors_allowed))
2267 },
2268 );
2269 let cors = tower_http::cors::CorsLayer::new()
2270 .allow_origin(allow_origin)
2271 .allow_methods([
2272 axum::http::Method::GET,
2273 axum::http::Method::POST,
2274 axum::http::Method::OPTIONS,
2275 ])
2276 .allow_headers([
2277 axum::http::header::CONTENT_TYPE,
2278 axum::http::header::AUTHORIZATION,
2279 ]);
2280 router = router.layer(cors);
2281 }
2282
2283 if config.compression_enabled {
2287 use tower_http::compression::Predicate as _;
2288 let predicate = tower_http::compression::DefaultPredicate::new().and(
2289 tower_http::compression::predicate::SizeAbove::new(u64::from(
2290 config.compression_min_size,
2291 )),
2292 );
2293 router = router.layer(
2294 tower_http::compression::CompressionLayer::new()
2295 .gzip(true)
2296 .br(true)
2297 .compress_when(predicate),
2298 );
2299 tracing::info!(
2300 min_size = config.compression_min_size,
2301 "response compression enabled (gzip, br)"
2302 );
2303 }
2304
2305 if let Some(max) = config.max_concurrent_requests {
2308 let overload_handler = tower::ServiceBuilder::new()
2309 .layer(axum::error_handling::HandleErrorLayer::new(
2310 |_err: tower::BoxError| async {
2311 (
2312 axum::http::StatusCode::SERVICE_UNAVAILABLE,
2313 axum::Json(serde_json::json!({
2314 "error": "overloaded",
2315 "error_description": "server is at capacity, retry later"
2316 })),
2317 )
2318 },
2319 ))
2320 .layer(tower::load_shed::LoadShedLayer::new())
2321 .layer(tower::limit::ConcurrencyLimitLayer::new(max));
2322 router = router.layer(overload_handler);
2323 tracing::info!(max, "global concurrency limit enabled");
2324 }
2325
2326 router = router.fallback(|| async {
2330 (
2331 axum::http::StatusCode::NOT_FOUND,
2332 axum::Json(serde_json::json!({
2333 "error": "not_found",
2334 "error_description": "The requested endpoint does not exist"
2335 })),
2336 )
2337 });
2338
2339 #[cfg(feature = "metrics")]
2341 if config.metrics_enabled {
2342 let metrics: Arc<crate::metrics::McpMetrics> =
2345 if let Some(handle) = config.metrics_handle.take() {
2346 handle
2347 } else {
2348 Arc::new(
2349 crate::metrics::McpMetrics::new()
2350 .map_err(|e| anyhow::anyhow!("metrics init: {e}"))?,
2351 )
2352 };
2353 ensure_framework_metrics_registered(&metrics).map_err(|e| anyhow::anyhow!("{e}"))?;
2358 let m = Arc::clone(&metrics);
2359 router = router.layer(axum::middleware::from_fn(
2360 move |req: Request<Body>, next: Next| {
2361 let m = Arc::clone(&m);
2362 metrics_middleware(m, req, next)
2363 },
2364 ));
2365 let metrics_bind = config.metrics_bind.clone();
2366 let metrics_shutdown = ct.clone();
2367 tokio::spawn(async move {
2368 if let Err(e) =
2369 crate::metrics::serve_metrics(metrics_bind, metrics, metrics_shutdown).await
2370 {
2371 tracing::error!("metrics listener failed: {e}");
2372 }
2373 });
2374 }
2375
2376 let forward_resolver: Option<Arc<ForwardResolver>> = if config.trusted_proxies.is_empty() {
2384 None
2385 } else {
2386 Some(Arc::new(ForwardResolver {
2389 trusted: config
2390 .trusted_proxies
2391 .iter()
2392 .filter_map(|entry| parse_proxy_net(entry))
2393 .collect(),
2394 mode: config
2395 .forwarded_header
2396 .unwrap_or(ForwardedHeaderMode::XForwardedFor),
2397 max_scanned_entries: config.trusted_forwarder_max_entries,
2398 }))
2399 };
2400 if forward_resolver.is_some() {
2401 tracing::info!(
2402 proxies = config.trusted_proxies.len(),
2403 "trusted-forwarder mode enabled: limiters key by resolved client IP"
2404 );
2405 }
2406 router = router.layer(axum::middleware::from_fn(move |req, next| {
2407 let r = forward_resolver.clone();
2408 normalize_peer_addr_middleware(r, req, next)
2409 }));
2410
2411 router = router.layer(axum::middleware::from_fn(move |req, next| {
2423 let origins = Arc::clone(&allowed_origins);
2424 origin_check_middleware(origins, log_request_headers, req, next)
2425 }));
2426
2427 let is_tls = config.tls_cert_path.is_some();
2436 warn_security_header_overrides(&config.security_headers);
2437 let security_headers_cfg = Arc::new(config.security_headers.clone());
2438 router = router.layer(axum::middleware::from_fn(move |req, next| {
2439 let cfg = Arc::clone(&security_headers_cfg);
2440 security_headers_middleware(is_tls, cfg, req, next)
2441 }));
2442
2443 let scheme = if config.tls_cert_path.is_some() {
2444 "https"
2445 } else {
2446 "http"
2447 };
2448
2449 let tls_paths = match (&config.tls_cert_path, &config.tls_key_path) {
2450 (Some(cert), Some(key)) => Some((cert.clone(), key.clone())),
2451 _ => None,
2452 };
2453 let tls_handshake_timeout = config.tls_handshake_timeout;
2454 let max_concurrent_tls_handshakes = config.max_concurrent_tls_handshakes;
2455 let mtls_config = config.auth.as_ref().and_then(|a| a.mtls.as_ref()).cloned();
2456
2457 Ok((
2458 router,
2459 AppRunParams {
2460 tls_paths,
2461 tls_handshake_timeout,
2462 max_concurrent_tls_handshakes,
2463 mtls_config,
2464 shutdown_timeout: config.shutdown_timeout,
2465 auth_state,
2466 rbac_swap,
2467 on_reload_ready: config.on_reload_ready.take(),
2468 ct,
2469 session_ct,
2470 scheme,
2471 name: config.name.clone(),
2472 },
2473 ))
2474}
2475
2476struct CancelOnDrop(CancellationToken);
2489
2490impl Drop for CancelOnDrop {
2491 fn drop(&mut self) {
2492 self.0.cancel();
2493 }
2494}
2495
2496fn spawn_external_shutdown_bridge(
2500 external: CancellationToken,
2501 internal: CancellationToken,
2502) -> tokio::task::JoinHandle<()> {
2503 tokio::spawn(async move {
2504 tokio::select! {
2508 () = external.cancelled() => internal.cancel(),
2509 () = internal.cancelled() => {}
2510 }
2511 })
2512}
2513
2514pub async fn serve<H, F>(
2534 config: Validated<McpServerConfig>,
2535 handler_factory: F,
2536) -> Result<(), RmcpServerKitError>
2537where
2538 H: ServerHandler + 'static,
2539 F: Fn() -> H + Send + Sync + Clone + 'static,
2540{
2541 let config = config.into_inner();
2542 #[allow(
2543 deprecated,
2544 reason = "internal serve() reads `bind_addr` to construct the listener; field becomes pub(crate) in 1.0"
2545 )]
2546 let bind_addr = config.bind_addr.clone();
2547 let (router, params) = build_app_router(config, handler_factory).map_err(anyhow_to_startup)?;
2548 let _cancel_guard = CancelOnDrop(params.ct.clone());
2549
2550 let listener = TcpListener::bind(&bind_addr)
2551 .await
2552 .map_err(|e| io_to_startup(&format!("bind {bind_addr}"), e))?;
2553 log_listening(¶ms.name, params.scheme, &bind_addr);
2554
2555 run_server(
2556 router,
2557 listener,
2558 params.tls_paths,
2559 params.tls_handshake_timeout,
2560 params.max_concurrent_tls_handshakes,
2561 params.mtls_config,
2562 params.shutdown_timeout,
2563 params.auth_state,
2564 params.rbac_swap,
2565 params.on_reload_ready,
2566 params.ct,
2567 params.session_ct,
2568 )
2569 .await
2570 .map_err(anyhow_to_startup)
2571}
2572
2573pub async fn serve_with_listener<H, F>(
2606 listener: TcpListener,
2607 config: Validated<McpServerConfig>,
2608 handler_factory: F,
2609 ready_tx: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
2610 shutdown: Option<CancellationToken>,
2611) -> Result<(), RmcpServerKitError>
2612where
2613 H: ServerHandler + 'static,
2614 F: Fn() -> H + Send + Sync + Clone + 'static,
2615{
2616 let config = config.into_inner();
2617 let local_addr = listener
2618 .local_addr()
2619 .map_err(|e| io_to_startup("listener.local_addr", e))?;
2620 let (router, params) = build_app_router(config, handler_factory).map_err(anyhow_to_startup)?;
2621 let _cancel_guard = CancelOnDrop(params.ct.clone());
2622
2623 log_listening(¶ms.name, params.scheme, &local_addr.to_string());
2624
2625 if let Some(external) = shutdown {
2629 let _bridge_task = spawn_external_shutdown_bridge(external, params.ct.clone());
2630 }
2631
2632 if let Some(tx) = ready_tx {
2636 let _ = tx.send(local_addr);
2638 }
2639
2640 run_server(
2641 router,
2642 listener,
2643 params.tls_paths,
2644 params.tls_handshake_timeout,
2645 params.max_concurrent_tls_handshakes,
2646 params.mtls_config,
2647 params.shutdown_timeout,
2648 params.auth_state,
2649 params.rbac_swap,
2650 params.on_reload_ready,
2651 params.ct,
2652 params.session_ct,
2653 )
2654 .await
2655 .map_err(anyhow_to_startup)
2656}
2657
2658#[allow(
2661 clippy::cognitive_complexity,
2662 reason = "tracing::info! macro expansions inflate the score; logic is trivial"
2663)]
2664fn log_listening(name: &str, scheme: &str, addr: &str) {
2665 tracing::info!("{name} listening on {addr}");
2666 tracing::info!(" MCP endpoint: {scheme}://{addr}/mcp");
2667 tracing::info!(" Health check: {scheme}://{addr}/healthz");
2668 tracing::info!(" Readiness: {scheme}://{addr}/readyz");
2669}
2670
2671#[allow(
2694 clippy::too_many_arguments,
2695 clippy::cognitive_complexity,
2696 reason = "server start-up threads TLS, reload state, and graceful shutdown through one flow"
2697)]
2698async fn run_server(
2702 router: axum::Router,
2703 listener: TcpListener,
2704 tls_paths: Option<(PathBuf, PathBuf)>,
2705 tls_handshake_timeout: Duration,
2706 max_concurrent_tls_handshakes: usize,
2707 mtls_config: Option<MtlsConfig>,
2708 shutdown_timeout: Duration,
2709 auth_state: Option<Arc<AuthState>>,
2710 rbac_swap: Arc<ArcSwap<RbacPolicy>>,
2711 mut on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
2712 ct: CancellationToken,
2713 session_ct: CancellationToken,
2714) -> anyhow::Result<()> {
2715 let shutdown_trigger = CancellationToken::new();
2719 {
2720 let trigger = shutdown_trigger.clone();
2721 let parent = ct.clone();
2722 tokio::spawn(async move {
2723 tokio::select! {
2726 () = shutdown_signal() => {}
2727 () = parent.cancelled() => {}
2728 }
2729 trigger.cancel();
2730 });
2731 }
2732
2733 let graceful = {
2734 let trigger = shutdown_trigger.clone();
2735 let ct = ct.clone();
2736 async move {
2737 trigger.cancelled().await;
2738 tracing::info!("shutting down (grace period: {shutdown_timeout:?})");
2739 ct.cancel();
2740 }
2741 };
2742
2743 let force_exit_timer = {
2744 let trigger = shutdown_trigger.clone();
2745 async move {
2746 trigger.cancelled().await;
2747 tokio::time::sleep(shutdown_timeout).await;
2748 }
2749 };
2750
2751 if let Some((cert_path, key_path)) = tls_paths {
2752 let crl_set = if let Some(mtls) = mtls_config.as_ref()
2753 && mtls.crl_enabled
2754 {
2755 let (ca_certs, roots) = load_client_auth_roots(&mtls.ca_cert_path)?;
2756 let (crl_set, discover_rx) =
2757 mtls_revocation::bootstrap_fetch(roots, &ca_certs, mtls.clone())
2758 .await
2759 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
2760 tokio::spawn(mtls_revocation::run_crl_refresher(
2761 Arc::clone(&crl_set),
2762 discover_rx,
2763 ct.clone(),
2764 ));
2765 Some(crl_set)
2766 } else {
2767 None
2768 };
2769
2770 if let Some(cb) = on_reload_ready.take() {
2771 cb(ReloadHandle {
2772 auth: auth_state.clone(),
2773 rbac: Some(Arc::clone(&rbac_swap)),
2774 crl_set: crl_set.clone(),
2775 });
2776 }
2777
2778 let tls_listener = TlsListener::new(
2779 listener,
2780 &cert_path,
2781 &key_path,
2782 mtls_config.as_ref(),
2783 crl_set,
2784 tls_handshake_timeout,
2785 max_concurrent_tls_handshakes,
2786 )?;
2787 let make_svc = router.into_make_service_with_connect_info::<TlsConnInfo>();
2788 tokio::select! {
2791 result = axum::serve(tls_listener, make_svc)
2792 .with_graceful_shutdown(graceful) => { session_ct.cancel(); result?; }
2793 () = force_exit_timer => {
2794 tracing::warn!("shutdown timeout exceeded, forcing exit");
2795 session_ct.cancel();
2796 }
2797 }
2798 } else {
2799 if let Some(cb) = on_reload_ready.take() {
2800 cb(ReloadHandle {
2801 auth: auth_state,
2802 rbac: Some(rbac_swap),
2803 crl_set: None,
2804 });
2805 }
2806
2807 let make_svc = router.into_make_service_with_connect_info::<SocketAddr>();
2808 tokio::select! {
2811 result = axum::serve(listener, make_svc)
2812 .with_graceful_shutdown(graceful) => { session_ct.cancel(); result?; }
2813 () = force_exit_timer => {
2814 tracing::warn!("shutdown timeout exceeded, forcing exit");
2815 session_ct.cancel();
2816 }
2817 }
2818 }
2819
2820 Ok(())
2821}
2822
2823#[cfg(feature = "oauth")]
2832fn install_oauth_proxy_routes(
2833 router: axum::Router,
2834 server_url: &str,
2835 oauth_config: &crate::oauth::OAuthConfig,
2836 auth_state: Option<&Arc<AuthState>>,
2837 max_request_body: usize,
2838 admin_role: &str,
2839) -> Result<axum::Router, RmcpServerKitError> {
2840 let Some(ref proxy) = oauth_config.proxy else {
2841 return Ok(router);
2842 };
2843
2844 let http = crate::oauth::OauthHttpClient::with_config(oauth_config)?;
2847
2848 let proxy_router = axum::Router::new();
2854
2855 let asm = crate::oauth::authorization_server_metadata(server_url, oauth_config);
2856 let proxy_router = proxy_router.route(
2857 "/.well-known/oauth-authorization-server",
2858 axum::routing::get(move || {
2859 let m = asm.clone();
2860 async move { axum::Json(m) }
2861 }),
2862 );
2863
2864 let proxy_authorize = proxy.clone();
2865 let proxy_router = proxy_router.route(
2866 "/authorize",
2867 axum::routing::get(
2868 move |axum::extract::RawQuery(query): axum::extract::RawQuery| {
2869 let p = proxy_authorize.clone();
2870 async move { crate::oauth::handle_authorize(&p, &query.unwrap_or_default()) }
2871 },
2872 ),
2873 );
2874
2875 let proxy_token = proxy.clone();
2876 let token_http = http.clone();
2877 let proxy_router = proxy_router.route(
2878 "/token",
2879 axum::routing::post(move |body: String| {
2880 let p = proxy_token.clone();
2881 let h = token_http.clone();
2882 async move { crate::oauth::handle_token(&h, &p, &body).await }
2883 })
2884 .layer(axum::middleware::from_fn(
2885 oauth_token_cache_headers_middleware,
2886 )),
2887 );
2888
2889 let proxy_register = proxy.clone();
2890 let proxy_router = proxy_router.route(
2891 "/register",
2892 axum::routing::post(move |axum::Json(body): axum::Json<serde_json::Value>| {
2893 let p = proxy_register;
2894 async move { axum::Json(crate::oauth::handle_register(&p, &body)) }
2895 })
2896 .layer(axum::middleware::from_fn(
2897 oauth_token_cache_headers_middleware,
2898 )),
2899 );
2900
2901 let admin_routes_enabled = proxy.expose_admin_endpoints
2902 && (proxy.introspection_url.is_some() || proxy.revocation_url.is_some());
2903 if proxy.expose_admin_endpoints
2904 && !proxy.require_auth_on_admin_endpoints
2905 && proxy.allow_unauthenticated_admin_endpoints
2906 {
2907 tracing::warn!(
2911 "OAuth introspect/revoke endpoints are unauthenticated by explicit \
2912 allow_unauthenticated_admin_endpoints opt-out; ensure an \
2913 authenticated reverse proxy fronts these routes"
2914 );
2915 }
2916
2917 let admin_router = if admin_routes_enabled {
2918 build_oauth_admin_router(proxy, http, auth_state, admin_role)?
2919 } else {
2920 axum::Router::new()
2921 };
2922
2923 let proxy_router =
2927 proxy_router
2928 .merge(admin_router)
2929 .layer(tower_http::limit::RequestBodyLimitLayer::new(
2930 max_request_body,
2931 ));
2932
2933 let router = router.merge(proxy_router);
2934
2935 tracing::info!(
2936 introspect = proxy.expose_admin_endpoints && proxy.introspection_url.is_some(),
2937 revoke = proxy.expose_admin_endpoints && proxy.revocation_url.is_some(),
2938 max_request_body,
2939 "OAuth 2.1 proxy endpoints enabled (/authorize, /token, /register)"
2940 );
2941 Ok(router)
2942}
2943
2944#[cfg(feature = "oauth")]
2950fn build_oauth_admin_router(
2951 proxy: &crate::oauth::OAuthProxyConfig,
2952 http: crate::oauth::OauthHttpClient,
2953 auth_state: Option<&Arc<AuthState>>,
2954 admin_role: &str,
2955) -> Result<axum::Router, RmcpServerKitError> {
2956 let mut admin_router = axum::Router::new();
2957 if proxy.introspection_url.is_some() {
2958 let proxy_introspect = proxy.clone();
2959 let introspect_http = http.clone();
2960 admin_router = admin_router.route(
2961 "/introspect",
2962 axum::routing::post(move |body: String| {
2963 let p = proxy_introspect.clone();
2964 let h = introspect_http.clone();
2965 async move { crate::oauth::handle_introspect(&h, &p, &body).await }
2966 }),
2967 );
2968 }
2969 if proxy.revocation_url.is_some() {
2970 let proxy_revoke = proxy.clone();
2971 let revoke_http = http;
2972 admin_router = admin_router.route(
2973 "/revoke",
2974 axum::routing::post(move |body: String| {
2975 let p = proxy_revoke.clone();
2976 let h = revoke_http.clone();
2977 async move { crate::oauth::handle_revoke(&h, &p, &body).await }
2978 }),
2979 );
2980 }
2981
2982 let admin_router = admin_router.layer(axum::middleware::from_fn(
2983 oauth_token_cache_headers_middleware,
2984 ));
2985
2986 if proxy.require_auth_on_admin_endpoints {
2987 let Some(state) = auth_state else {
2988 return Err(RmcpServerKitError::Startup(
2989 "oauth proxy admin endpoints require auth state".into(),
2990 ));
2991 };
2992 let state_for_mw = Arc::clone(state);
2993 let required_role: Arc<str> = Arc::from(admin_role);
2994 Ok(admin_router
3000 .layer(axum::middleware::from_fn(move |req, next| {
3001 let r = Arc::clone(&required_role);
3002 crate::admin::require_admin_role(r, req, next)
3003 }))
3004 .layer(axum::middleware::from_fn(move |req, next| {
3005 let s = Arc::clone(&state_for_mw);
3006 auth_middleware(s, req, next)
3007 })))
3008 } else {
3009 Ok(admin_router)
3010 }
3011}
3012
3013#[allow(
3020 deprecated,
3021 reason = "internal metadata assembly reads deprecated `pub` config fields by design until 1.0 makes them pub(crate)"
3022)]
3023fn derive_server_url(config: &McpServerConfig) -> String {
3024 config.public_url.as_ref().map_or_else(
3025 || {
3026 let scheme = if config.tls_cert_path.is_some() {
3027 "https"
3028 } else {
3029 "http"
3030 };
3031 format!("{scheme}://{}", config.bind_addr)
3032 },
3033 |url| url.trim_end_matches('/').to_owned(),
3034 )
3035}
3036
3037fn derive_allowed_hosts(bind_addr: &str, public_url: Option<&str>) -> Vec<String> {
3042 let mut hosts = vec![
3043 "localhost".to_owned(),
3044 "127.0.0.1".to_owned(),
3045 "::1".to_owned(),
3046 ];
3047
3048 if let Some(url) = public_url
3049 && let Ok(uri) = url.parse::<axum::http::Uri>()
3050 && let Some(authority) = uri.authority()
3051 {
3052 let host = authority.host().to_owned();
3053 if !hosts.iter().any(|h| h == &host) {
3054 hosts.push(host);
3055 }
3056
3057 let authority = authority.as_str().to_owned();
3058 if !hosts.iter().any(|h| h == &authority) {
3059 hosts.push(authority);
3060 }
3061 }
3062
3063 if let Ok(uri) = format!("http://{bind_addr}").parse::<axum::http::Uri>()
3064 && let Some(authority) = uri.authority()
3065 {
3066 let host = authority.host().to_owned();
3067 if !hosts.iter().any(|h| h == &host) {
3068 hosts.push(host);
3069 }
3070
3071 let authority = authority.as_str().to_owned();
3072 if !hosts.iter().any(|h| h == &authority) {
3073 hosts.push(authority);
3074 }
3075 }
3076
3077 hosts
3078}
3079
3080impl axum::extract::connect_info::Connected<axum::serve::IncomingStream<'_, TlsListener>>
3093 for TlsConnInfo
3094{
3095 fn connect_info(target: axum::serve::IncomingStream<'_, TlsListener>) -> Self {
3096 let addr = *target.remote_addr();
3097 let identity = target.io().identity().cloned();
3098 Self::new(addr, identity)
3099 }
3100}
3101
3102const DEFAULT_TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
3109
3110const DEFAULT_MAX_CONCURRENT_TLS_HANDSHAKES: usize = 256;
3118
3119const TLS_ACCEPT_CHANNEL_CAPACITY: usize = 32;
3124
3125struct TlsListener {
3141 local_addr: SocketAddr,
3144 rx: mpsc::Receiver<(AuthenticatedTlsStream, SocketAddr)>,
3146 acceptor_task: tokio::task::JoinHandle<()>,
3149}
3150
3151impl TlsListener {
3152 fn new(
3153 inner: TcpListener,
3154 cert_path: &Path,
3155 key_path: &Path,
3156 mtls_config: Option<&MtlsConfig>,
3157 crl_set: Option<Arc<CrlSet>>,
3158 handshake_timeout: Duration,
3159 max_concurrent_handshakes: usize,
3160 ) -> anyhow::Result<Self> {
3161 rustls::crypto::ring::default_provider()
3163 .install_default()
3164 .ok();
3165
3166 let certs = load_certs(cert_path)?;
3167 let key = load_key(key_path)?;
3168
3169 let mtls_default_role;
3170
3171 let tls_config = if let Some(mtls) = mtls_config {
3172 mtls_default_role = mtls.default_role.clone();
3173 let verifier: Arc<dyn rustls::server::danger::ClientCertVerifier> = if mtls.crl_enabled
3174 {
3175 let Some(crl_set) = crl_set else {
3176 return Err(anyhow::anyhow!(
3177 "mTLS CRL verifier requested but CRL state was not initialized"
3178 ));
3179 };
3180 Arc::new(DynamicClientCertVerifier::new(crl_set))
3181 } else {
3182 let (_, root_store) = load_client_auth_roots(&mtls.ca_cert_path)?;
3183 if mtls.required {
3184 rustls::server::WebPkiClientVerifier::builder(root_store)
3185 .build()
3186 .map_err(|e| anyhow::anyhow!("mTLS verifier error: {e}"))?
3187 } else {
3188 rustls::server::WebPkiClientVerifier::builder(root_store)
3189 .allow_unauthenticated()
3190 .build()
3191 .map_err(|e| anyhow::anyhow!("mTLS verifier error: {e}"))?
3192 }
3193 };
3194
3195 tracing::info!(
3196 ca = %mtls.ca_cert_path.display(),
3197 required = mtls.required,
3198 crl_enabled = mtls.crl_enabled,
3199 "mTLS client auth configured"
3200 );
3201
3202 rustls::ServerConfig::builder_with_protocol_versions(&[
3203 &rustls::version::TLS12,
3204 &rustls::version::TLS13,
3205 ])
3206 .with_client_cert_verifier(verifier)
3207 .with_single_cert(certs, key)?
3208 } else {
3209 mtls_default_role = "viewer".to_owned();
3210 rustls::ServerConfig::builder_with_protocol_versions(&[
3211 &rustls::version::TLS12,
3212 &rustls::version::TLS13,
3213 ])
3214 .with_no_client_auth()
3215 .with_single_cert(certs, key)?
3216 };
3217
3218 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
3219 tracing::info!(
3220 "TLS enabled (cert: {}, key: {})",
3221 cert_path.display(),
3222 key_path.display()
3223 );
3224 let local_addr = inner.local_addr()?;
3225 let (tx, rx) = mpsc::channel(TLS_ACCEPT_CHANNEL_CAPACITY);
3226 let acceptor_task = tokio::spawn(run_tls_acceptor(
3227 inner,
3228 acceptor,
3229 mtls_default_role,
3230 tx,
3231 handshake_timeout,
3232 max_concurrent_handshakes,
3233 ));
3234 Ok(Self {
3235 local_addr,
3236 rx,
3237 acceptor_task,
3238 })
3239 }
3240
3241 fn extract_handshake_identity(
3245 tls_stream: &tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
3246 default_role: &str,
3247 addr: SocketAddr,
3248 ) -> Option<AuthIdentity> {
3249 let (_, server_conn) = tls_stream.get_ref();
3250 let cert_der = server_conn.peer_certificates()?.first()?;
3251 let id = extract_mtls_identity(cert_der.as_ref(), default_role)?;
3252 tracing::debug!(name = %id.name, peer = %addr, "mTLS client cert accepted");
3253 Some(id)
3254 }
3255}
3256
3257async fn run_tls_acceptor(
3268 listener: TcpListener,
3269 acceptor: tokio_rustls::TlsAcceptor,
3270 default_role: String,
3271 tx: mpsc::Sender<(AuthenticatedTlsStream, SocketAddr)>,
3272 handshake_timeout: Duration,
3273 max_concurrent_handshakes: usize,
3274) {
3275 let inflight = Arc::new(Semaphore::new(max_concurrent_handshakes));
3276 loop {
3277 let Ok(permit) = Arc::clone(&inflight).acquire_owned().await else {
3281 return;
3283 };
3284 let (stream, addr) = match listener.accept().await {
3285 Ok(pair) => pair,
3286 Err(e) => {
3287 tracing::debug!("TCP accept error: {e}");
3288 continue;
3289 }
3290 };
3291 if tx.is_closed() {
3292 return;
3294 }
3295 let acceptor = acceptor.clone();
3296 let default_role = default_role.clone();
3297 let tx = tx.clone();
3298 tokio::spawn(async move {
3299 let _permit = permit;
3300 match tokio::time::timeout(handshake_timeout, acceptor.accept(stream)).await {
3301 Ok(Ok(tls_stream)) => {
3302 let identity =
3303 TlsListener::extract_handshake_identity(&tls_stream, &default_role, addr);
3304 let wrapped = AuthenticatedTlsStream {
3305 inner: tls_stream,
3306 identity,
3307 };
3308 let _ = tx.send((wrapped, addr)).await;
3311 }
3312 Ok(Err(e)) => {
3313 tracing::debug!("TLS handshake failed from {addr}: {e}");
3314 }
3315 Err(_elapsed) => {
3316 tracing::debug!(
3317 "TLS handshake timed out from {addr} after {handshake_timeout:?}"
3318 );
3319 }
3320 }
3321 });
3322 }
3323}
3324
3325pub(crate) struct AuthenticatedTlsStream {
3337 inner: tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
3338 identity: Option<AuthIdentity>,
3339}
3340
3341impl AuthenticatedTlsStream {
3342 #[must_use]
3344 pub(crate) const fn identity(&self) -> Option<&AuthIdentity> {
3345 self.identity.as_ref()
3346 }
3347}
3348
3349impl std::fmt::Debug for AuthenticatedTlsStream {
3350 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3351 f.debug_struct("AuthenticatedTlsStream")
3352 .field("identity", &self.identity.as_ref().map(|id| &id.name))
3353 .finish_non_exhaustive()
3354 }
3355}
3356
3357impl tokio::io::AsyncRead for AuthenticatedTlsStream {
3358 fn poll_read(
3359 mut self: Pin<&mut Self>,
3360 cx: &mut std::task::Context<'_>,
3361 buf: &mut tokio::io::ReadBuf<'_>,
3362 ) -> std::task::Poll<std::io::Result<()>> {
3363 Pin::new(&mut self.inner).poll_read(cx, buf)
3364 }
3365}
3366
3367impl tokio::io::AsyncWrite for AuthenticatedTlsStream {
3368 fn poll_write(
3369 mut self: Pin<&mut Self>,
3370 cx: &mut std::task::Context<'_>,
3371 buf: &[u8],
3372 ) -> std::task::Poll<std::io::Result<usize>> {
3373 Pin::new(&mut self.inner).poll_write(cx, buf)
3374 }
3375
3376 fn poll_flush(
3377 mut self: Pin<&mut Self>,
3378 cx: &mut std::task::Context<'_>,
3379 ) -> std::task::Poll<std::io::Result<()>> {
3380 Pin::new(&mut self.inner).poll_flush(cx)
3381 }
3382
3383 fn poll_shutdown(
3384 mut self: Pin<&mut Self>,
3385 cx: &mut std::task::Context<'_>,
3386 ) -> std::task::Poll<std::io::Result<()>> {
3387 Pin::new(&mut self.inner).poll_shutdown(cx)
3388 }
3389
3390 fn poll_write_vectored(
3391 mut self: Pin<&mut Self>,
3392 cx: &mut std::task::Context<'_>,
3393 bufs: &[std::io::IoSlice<'_>],
3394 ) -> std::task::Poll<std::io::Result<usize>> {
3395 Pin::new(&mut self.inner).poll_write_vectored(cx, bufs)
3396 }
3397
3398 fn is_write_vectored(&self) -> bool {
3399 self.inner.is_write_vectored()
3400 }
3401}
3402
3403impl axum::serve::Listener for TlsListener {
3404 type Io = AuthenticatedTlsStream;
3405 type Addr = SocketAddr;
3406
3407 async fn accept(&mut self) -> (Self::Io, Self::Addr) {
3413 if let Some(pair) = self.rx.recv().await {
3414 return pair;
3415 }
3416 tracing::error!("TLS acceptor task terminated; no further connections will be accepted");
3422 std::future::pending().await
3423 }
3424
3425 fn local_addr(&self) -> std::io::Result<Self::Addr> {
3426 Ok(self.local_addr)
3427 }
3428}
3429
3430impl Drop for TlsListener {
3431 fn drop(&mut self) {
3432 self.acceptor_task.abort();
3435 }
3436}
3437
3438fn load_certs(path: &Path) -> anyhow::Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
3439 use rustls::pki_types::pem::PemObject;
3440 let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_file_iter(path)
3441 .map_err(|e| anyhow::anyhow!("failed to read certs from {}: {e}", path.display()))?
3442 .collect::<Result<_, _>>()
3443 .map_err(|e| anyhow::anyhow!("invalid cert in {}: {e}", path.display()))?;
3444 anyhow::ensure!(
3445 !certs.is_empty(),
3446 "no certificates found in {}",
3447 path.display()
3448 );
3449 Ok(certs)
3450}
3451
3452fn load_client_auth_roots(
3453 path: &Path,
3454) -> anyhow::Result<(
3455 Vec<rustls::pki_types::CertificateDer<'static>>,
3456 Arc<RootCertStore>,
3457)> {
3458 let ca_certs = load_certs(path)?;
3459 let mut root_store = RootCertStore::empty();
3460 for cert in &ca_certs {
3461 root_store
3462 .add(cert.clone())
3463 .map_err(|error| anyhow::anyhow!("invalid CA cert: {error}"))?;
3464 }
3465
3466 Ok((ca_certs, Arc::new(root_store)))
3467}
3468
3469fn load_key(path: &Path) -> anyhow::Result<rustls::pki_types::PrivateKeyDer<'static>> {
3470 use rustls::pki_types::pem::PemObject;
3471 rustls::pki_types::PrivateKeyDer::from_pem_file(path)
3472 .map_err(|e| anyhow::anyhow!("failed to read key from {}: {e}", path.display()))
3473}
3474
3475#[allow(
3477 clippy::unused_async,
3478 reason = "axum route handler signature requires `async fn` even when the body is synchronous"
3479)]
3480async fn healthz() -> impl IntoResponse {
3481 axum::Json(serde_json::json!({
3482 "status": "ok",
3483 }))
3484}
3485
3486fn version_payload(name: &str, version: &str, expose_build_metadata: bool) -> serde_json::Value {
3496 let mut map = serde_json::Map::new();
3497 map.insert("name".into(), name.into());
3498 map.insert("version".into(), version.into());
3499 map.insert(
3500 "rmcp_server_kit_version".into(),
3501 env!("CARGO_PKG_VERSION").into(),
3502 );
3503 if expose_build_metadata {
3504 map.insert(
3505 "build_git_sha".into(),
3506 option_env!("RMCP_SERVER_KIT_BUILD_SHA")
3507 .unwrap_or("unknown")
3508 .into(),
3509 );
3510 map.insert(
3511 "build_timestamp".into(),
3512 option_env!("RMCP_SERVER_KIT_BUILD_TIME")
3513 .unwrap_or("unknown")
3514 .into(),
3515 );
3516 map.insert(
3517 "rust_version".into(),
3518 option_env!("RMCP_SERVER_KIT_RUSTC_VERSION")
3519 .unwrap_or("unknown")
3520 .into(),
3521 );
3522 }
3523 serde_json::Value::Object(map)
3524}
3525
3526fn serialize_version_payload(name: &str, version: &str, expose_build_metadata: bool) -> Arc<[u8]> {
3536 let value = version_payload(name, version, expose_build_metadata);
3537 serde_json::to_vec(&value).map_or_else(|_| Arc::from(&b"{}"[..]), Arc::from)
3538}
3539
3540async fn readyz(check: ReadinessCheck) -> impl IntoResponse {
3545 let status = check().await;
3546 let ready = status
3547 .get("ready")
3548 .and_then(serde_json::Value::as_bool)
3549 .unwrap_or(false);
3550 let code = if ready {
3551 axum::http::StatusCode::OK
3552 } else {
3553 axum::http::StatusCode::SERVICE_UNAVAILABLE
3554 };
3555 (code, axum::Json(status))
3556}
3557
3558async fn shutdown_signal() {
3562 let ctrl_c = tokio::signal::ctrl_c();
3563
3564 #[cfg(unix)]
3565 {
3566 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
3567 Ok(mut term) => {
3568 tokio::select! {
3571 _ = ctrl_c => {}
3572 _ = term.recv() => {}
3573 }
3574 }
3575 Err(e) => {
3576 tracing::warn!(error = %e, "failed to register SIGTERM handler, using SIGINT only");
3577 ctrl_c.await.ok();
3578 }
3579 }
3580 }
3581
3582 #[cfg(not(unix))]
3583 {
3584 ctrl_c.await.ok();
3585 }
3586}
3587
3588#[cfg(feature = "metrics")]
3605fn metrics_labels(req: &Request<Body>) -> (&'static str, String) {
3606 let method = match *req.method() {
3607 axum::http::Method::GET => "GET",
3608 axum::http::Method::POST => "POST",
3609 axum::http::Method::PUT => "PUT",
3610 axum::http::Method::PATCH => "PATCH",
3611 axum::http::Method::DELETE => "DELETE",
3612 axum::http::Method::HEAD => "HEAD",
3613 axum::http::Method::OPTIONS => "OPTIONS",
3614 axum::http::Method::TRACE => "TRACE",
3615 axum::http::Method::CONNECT => "CONNECT",
3616 _ => "OTHER",
3619 };
3620
3621 let path = req
3622 .extensions()
3623 .get::<axum::extract::MatchedPath>()
3624 .map_or_else(
3625 || {
3626 let raw = req.uri().path();
3627 if raw == "/mcp" || raw.starts_with("/mcp/") {
3628 "/mcp".to_owned()
3629 } else {
3630 "<unmatched>".to_owned()
3631 }
3632 },
3633 |matched| matched.as_str().to_owned(),
3634 );
3635
3636 (method, path)
3637}
3638
3639#[cfg(feature = "metrics")]
3655fn ensure_framework_metrics_registered(
3656 metrics: &crate::metrics::McpMetrics,
3657) -> Result<(), RmcpServerKitError> {
3658 use prometheus::core::Collector;
3659
3660 type BoxedFactory<'a> = &'a dyn Fn() -> Box<dyn Collector>;
3664 let factories: [BoxedFactory<'_>; 3] = [
3665 &|| -> Box<dyn Collector> { Box::new(metrics.http_requests_total.clone()) },
3666 &|| -> Box<dyn Collector> { Box::new(metrics.http_request_duration_seconds.clone()) },
3667 &|| -> Box<dyn Collector> { Box::new(metrics.rate_limited_total.clone()) },
3668 ];
3669
3670 for make in factories {
3671 let _ = metrics.registry.unregister(make());
3674 metrics.registry.register(make()).map_err(|error| {
3675 RmcpServerKitError::Startup(format!(
3676 "metrics registry conflict on reserved rmcp_server_kit_* name: {error}"
3677 ))
3678 })?;
3679 }
3680 Ok(())
3681}
3682
3683#[cfg(feature = "metrics")]
3693async fn metrics_middleware(
3694 metrics: Arc<crate::metrics::McpMetrics>,
3695 mut req: Request<Body>,
3696 next: Next,
3697) -> axum::response::Response {
3698 let (method, path) = metrics_labels(&req);
3699 let start = std::time::Instant::now();
3700
3701 req.extensions_mut().insert(Arc::clone(&metrics));
3702 let response = next.run(req).await;
3703
3704 let mut status_buf = core::fmt::NumBuffer::<u16>::new();
3705 let status = response.status().as_u16().format_into(&mut status_buf);
3706 let duration = start.elapsed().as_secs_f64();
3707
3708 metrics
3709 .http_requests_total
3710 .with_label_values(&[method, &path, status])
3711 .inc();
3712 metrics
3713 .http_request_duration_seconds
3714 .with_label_values(&[method, &path])
3715 .observe(duration);
3716
3717 response
3718}
3719
3720async fn security_headers_middleware(
3734 is_tls: bool,
3735 cfg: Arc<SecurityHeadersConfig>,
3736 req: Request<Body>,
3737 next: Next,
3738) -> axum::response::Response {
3739 use axum::http::{HeaderName, header};
3740
3741 let mut resp = next.run(req).await;
3742 let headers = resp.headers_mut();
3743
3744 headers.remove(header::SERVER);
3746 headers.remove(HeaderName::from_static("x-powered-by"));
3747
3748 apply_security_header(
3749 headers,
3750 header::X_CONTENT_TYPE_OPTIONS,
3751 cfg.x_content_type_options.as_deref(),
3752 "nosniff",
3753 );
3754 apply_security_header(
3755 headers,
3756 header::X_FRAME_OPTIONS,
3757 cfg.x_frame_options.as_deref(),
3758 "deny",
3759 );
3760 apply_security_header(
3761 headers,
3762 header::CACHE_CONTROL,
3763 cfg.cache_control.as_deref(),
3764 "no-store, max-age=0",
3765 );
3766 apply_security_header(
3767 headers,
3768 header::REFERRER_POLICY,
3769 cfg.referrer_policy.as_deref(),
3770 "no-referrer",
3771 );
3772 apply_security_header(
3773 headers,
3774 HeaderName::from_static("cross-origin-opener-policy"),
3775 cfg.cross_origin_opener_policy.as_deref(),
3776 "same-origin",
3777 );
3778 apply_security_header(
3779 headers,
3780 HeaderName::from_static("cross-origin-resource-policy"),
3781 cfg.cross_origin_resource_policy.as_deref(),
3782 "same-origin",
3783 );
3784 apply_security_header(
3785 headers,
3786 HeaderName::from_static("cross-origin-embedder-policy"),
3787 cfg.cross_origin_embedder_policy.as_deref(),
3788 "require-corp",
3789 );
3790 apply_security_header(
3791 headers,
3792 HeaderName::from_static("permissions-policy"),
3793 cfg.permissions_policy.as_deref(),
3794 "accelerometer=(), camera=(), geolocation=(), microphone=()",
3795 );
3796 apply_security_header(
3797 headers,
3798 HeaderName::from_static("x-permitted-cross-domain-policies"),
3799 cfg.x_permitted_cross_domain_policies.as_deref(),
3800 "none",
3801 );
3802 apply_security_header(
3803 headers,
3804 HeaderName::from_static("content-security-policy"),
3805 cfg.content_security_policy.as_deref(),
3806 "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests",
3807 );
3808 apply_security_header(
3809 headers,
3810 HeaderName::from_static("x-dns-prefetch-control"),
3811 cfg.x_dns_prefetch_control.as_deref(),
3812 "off",
3813 );
3814
3815 if is_tls {
3816 apply_security_header(
3817 headers,
3818 header::STRICT_TRANSPORT_SECURITY,
3819 cfg.strict_transport_security.as_deref(),
3820 "max-age=63072000; includeSubDomains",
3821 );
3822 }
3823
3824 resp
3825}
3826
3827fn apply_security_header(
3838 headers: &mut axum::http::HeaderMap,
3839 name: axum::http::HeaderName,
3840 override_value: Option<&str>,
3841 default: &'static str,
3842) {
3843 use axum::http::HeaderValue;
3844
3845 match override_value {
3846 None => {
3847 headers.insert(name, HeaderValue::from_static(default));
3848 }
3849 Some("") => {
3850 }
3852 Some(v) => match HeaderValue::from_str(v) {
3853 Ok(hv) => {
3854 headers.insert(name, hv);
3855 }
3856 Err(err) => {
3857 tracing::error!(
3858 header = %name,
3859 error = %err,
3860 "invalid security header override reached middleware; using default"
3861 );
3862 headers.insert(name, HeaderValue::from_static(default));
3863 }
3864 },
3865 }
3866}
3867
3868pub(crate) fn validate_security_headers(
3879 cfg: &SecurityHeadersConfig,
3880) -> Result<(), RmcpServerKitError> {
3881 use axum::http::HeaderValue;
3882
3883 let fields: &[(&str, Option<&str>)] = &[
3884 (
3885 "x_content_type_options",
3886 cfg.x_content_type_options.as_deref(),
3887 ),
3888 ("x_frame_options", cfg.x_frame_options.as_deref()),
3889 ("cache_control", cfg.cache_control.as_deref()),
3890 ("referrer_policy", cfg.referrer_policy.as_deref()),
3891 (
3892 "cross_origin_opener_policy",
3893 cfg.cross_origin_opener_policy.as_deref(),
3894 ),
3895 (
3896 "cross_origin_resource_policy",
3897 cfg.cross_origin_resource_policy.as_deref(),
3898 ),
3899 (
3900 "cross_origin_embedder_policy",
3901 cfg.cross_origin_embedder_policy.as_deref(),
3902 ),
3903 ("permissions_policy", cfg.permissions_policy.as_deref()),
3904 (
3905 "x_permitted_cross_domain_policies",
3906 cfg.x_permitted_cross_domain_policies.as_deref(),
3907 ),
3908 (
3909 "content_security_policy",
3910 cfg.content_security_policy.as_deref(),
3911 ),
3912 (
3913 "x_dns_prefetch_control",
3914 cfg.x_dns_prefetch_control.as_deref(),
3915 ),
3916 (
3917 "strict_transport_security",
3918 cfg.strict_transport_security.as_deref(),
3919 ),
3920 ];
3921
3922 for (field, value) in fields {
3923 let Some(v) = value else { continue };
3924 if v.is_empty() {
3925 continue;
3926 }
3927 if let Err(err) = HeaderValue::from_str(v) {
3928 return Err(RmcpServerKitError::Config(format!(
3929 "invalid security_headers.{field}: {err}"
3930 )));
3931 }
3932 }
3933
3934 if let Some(v) = cfg.strict_transport_security.as_deref()
3935 && !v.is_empty()
3936 && v.to_ascii_lowercase().contains("preload")
3937 {
3938 return Err(RmcpServerKitError::Config(format!(
3939 "invalid security_headers.strict_transport_security: {v:?} contains the `preload` directive; \
3940 HSTS preload must be opted into explicitly via a dedicated builder, not via this knob"
3941 )));
3942 }
3943
3944 Ok(())
3945}
3946
3947#[cfg(feature = "oauth")]
3962async fn oauth_token_cache_headers_middleware(
3963 req: Request<Body>,
3964 next: Next,
3965) -> axum::response::Response {
3966 use axum::http::{HeaderValue, header};
3967
3968 let mut resp = next.run(req).await;
3969 let headers = resp.headers_mut();
3970 headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
3971 headers.append(header::VARY, HeaderValue::from_static("Authorization"));
3972 resp
3973}
3974
3975async fn normalize_peer_addr_middleware(
4006 resolver: Option<Arc<ForwardResolver>>,
4007 mut req: Request<Body>,
4008 next: Next,
4009) -> axum::response::Response {
4010 let direct = req
4011 .extensions()
4012 .get::<ConnectInfo<SocketAddr>>()
4013 .map(|ci| ci.0);
4014 let from_tls = req
4015 .extensions()
4016 .get::<ConnectInfo<TlsConnInfo>>()
4017 .map(|ci| ci.0.addr);
4018 if let Some(addr) = direct.or(from_tls) {
4019 if direct.is_none() {
4020 req.extensions_mut().insert(ConnectInfo(addr));
4021 }
4022 req.extensions_mut().insert(PeerAddr::new(addr));
4023 let client_ip = match &resolver {
4024 Some(r) => crate::forwarded::resolve_client_ip(
4025 addr.ip(),
4026 req.headers(),
4027 &r.trusted,
4028 r.mode,
4029 r.max_scanned_entries,
4030 )
4031 .unwrap_or_else(|reason| {
4032 tracing::debug!(
4033 reason = ?reason,
4034 "forwarded-header resolution fell back to direct peer"
4035 );
4036 addr.ip()
4037 }),
4038 None => addr.ip(),
4039 };
4040 req.extensions_mut().insert(ClientIp::new(client_ip));
4041 }
4042 next.run(req).await
4043}
4044
4045fn parse_proxy_net(entry: &str) -> Option<ipnet::IpNet> {
4048 if let Ok(net) = entry.parse::<ipnet::IpNet>() {
4049 return Some(net);
4050 }
4051 entry.parse::<IpAddr>().ok().map(ipnet::IpNet::from)
4052}
4053
4054pub(crate) fn validate_trusted_proxy_entry(entry: &str) -> Result<(), String> {
4064 match parse_proxy_net(entry) {
4065 None => Err(format!(
4066 "trusted_proxies entry {entry:?} is neither a CIDR nor an IP address"
4067 )),
4068 Some(net) if net.prefix_len() == 0 => Err(format!(
4069 "trusted_proxies entry {entry:?}: prefix length 0 is forbidden (marks every peer trusted, enabling client-IP spoofing)"
4070 )),
4071 Some(_) => Ok(()),
4072 }
4073}
4074
4075pub(crate) fn limiter_client_ip(extensions: &axum::http::Extensions) -> Option<IpAddr> {
4079 if let Some(client) = extensions.get::<ClientIp>() {
4080 return Some(client.ip);
4081 }
4082 extensions
4083 .get::<ConnectInfo<SocketAddr>>()
4084 .map(|ci| ci.0.ip())
4085 .or_else(|| {
4086 extensions
4087 .get::<ConnectInfo<TlsConnInfo>>()
4088 .map(|ci| ci.0.addr.ip())
4089 })
4090}
4091
4092#[derive(Clone, PartialEq, Eq, Hash, Debug)]
4105pub(crate) enum RateLimitKey {
4106 Ip(IpAddr),
4108 Unattributed,
4110}
4111
4112impl std::fmt::Display for RateLimitKey {
4113 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4114 match self {
4115 Self::Ip(ip) => write!(f, "{ip}"),
4116 Self::Unattributed => f.write_str("unattributed"),
4117 }
4118 }
4119}
4120
4121static UNATTRIBUTED_WARNED: std::sync::atomic::AtomicBool =
4123 std::sync::atomic::AtomicBool::new(false);
4124
4125pub(crate) fn limiter_client_key(extensions: &axum::http::Extensions) -> RateLimitKey {
4136 if let Some(ip) = limiter_client_ip(extensions) {
4137 return RateLimitKey::Ip(ip);
4138 }
4139 if !UNATTRIBUTED_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
4140 tracing::warn!(
4141 "request carries no resolvable client address; rate limiting is \
4142 falling back to a single shared bucket. This indicates \
4143 rmcp-server-kit middleware composed outside serve()."
4144 );
4145 }
4146 RateLimitKey::Unattributed
4147}
4148
4149pub(crate) type ExtraRouteRateLimiter = BoundedKeyedLimiter<RateLimitKey>;
4153
4154const EXTRA_ROUTE_MAX_TRACKED_KEYS: usize = 10_000;
4160
4161const EXTRA_ROUTE_IDLE_EVICTION: Duration = Duration::from_mins(15);
4164
4165fn build_extra_route_rate_limiter_with_policy(
4172 per_minute: u32,
4173 burst: Option<u32>,
4174 key_eviction_policy: KeyEvictionPolicy,
4175 max_tracked_keys: NonZeroUsize,
4176) -> Arc<ExtraRouteRateLimiter> {
4177 let rate = std::num::NonZeroU32::new(per_minute.max(1)).unwrap_or(std::num::NonZeroU32::MIN);
4178 let mut quota = governor::Quota::per_minute(rate);
4179 if let Some(b) = burst.and_then(std::num::NonZeroU32::new) {
4180 quota = quota.allow_burst(b);
4181 }
4182 Arc::new(BoundedKeyedLimiter::new_with_policy(
4183 quota,
4184 max_tracked_keys,
4185 EXTRA_ROUTE_IDLE_EVICTION,
4186 key_eviction_policy,
4187 ))
4188}
4189
4190async fn extra_route_rate_limit_middleware(
4215 limiter: Arc<ExtraRouteRateLimiter>,
4216 exempt: Arc<std::collections::HashSet<String>>,
4217 req: Request<Body>,
4218 next: Next,
4219) -> axum::response::Response {
4220 if exempt.contains(req.uri().path()) {
4221 return next.run(req).await;
4222 }
4223 let peer_key = limiter_client_key(req.extensions());
4224 match limiter.check_key_detailed(&peer_key) {
4225 Ok(()) => {}
4226 Err(BoundedLimiterDeny::RateLimited(wait)) => {
4227 #[cfg(feature = "metrics")]
4228 crate::metrics::record_rate_limit_deny(req.extensions(), "extra_route");
4229 tracing::warn!(rate_limit_key = %peer_key, "extra route request rate limited");
4230 return RmcpServerKitError::RateLimitedFor {
4231 message: "too many requests to application routes from this source".into(),
4232 retry_after: wait,
4233 }
4234 .into_response();
4235 }
4236 Err(BoundedLimiterDeny::CapacityFull) => {
4237 tracing::warn!(
4238 rate_limit_key = %peer_key,
4239 "extra route limiter rejected unseen key because tracked-key capacity is full"
4240 );
4241 return (
4242 axum::http::StatusCode::SERVICE_UNAVAILABLE,
4243 "rate limiter capacity exhausted",
4244 )
4245 .into_response();
4246 }
4247 }
4248 next.run(req).await
4249}
4250
4251#[derive(Debug, Clone, PartialEq, Eq)]
4257enum AllowedOrigin {
4258 Tuple(String, String, u16),
4260 Null,
4262}
4263
4264fn parse_request_origin_tuple(value: &str) -> Option<(String, String, u16)> {
4271 parse_origin(value, false)
4272}
4273
4274fn parse_config_origin_tuple(value: &str) -> Option<(String, String, u16)> {
4277 parse_origin(value, true)
4278}
4279
4280fn parse_port_token(token: &str) -> Option<u16> {
4286 if token.is_empty() || !token.bytes().all(|byte| byte.is_ascii_digit()) {
4287 return None;
4288 }
4289 if token.len() > 1 && token.starts_with('0') {
4290 return None;
4291 }
4292 match token.parse::<u16>() {
4293 Ok(0) | Err(_) => None,
4294 Ok(port) => Some(port),
4295 }
4296}
4297
4298fn parse_origin(value: &str, allow_root_slash: bool) -> Option<(String, String, u16)> {
4301 let (scheme, rest) = value.split_once("://")?;
4302 let scheme = scheme.to_ascii_lowercase();
4303 let default_port = match scheme.as_str() {
4304 "http" => 80,
4305 "https" => 443,
4306 _ => return None,
4307 };
4308
4309 if rest.is_empty() {
4310 return None;
4311 }
4312 let rest = if allow_root_slash {
4313 rest.strip_suffix('/').unwrap_or(rest)
4314 } else {
4315 rest
4316 };
4317 if rest.is_empty() || rest.contains(['/', '?', '#']) {
4318 return None;
4319 }
4320
4321 let (host, port) = if let Some(after_bracket) = rest.strip_prefix('[') {
4322 let (inside, tail) = after_bracket.split_once(']')?;
4324 if inside.is_empty() {
4325 return None;
4326 }
4327 let port = if tail.is_empty() {
4328 default_port
4329 } else {
4330 parse_port_token(tail.strip_prefix(':')?)?
4331 };
4332 (format!("[{inside}]"), port)
4333 } else {
4334 if rest.matches(':').count() > 1 {
4335 return None;
4337 }
4338 match rest.split_once(':') {
4339 Some((host, port)) => {
4340 if host.is_empty() {
4341 return None;
4342 }
4343 (host.to_owned(), parse_port_token(port)?)
4344 }
4345 None => (rest.to_owned(), default_port),
4346 }
4347 };
4348
4349 if host.is_empty() || host.contains(|c: char| c.is_whitespace() || c.is_control()) {
4350 return None;
4351 }
4352 if port == 0 {
4353 return None;
4354 }
4355 Some((scheme, host.to_ascii_lowercase(), port))
4356}
4357
4358fn parse_allowed_origin(value: &str) -> Option<AllowedOrigin> {
4364 if value.eq_ignore_ascii_case("null") {
4365 return Some(AllowedOrigin::Null);
4366 }
4367 parse_config_origin_tuple(value)
4368 .map(|(scheme, host, port)| AllowedOrigin::Tuple(scheme, host, port))
4369}
4370
4371pub(crate) fn validate_public_url_value(url: &str) -> Result<(), String> {
4376 if !(url.starts_with("http://") || url.starts_with("https://")) {
4377 return Err(format!(
4378 "public_url {url:?} must start with http:// or https://"
4379 ));
4380 }
4381 Ok(())
4382}
4383
4384pub(crate) fn validate_allowed_origin_entry(entry: &str) -> Result<(), String> {
4391 if parse_allowed_origin(entry).is_none() {
4392 return Err(format!(
4393 "allowed_origins entry {entry:?} must be scheme://host[:port] (http or https), \
4394 optionally with one trailing '/', or the literal \"null\""
4395 ));
4396 }
4397 Ok(())
4398}
4399
4400fn request_origin_allowed(value: &str, allowed: &[AllowedOrigin]) -> bool {
4405 if value.eq_ignore_ascii_case("null") {
4406 return allowed.contains(&AllowedOrigin::Null);
4407 }
4408 let Some((scheme, host, port)) = parse_request_origin_tuple(value) else {
4409 return false;
4410 };
4411 allowed.iter().any(|entry| {
4412 matches!(
4413 entry,
4414 AllowedOrigin::Tuple(entry_scheme, entry_host, entry_port)
4415 if *entry_scheme == scheme && *entry_host == host && *entry_port == port
4416 )
4417 })
4418}
4419
4420async fn origin_check_middleware(
4430 allowed: Arc<[AllowedOrigin]>,
4431 log_request_headers: bool,
4432 req: Request<Body>,
4433 next: Next,
4434) -> axum::response::Response {
4435 let method = req.method().clone();
4436 let path = req.uri().path().to_owned();
4437
4438 log_incoming_request(&method, &path, req.headers(), log_request_headers);
4439
4440 let mut origins = req.headers().get_all(axum::http::header::ORIGIN).iter();
4445 if let Some(origin) = origins.next() {
4446 let duplicate_origin_headers = origins.next().is_some();
4447 let accepted = !duplicate_origin_headers
4448 && origin
4449 .to_str()
4450 .is_ok_and(|value| request_origin_allowed(value, &allowed));
4451 if !accepted {
4452 let logged = origin.to_str().unwrap_or("<non-utf8>");
4455 tracing::warn!(
4456 origin = logged,
4457 duplicate_origin_headers,
4458 %method,
4459 %path,
4460 allowed = ?&*allowed,
4461 "rejected request: Origin not allowed"
4462 );
4463 return (
4464 axum::http::StatusCode::FORBIDDEN,
4465 "Forbidden: Origin not allowed",
4466 )
4467 .into_response();
4468 }
4469 }
4470 next.run(req).await
4471}
4472
4473fn log_incoming_request(
4476 method: &axum::http::Method,
4477 path: &str,
4478 headers: &axum::http::HeaderMap,
4479 log_request_headers: bool,
4480) {
4481 if log_request_headers {
4482 tracing::debug!(
4483 %method,
4484 %path,
4485 headers = %format_request_headers_for_log(headers),
4486 "incoming request"
4487 );
4488 } else {
4489 tracing::debug!(%method, %path, "incoming request");
4490 }
4491}
4492
4493const REDACTED_LOG_HEADERS: [&str; 6] = [
4501 "authorization",
4502 "cookie",
4503 "proxy-authorization",
4504 "forwarded",
4505 "x-forwarded-for",
4506 "x-real-ip",
4507];
4508
4509fn format_request_headers_for_log(headers: &axum::http::HeaderMap) -> String {
4510 headers
4511 .iter()
4512 .map(|(k, v)| {
4513 let name = k.as_str();
4514 if REDACTED_LOG_HEADERS.contains(&name) {
4515 format!("{name}: [REDACTED]")
4516 } else {
4517 format!("{name}: {}", v.to_str().unwrap_or("<non-utf8>"))
4518 }
4519 })
4520 .collect::<Vec<_>>()
4521 .join(", ")
4522}
4523
4524#[allow(
4548 clippy::cognitive_complexity,
4549 reason = "complexity is purely tracing macro expansion (info/warn + match arms); 18 lines of straight-line code, nothing meaningful to extract"
4550)]
4551pub async fn serve_stdio<H>(handler: H) -> Result<(), RmcpServerKitError>
4552where
4553 H: ServerHandler + 'static,
4554{
4555 use rmcp::ServiceExt as _;
4556
4557 tracing::info!("stdio transport: serving on stdin/stdout");
4558 tracing::warn!("stdio mode: auth, RBAC, TLS, and Origin checks are DISABLED");
4559
4560 let transport = rmcp::transport::io::stdio();
4561
4562 let service = handler
4563 .serve(transport)
4564 .await
4565 .map_err(|e| RmcpServerKitError::Startup(format!("stdio initialize failed: {e}")))?;
4566
4567 if let Err(e) = service.waiting().await {
4568 tracing::warn!(error = %e, "stdio session ended with error");
4569 }
4570 tracing::info!("stdio session ended");
4571 Ok(())
4572}
4573
4574#[allow(
4575 deprecated,
4576 reason = "builder methods are the sanctioned transition layer for deprecated public fields"
4577)]
4578impl McpServerConfig {
4579 #[must_use]
4583 pub fn with_tls_paths(mut self, cert_path: Option<PathBuf>, key_path: Option<PathBuf>) -> Self {
4584 self.tls_cert_path = cert_path;
4585 self.tls_key_path = key_path;
4586 self
4587 }
4588
4589 #[must_use]
4593 pub fn with_tls_cert_path(mut self, cert_path: impl Into<PathBuf>) -> Self {
4594 self.tls_cert_path = Some(cert_path.into());
4595 self
4596 }
4597
4598 #[must_use]
4602 pub fn with_tls_key_path(mut self, key_path: impl Into<PathBuf>) -> Self {
4603 self.tls_key_path = Some(key_path.into());
4604 self
4605 }
4606
4607 #[must_use]
4609 pub fn with_optional_auth(mut self, auth: Option<AuthConfig>) -> Self {
4610 self.auth = auth;
4611 self
4612 }
4613
4614 #[must_use]
4616 pub fn with_optional_session_binding_secret(mut self, secret: Option<SecretString>) -> Self {
4617 self.session_binding_secret = secret;
4618 self
4619 }
4620
4621 #[must_use]
4623 pub fn with_optional_tool_rate_limit(mut self, per_minute: Option<u32>) -> Self {
4624 self.tool_rate_limit = per_minute;
4625 self
4626 }
4627
4628 #[must_use]
4630 pub fn with_optional_tool_rate_limit_burst(mut self, burst: Option<u32>) -> Self {
4631 self.tool_rate_limit_burst = burst;
4632 self
4633 }
4634
4635 #[must_use]
4637 pub fn with_optional_extra_route_rate_limit(mut self, per_minute: Option<u32>) -> Self {
4638 self.extra_route_rate_limit = per_minute;
4639 self
4640 }
4641
4642 #[must_use]
4644 pub fn with_optional_extra_route_rate_limit_burst(mut self, burst: Option<u32>) -> Self {
4645 self.extra_route_rate_limit_burst = burst;
4646 self
4647 }
4648
4649 #[must_use]
4651 pub fn with_optional_forwarded_header(mut self, mode: Option<ForwardedHeaderMode>) -> Self {
4652 self.forwarded_header = mode;
4653 self
4654 }
4655
4656 #[must_use]
4658 pub fn with_optional_public_url(mut self, url: Option<String>) -> Self {
4659 self.public_url = url;
4660 self
4661 }
4662
4663 #[must_use]
4667 pub fn with_compression_min_size(mut self, min_size: u16) -> Self {
4668 self.compression_min_size = min_size;
4669 self
4670 }
4671
4672 #[must_use]
4674 pub fn with_compression_enabled(mut self, enabled: bool) -> Self {
4675 self.compression_enabled = enabled;
4676 self
4677 }
4678
4679 #[must_use]
4681 pub fn with_optional_max_concurrent_requests(mut self, limit: Option<usize>) -> Self {
4682 self.max_concurrent_requests = limit;
4683 self
4684 }
4685
4686 #[must_use]
4688 pub fn with_admin_enabled(mut self, enabled: bool) -> Self {
4689 self.admin_enabled = enabled;
4690 self
4691 }
4692
4693 #[must_use]
4696 pub fn with_admin_role(mut self, role: impl Into<String>) -> Self {
4697 self.admin_role = role.into();
4698 self
4699 }
4700
4701 #[must_use]
4703 pub fn with_expose_build_metadata(mut self, enabled: bool) -> Self {
4704 self.expose_build_metadata = enabled;
4705 self
4706 }
4707}
4708
4709fn warn_security_header_overrides(cfg: &SecurityHeadersConfig) {
4710 for (field, value) in security_header_overrides(cfg) {
4711 let action = if value.is_empty() {
4712 "omitted"
4713 } else {
4714 "overridden"
4715 };
4716 tracing::warn!(
4717 security_header = field,
4718 action,
4719 "security header configured; inspect server.security_headers.<security_header>"
4720 );
4721 }
4722}
4723
4724fn security_header_overrides(
4725 cfg: &SecurityHeadersConfig,
4726) -> impl Iterator<Item = (&'static str, &str)> {
4727 [
4728 (
4729 "x_content_type_options",
4730 cfg.x_content_type_options.as_deref(),
4731 ),
4732 ("x_frame_options", cfg.x_frame_options.as_deref()),
4733 ("cache_control", cfg.cache_control.as_deref()),
4734 ("referrer_policy", cfg.referrer_policy.as_deref()),
4735 (
4736 "cross_origin_opener_policy",
4737 cfg.cross_origin_opener_policy.as_deref(),
4738 ),
4739 (
4740 "cross_origin_resource_policy",
4741 cfg.cross_origin_resource_policy.as_deref(),
4742 ),
4743 (
4744 "cross_origin_embedder_policy",
4745 cfg.cross_origin_embedder_policy.as_deref(),
4746 ),
4747 ("permissions_policy", cfg.permissions_policy.as_deref()),
4748 (
4749 "x_permitted_cross_domain_policies",
4750 cfg.x_permitted_cross_domain_policies.as_deref(),
4751 ),
4752 (
4753 "content_security_policy",
4754 cfg.content_security_policy.as_deref(),
4755 ),
4756 (
4757 "x_dns_prefetch_control",
4758 cfg.x_dns_prefetch_control.as_deref(),
4759 ),
4760 (
4761 "strict_transport_security",
4762 cfg.strict_transport_security.as_deref(),
4763 ),
4764 ]
4765 .into_iter()
4766 .filter_map(|(field, value)| value.map(|v| (field, v)))
4767}
4768
4769fn check_auth_capacity_knobs(auth: Option<&AuthConfig>) -> Result<(), RmcpServerKitError> {
4770 if let Some(auth_cfg) = auth {
4771 if let Some(rl) = &auth_cfg.rate_limit {
4772 (rl.max_attempts_per_minute != 0).ok_or_else(|| {
4773 RmcpServerKitError::Config(
4774 "auth.rate_limit.max_attempts_per_minute must be nonzero".into(),
4775 )
4776 })?;
4777 (rl.pre_auth_max_per_minute != Some(0)).ok_or_else(|| {
4782 RmcpServerKitError::Config(
4783 "auth.rate_limit.pre_auth_max_per_minute must be nonzero when set".into(),
4784 )
4785 })?;
4786 }
4787 if let Some(mtls) = &auth_cfg.mtls {
4788 check_mtls_capacity_knobs(mtls)?;
4789 }
4790 auth_cfg.check_oauth_feature()?;
4791 }
4792 Ok(())
4793}
4794
4795fn check_mtls_capacity_knobs(mtls: &MtlsConfig) -> Result<(), RmcpServerKitError> {
4796 (mtls.crl_max_concurrent_fetches != 0).ok_or_else(|| {
4797 RmcpServerKitError::Config("auth.mtls.crl_max_concurrent_fetches must be nonzero".into())
4798 })?;
4799 (mtls.crl_discovery_rate_per_min != 0).ok_or_else(|| {
4800 RmcpServerKitError::Config("auth.mtls.crl_discovery_rate_per_min must be nonzero".into())
4801 })?;
4802 (mtls.crl_max_host_semaphores != 0).ok_or_else(|| {
4803 RmcpServerKitError::Config("auth.mtls.crl_max_host_semaphores must be nonzero".into())
4804 })?;
4805 (mtls.crl_max_seen_urls != 0).ok_or_else(|| {
4806 RmcpServerKitError::Config("auth.mtls.crl_max_seen_urls must be nonzero".into())
4807 })?;
4808 (mtls.crl_max_cache_entries != 0).ok_or_else(|| {
4809 RmcpServerKitError::Config("auth.mtls.crl_max_cache_entries must be nonzero".into())
4810 })?;
4811 (mtls.crl_max_response_bytes != 0).ok_or_else(|| {
4816 RmcpServerKitError::Config("auth.mtls.crl_max_response_bytes must be nonzero".into())
4817 })?;
4818 Ok(())
4819}
4820
4821#[cfg(test)]
4822mod tests {
4823 #![allow(
4824 clippy::unwrap_used,
4825 clippy::expect_used,
4826 clippy::panic,
4827 clippy::indexing_slicing,
4828 clippy::unwrap_in_result,
4829 clippy::print_stdout,
4830 clippy::print_stderr,
4831 deprecated,
4832 reason = "internal unit tests legitimately read/write the deprecated `pub` fields they were designed to verify"
4833 )]
4834 use std::{sync::Arc, time::Duration};
4835
4836 use axum::{
4837 body::Body,
4838 http::{Request, StatusCode, header},
4839 response::IntoResponse,
4840 };
4841 use http_body_util::BodyExt;
4842 use tower::ServiceExt as _;
4843
4844 use super::*;
4845
4846 #[tokio::test]
4849 async fn external_shutdown_bridge_exits_when_internal_token_cancels() {
4850 let external = CancellationToken::new();
4851 let internal = CancellationToken::new();
4852 let bridge = spawn_external_shutdown_bridge(external.clone(), internal.clone());
4853
4854 internal.cancel();
4857
4858 let joined = tokio::time::timeout(Duration::from_secs(2), bridge).await;
4859 assert!(
4860 joined.is_ok(),
4861 "bridge task must exit once the internal token is cancelled, \
4862 otherwise it leaks for the lifetime of the process"
4863 );
4864 }
4865
4866 #[tokio::test]
4867 async fn external_shutdown_bridge_still_forwards_external_cancel() {
4868 let external = CancellationToken::new();
4869 let internal = CancellationToken::new();
4870 let bridge = spawn_external_shutdown_bridge(external.clone(), internal.clone());
4871
4872 external.cancel();
4873
4874 let joined = tokio::time::timeout(Duration::from_secs(2), bridge).await;
4875 assert!(joined.is_ok(), "bridge task must exit on external cancel");
4876 assert!(
4877 internal.is_cancelled(),
4878 "external cancellation must still propagate to the internal token"
4879 );
4880 }
4881
4882 #[test]
4883 fn cancel_on_drop_cancels_its_token() {
4884 let ct = CancellationToken::new();
4885 {
4886 let _guard = CancelOnDrop(ct.clone());
4887 assert!(!ct.is_cancelled());
4888 }
4889 assert!(
4890 ct.is_cancelled(),
4891 "dropping the guard must cancel background startup tasks"
4892 );
4893 }
4894
4895 #[test]
4896 fn validate_rejects_mtls_without_tls() {
4897 for (cert, key) in [
4898 (None, None),
4899 (Some("cert.pem"), None),
4900 (None, Some("key.pem")),
4901 ] {
4902 let mut auth = AuthConfig::with_keys(vec![]);
4903 auth.mtls = Some(valid_mtls_config());
4904 let mut cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
4905 cfg.tls_cert_path = cert.map(Into::into);
4906 cfg.tls_key_path = key.map(Into::into);
4907
4908 let err = cfg
4909 .validate()
4910 .expect_err("mTLS without both TLS paths must be rejected");
4911 let msg = err.to_string();
4912 assert!(
4913 msg.contains("tls_cert_path") && msg.contains("tls_key_path"),
4914 "cert={cert:?} key={key:?}: {msg}"
4915 );
4916 }
4917 }
4918
4919 #[test]
4920 fn validate_accepts_mtls_with_tls() {
4921 let mut auth = AuthConfig::with_keys(vec![]);
4922 auth.mtls = Some(valid_mtls_config());
4923 let mut cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
4924 cfg.tls_cert_path = Some("cert.pem".into());
4925 cfg.tls_key_path = Some("key.pem".into());
4926
4927 assert!(cfg.validate().is_ok(), "mTLS with both TLS paths is valid");
4928 }
4929
4930 #[test]
4931 fn validate_rejects_blank_api_key_name() {
4932 let blank = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(
4933 AuthConfig::with_keys(vec![crate::auth::ApiKeyEntry::new("", "hash", "viewer")]),
4934 );
4935 let err = blank
4936 .validate()
4937 .expect_err("blank API-key name must be rejected");
4938 assert!(err.to_string().contains("api_keys[0]"), "{err}");
4939
4940 let whitespace = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(
4941 AuthConfig::with_keys(vec![crate::auth::ApiKeyEntry::new(" ", "hash", "viewer")]),
4942 );
4943 assert!(whitespace.validate().is_err());
4944
4945 let ok = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(
4946 AuthConfig::with_keys(vec![crate::auth::ApiKeyEntry::new(
4947 "viewer-key",
4948 "hash",
4949 "viewer",
4950 )]),
4951 );
4952 assert!(ok.validate().is_ok(), "a normal name must still validate");
4953 }
4954
4955 fn reload_test_state(name: &str) -> (Arc<AuthState>, String) {
4956 let (token, hash) = crate::auth::generate_api_key().unwrap();
4957 let state = Arc::new(AuthState {
4958 api_keys: ArcSwap::from_pointee(vec![crate::auth::ApiKeyEntry::new(name, hash, "ops")]),
4959 rate_limiter: None,
4960 pre_auth_limiter: None,
4961 #[cfg(feature = "oauth")]
4962 jwks_cache: None,
4963 seen_identities: crate::auth::SeenIdentitySet::new(),
4964 counters: crate::auth::AuthCounters::default(),
4965 resource_metadata_url: None,
4966 });
4967 (state, token)
4968 }
4969
4970 #[test]
4971 fn try_reload_auth_keys_rejects_blank_name() {
4972 let (state, _token) = reload_test_state("prev-key");
4973 let handle = ReloadHandle {
4974 auth: Some(state),
4975 rbac: None,
4976 crl_set: None,
4977 };
4978 let err = handle
4979 .try_reload_auth_keys(vec![crate::auth::ApiKeyEntry::new("", "h", "ops")])
4980 .expect_err("blank API-key name must be rejected on reload");
4981 assert!(err.to_string().contains("api_keys[0]"), "{err}");
4982 }
4983
4984 #[test]
4985 fn reload_auth_keys_blank_name_leaves_previous_keys() {
4986 let (state, token) = reload_test_state("prev-key");
4987 let handle = ReloadHandle {
4988 auth: Some(Arc::clone(&state)),
4989 rbac: None,
4990 crl_set: None,
4991 };
4992 handle.reload_auth_keys(vec![crate::auth::ApiKeyEntry::new(" ", "h", "ops")]);
4993
4994 let installed = state.api_keys.load();
4995 assert!(
4996 crate::auth::verify_bearer_token(&token, &installed).is_some(),
4997 "the previous key must still authenticate after a rejected reload"
4998 );
4999 }
5000
5001 #[test]
5004 fn server_config_new_defaults() {
5005 let cfg = McpServerConfig::new("0.0.0.0:8443", "test-server", "1.0.0");
5006 assert_eq!(cfg.bind_addr, "0.0.0.0:8443");
5007 assert_eq!(cfg.name, "test-server");
5008 assert_eq!(cfg.version, "1.0.0");
5009 assert!(cfg.tls_cert_path.is_none());
5010 assert!(cfg.tls_key_path.is_none());
5011 assert!(cfg.auth.is_none());
5012 assert!(cfg.rbac.is_none());
5013 assert!(cfg.allowed_origins.is_empty());
5014 assert!(cfg.tool_rate_limit.is_none());
5015 assert!(cfg.readiness_check.is_none());
5016 assert_eq!(cfg.max_request_body, 1024 * 1024);
5017 assert_eq!(cfg.request_timeout, Duration::from_mins(2));
5018 assert_eq!(cfg.shutdown_timeout, Duration::from_secs(30));
5019 assert!(!cfg.log_request_headers);
5020 assert_eq!(cfg.tls_handshake_timeout, Duration::from_secs(10));
5021 assert_eq!(cfg.max_concurrent_tls_handshakes, 256);
5022 assert!(cfg.session_store.is_none());
5023 assert!(cfg.session_binding_secret.is_none());
5024 }
5025
5026 #[derive(Default)]
5027 struct TestSessionStore;
5028
5029 #[async_trait::async_trait]
5030 impl SessionStore for TestSessionStore {
5031 async fn load(
5032 &self,
5033 _session_id: &str,
5034 ) -> Result<
5035 Option<rmcp::transport::streamable_http_server::session::SessionState>,
5036 rmcp::transport::streamable_http_server::session::SessionStoreError,
5037 > {
5038 Ok(None)
5039 }
5040
5041 async fn store(
5042 &self,
5043 _session_id: &str,
5044 _state: &rmcp::transport::streamable_http_server::session::SessionState,
5045 ) -> Result<(), rmcp::transport::streamable_http_server::session::SessionStoreError>
5046 {
5047 Ok(())
5048 }
5049
5050 async fn delete(
5051 &self,
5052 _session_id: &str,
5053 ) -> Result<(), rmcp::transport::streamable_http_server::session::SessionStoreError>
5054 {
5055 Ok(())
5056 }
5057 }
5058
5059 fn test_session_store() -> Arc<dyn SessionStore> {
5060 Arc::new(TestSessionStore)
5061 }
5062
5063 fn shared_session_binding_secret() -> SecretString {
5064 SecretString::from("0123456789abcdef0123456789abcdef")
5065 }
5066
5067 #[test]
5068 fn session_store_defaults_to_none() {
5069 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
5070
5071 assert!(cfg.session_store.is_none());
5072 }
5073
5074 #[test]
5075 fn event_store_defaults_to_none() {
5076 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
5077
5078 assert!(cfg.event_store.is_none());
5079 }
5080
5081 #[test]
5082 fn validate_rejects_session_store_without_binding_secret() {
5083 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5084 .with_auth(AuthConfig::with_keys(vec![]))
5085 .with_session_store(test_session_store());
5086
5087 let err = cfg
5088 .validate()
5089 .expect_err("authenticated shared-store binding needs a shared secret");
5090 let msg = err.to_string();
5091 assert!(msg.contains("session_store"), "{msg}");
5092 assert!(msg.contains("session_binding"), "{msg}");
5093 assert!(msg.contains("shared secret"), "{msg}");
5094 }
5095
5096 #[test]
5097 fn validate_allows_session_store_with_binding_secret() {
5098 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5099 .with_auth(AuthConfig::with_keys(vec![]))
5100 .with_session_store(test_session_store())
5101 .with_session_binding_secret(shared_session_binding_secret());
5102
5103 assert!(cfg.validate().is_ok());
5104 }
5105
5106 #[test]
5107 fn validate_allows_session_store_when_binding_disabled() {
5108 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5109 .with_auth(AuthConfig::with_keys(vec![]))
5110 .with_session_binding(false)
5111 .with_session_store(test_session_store());
5112
5113 assert!(cfg.validate().is_ok());
5114 }
5115
5116 #[test]
5117 fn validate_allows_binding_secret_without_session_store() {
5118 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5119 .with_auth(AuthConfig::with_keys(vec![]))
5120 .with_session_binding_secret(shared_session_binding_secret());
5121
5122 assert!(cfg.validate().is_ok());
5123 }
5124
5125 #[test]
5126 fn tls_handshake_builders_set_fields() {
5127 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5128 .with_tls_handshake_timeout(Duration::from_secs(3))
5129 .with_max_concurrent_tls_handshakes(64);
5130 assert_eq!(cfg.tls_handshake_timeout, Duration::from_secs(3));
5131 assert_eq!(cfg.max_concurrent_tls_handshakes, 64);
5132 }
5133
5134 #[test]
5135 fn validate_rejects_zero_tls_handshake_timeout() {
5136 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5137 .with_tls_handshake_timeout(Duration::ZERO);
5138 let err = cfg.validate().expect_err("zero handshake timeout");
5139 assert!(err.to_string().contains("tls_handshake_timeout"));
5140 }
5141
5142 #[test]
5143 fn validate_rejects_zero_max_concurrent_tls_handshakes() {
5144 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5145 .with_max_concurrent_tls_handshakes(0);
5146 let err = cfg.validate().expect_err("zero handshake concurrency");
5147 assert!(err.to_string().contains("max_concurrent_tls_handshakes"));
5148 }
5149
5150 #[test]
5151 fn validate_consumes_and_proves() {
5152 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
5154 let validated = cfg.validate().expect("valid config");
5155 assert_eq!(validated.as_inner().name, "test-server");
5157 let raw = validated.into_inner();
5159 assert_eq!(raw.name, "test-server");
5160
5161 let mut bad = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
5163 bad.max_request_body = 0;
5164 assert!(bad.validate().is_err(), "zero body cap must fail validate");
5165 }
5166
5167 #[test]
5168 fn validate_rejects_zero_max_concurrent_requests() {
5169 let cfg =
5170 McpServerConfig::new("127.0.0.1:8080", "test", "1.0.0").with_max_concurrent_requests(0);
5171 let err = cfg.validate().expect_err("zero concurrency cap must fail");
5172 assert!(
5173 format!("{err}").contains("max_concurrent_requests"),
5174 "error should mention max_concurrent_requests, got: {err}"
5175 );
5176 }
5177
5178 #[test]
5179 fn validate_rejects_zero_max_tracked_keys() {
5180 let rl = crate::auth::RateLimitConfig {
5183 max_attempts_per_minute: 30,
5184 pre_auth_max_per_minute: None,
5185 max_tracked_keys: 0,
5186 idle_eviction: Duration::from_secs(15 * 60),
5187 burst: None,
5188 pre_auth_burst: None,
5189 key_eviction_policy: KeyEvictionPolicy::default(),
5190 };
5191 let auth_cfg = AuthConfig {
5192 enabled: true,
5193 api_keys: Vec::new(),
5194 mtls: None,
5195 rate_limit: Some(rl),
5196 #[cfg(feature = "oauth")]
5197 oauth: None,
5198 #[cfg(not(feature = "oauth"))]
5199 oauth: None,
5200 };
5201 let cfg = McpServerConfig::new("127.0.0.1:8080", "test", "1.0.0").with_auth(auth_cfg);
5202 let err = cfg.validate().expect_err("zero max_tracked_keys must fail");
5203 assert!(
5204 format!("{err}").contains("max_tracked_keys"),
5205 "error should mention max_tracked_keys, got: {err}"
5206 );
5207 }
5208
5209 #[test]
5210 fn derive_allowed_hosts_includes_public_host() {
5211 let hosts = derive_allowed_hosts("0.0.0.0:8080", Some("https://mcp.example.com/mcp"));
5212 assert!(
5213 hosts.iter().any(|h| h == "mcp.example.com"),
5214 "public_url host must be allowed"
5215 );
5216 }
5217
5218 #[test]
5219 fn derive_allowed_hosts_includes_bind_authority() {
5220 let hosts = derive_allowed_hosts("127.0.0.1:8080", None);
5221 assert!(
5222 hosts.iter().any(|h| h == "127.0.0.1"),
5223 "bind host must be allowed"
5224 );
5225 assert!(
5226 hosts.iter().any(|h| h == "127.0.0.1:8080"),
5227 "bind authority must be allowed"
5228 );
5229 }
5230
5231 #[tokio::test]
5234 async fn healthz_returns_ok_json() {
5235 let resp = healthz().await.into_response();
5236 assert_eq!(resp.status(), StatusCode::OK);
5237 let body = resp.into_body().collect().await.unwrap().to_bytes();
5238 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5239 assert_eq!(json["status"], "ok");
5240 assert!(
5241 json.get("name").is_none(),
5242 "healthz must not expose server name"
5243 );
5244 assert!(
5245 json.get("version").is_none(),
5246 "healthz must not expose version"
5247 );
5248 }
5249
5250 #[tokio::test]
5253 async fn readyz_returns_ok_when_ready() {
5254 let check: ReadinessCheck =
5255 Arc::new(|| Box::pin(async { serde_json::json!({"ready": true, "db": "connected"}) }));
5256 let resp = readyz(check).await.into_response();
5257 assert_eq!(resp.status(), StatusCode::OK);
5258 let body = resp.into_body().collect().await.unwrap().to_bytes();
5259 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
5260 assert_eq!(json["ready"], true);
5261 assert!(
5262 json.get("name").is_none(),
5263 "readyz must not expose server name"
5264 );
5265 assert!(
5266 json.get("version").is_none(),
5267 "readyz must not expose version"
5268 );
5269 assert_eq!(json["db"], "connected");
5270 }
5271
5272 #[tokio::test]
5273 async fn readyz_returns_503_when_not_ready() {
5274 let check: ReadinessCheck =
5275 Arc::new(|| Box::pin(async { serde_json::json!({"ready": false}) }));
5276 let resp = readyz(check).await.into_response();
5277 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
5278 }
5279
5280 #[tokio::test]
5281 async fn readyz_returns_503_when_ready_missing() {
5282 let check: ReadinessCheck =
5283 Arc::new(|| Box::pin(async { serde_json::json!({"status": "starting"}) }));
5284 let resp = readyz(check).await.into_response();
5285 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
5287 }
5288
5289 fn peer_probe_router() -> axum::Router {
5294 async fn probe(req: Request<Body>) -> String {
5295 let ci = req
5296 .extensions()
5297 .get::<ConnectInfo<SocketAddr>>()
5298 .map(|c| c.0.to_string())
5299 .unwrap_or_default();
5300 let pa = req
5301 .extensions()
5302 .get::<PeerAddr>()
5303 .map(|p| p.addr.to_string())
5304 .unwrap_or_default();
5305 format!("{ci}|{pa}")
5306 }
5307 axum::Router::new()
5308 .route("/probe", axum::routing::get(probe))
5309 .layer(axum::middleware::from_fn(|req, next| {
5310 normalize_peer_addr_middleware(None, req, next)
5311 }))
5312 }
5313
5314 async fn body_string(resp: axum::response::Response) -> String {
5315 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
5316 String::from_utf8(bytes.to_vec()).unwrap()
5317 }
5318
5319 #[tokio::test]
5320 async fn normalize_preserves_existing_connect_info_and_mirrors_peer_addr() {
5321 let plain: SocketAddr = "10.0.0.1:1111".parse().unwrap();
5324 let tls: SocketAddr = "10.0.0.2:2222".parse().unwrap();
5325 let req = Request::builder()
5326 .uri("/probe")
5327 .extension(ConnectInfo(plain))
5328 .extension(ConnectInfo(TlsConnInfo::new(tls, None)))
5329 .body(Body::empty())
5330 .unwrap();
5331 let resp = peer_probe_router().oneshot(req).await.unwrap();
5332 assert_eq!(resp.status(), StatusCode::OK);
5333 assert_eq!(body_string(resp).await, format!("{plain}|{plain}"));
5334 }
5335
5336 #[tokio::test]
5337 async fn normalize_inserts_connect_info_and_peer_addr_from_tls() {
5338 let tls: SocketAddr = "192.168.1.7:50443".parse().unwrap();
5339 let req = Request::builder()
5340 .uri("/probe")
5341 .extension(ConnectInfo(TlsConnInfo::new(tls, None)))
5342 .body(Body::empty())
5343 .unwrap();
5344 let resp = peer_probe_router().oneshot(req).await.unwrap();
5345 assert_eq!(resp.status(), StatusCode::OK);
5346 assert_eq!(body_string(resp).await, format!("{tls}|{tls}"));
5347 }
5348
5349 #[tokio::test]
5350 async fn normalize_no_op_without_any_connect_info() {
5351 let req = Request::builder()
5352 .uri("/probe")
5353 .body(Body::empty())
5354 .unwrap();
5355 let resp = peer_probe_router().oneshot(req).await.unwrap();
5356 assert_eq!(resp.status(), StatusCode::OK);
5357 assert_eq!(body_string(resp).await, "|");
5358 }
5359
5360 #[tokio::test]
5361 async fn peer_addr_extractor_rejects_when_absent() {
5362 async fn h(peer: PeerAddr) -> String {
5363 peer.addr.to_string()
5364 }
5365 let app = axum::Router::new().route("/p", axum::routing::get(h));
5366 let req = Request::builder().uri("/p").body(Body::empty()).unwrap();
5367 let resp = app.oneshot(req).await.unwrap();
5368 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
5369 }
5370
5371 #[tokio::test]
5372 async fn peer_addr_extractor_returns_value_when_present() {
5373 async fn h(peer: PeerAddr) -> String {
5374 peer.addr.to_string()
5375 }
5376 let addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
5377 let app = axum::Router::new().route("/p", axum::routing::get(h));
5378 let req = Request::builder()
5379 .uri("/p")
5380 .extension(PeerAddr::new(addr))
5381 .body(Body::empty())
5382 .unwrap();
5383 let resp = app.oneshot(req).await.unwrap();
5384 assert_eq!(resp.status(), StatusCode::OK);
5385 assert_eq!(body_string(resp).await, addr.to_string());
5386 }
5387
5388 #[tokio::test]
5389 async fn peer_addr_via_extension_extractor() {
5390 async fn h(axum::Extension(peer): axum::Extension<PeerAddr>) -> String {
5391 peer.addr.to_string()
5392 }
5393 let addr: SocketAddr = "127.0.0.1:4242".parse().unwrap();
5394 let app = axum::Router::new().route("/p", axum::routing::get(h));
5395 let req = Request::builder()
5396 .uri("/p")
5397 .extension(PeerAddr::new(addr))
5398 .body(Body::empty())
5399 .unwrap();
5400 let resp = app.oneshot(req).await.unwrap();
5401 assert_eq!(resp.status(), StatusCode::OK);
5402 assert_eq!(body_string(resp).await, addr.to_string());
5403 }
5404
5405 fn limited_router(per_minute: u32) -> axum::Router {
5410 limited_router_with_burst(per_minute, None)
5411 }
5412
5413 fn limited_router_with_burst(per_minute: u32, burst: Option<u32>) -> axum::Router {
5415 limited_router_full(per_minute, burst, &[])
5416 }
5417
5418 fn limited_router_full(
5422 per_minute: u32,
5423 burst: Option<u32>,
5424 exempt_paths: &[&str],
5425 ) -> axum::Router {
5426 let limiter = build_extra_route_rate_limiter_with_policy(
5427 per_minute,
5428 burst,
5429 KeyEvictionPolicy::default(),
5430 NonZeroUsize::new(EXTRA_ROUTE_MAX_TRACKED_KEYS).unwrap_or(NonZeroUsize::MIN),
5431 );
5432 let exempt: Arc<std::collections::HashSet<String>> =
5433 Arc::new(exempt_paths.iter().map(|s| (*s).to_owned()).collect());
5434 axum::Router::new()
5435 .route("/limited", axum::routing::get(|| async { "ok" }))
5436 .route("/exempt", axum::routing::get(|| async { "ok" }))
5437 .layer(axum::middleware::from_fn(move |req, next| {
5438 let l = Arc::clone(&limiter);
5439 let e = Arc::clone(&exempt);
5440 extra_route_rate_limit_middleware(l, e, req, next)
5441 }))
5442 }
5443
5444 fn limited_req(ip: &str) -> Request<Body> {
5445 limited_req_to(ip, "/limited")
5446 }
5447
5448 fn limited_req_to(ip: &str, path: &str) -> Request<Body> {
5449 let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
5450 Request::builder()
5451 .uri(path)
5452 .extension(ConnectInfo(addr))
5453 .body(Body::empty())
5454 .unwrap()
5455 }
5456
5457 #[tokio::test]
5458 async fn extra_route_limiter_denies_over_quota() {
5459 let app = limited_router(2);
5460 for i in 0..2 {
5461 let resp = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
5462 assert_eq!(resp.status(), StatusCode::OK, "request {i} should pass");
5463 }
5464 let resp = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
5465 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
5466 let body = body_string(resp).await;
5467 assert!(
5468 body.contains("too many requests to application routes"),
5469 "deny body should match the limiter message, got: {body}"
5470 );
5471 }
5472
5473 fn one_tracked_key() -> NonZeroUsize {
5474 NonZeroUsize::new(1).unwrap_or(NonZeroUsize::MIN)
5475 }
5476
5477 #[tokio::test]
5478 async fn extra_route_limiter_capacity_full_returns_503_without_retry_after() {
5479 let limiter = build_extra_route_rate_limiter_with_policy(
5480 10,
5481 None,
5482 KeyEvictionPolicy::RejectNew,
5483 one_tracked_key(),
5484 );
5485 let exempt = Arc::new(std::collections::HashSet::new());
5486 let app = axum::Router::new()
5487 .route("/limited", axum::routing::get(|| async { "ok" }))
5488 .layer(axum::middleware::from_fn(move |req, next| {
5489 let l = Arc::clone(&limiter);
5490 let e = Arc::clone(&exempt);
5491 extra_route_rate_limit_middleware(l, e, req, next)
5492 }));
5493 let established = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
5494 assert_eq!(established.status(), StatusCode::OK);
5495
5496 let denied = app.clone().oneshot(limited_req("10.1.1.2")).await.unwrap();
5497
5498 assert_eq!(denied.status(), StatusCode::SERVICE_UNAVAILABLE);
5499 assert!(denied.headers().get(header::RETRY_AFTER).is_none());
5500 }
5501
5502 #[tokio::test]
5503 async fn extra_route_limiter_isolates_keys() {
5504 let app = limited_router(2);
5505 for _ in 0..2 {
5506 let resp = app.clone().oneshot(limited_req("10.2.2.2")).await.unwrap();
5507 assert_eq!(resp.status(), StatusCode::OK);
5508 }
5509 let exhausted = app.clone().oneshot(limited_req("10.2.2.2")).await.unwrap();
5510 assert_eq!(exhausted.status(), StatusCode::TOO_MANY_REQUESTS);
5511 let other = app.clone().oneshot(limited_req("10.3.3.3")).await.unwrap();
5513 assert_eq!(other.status(), StatusCode::OK);
5514 }
5515
5516 #[tokio::test]
5517 async fn extra_route_limiter_bounds_requests_without_peer() {
5518 let app = limited_router(1);
5522 let mk = || {
5523 Request::builder()
5524 .uri("/limited")
5525 .body(Body::empty())
5526 .unwrap()
5527 };
5528 let first = app.clone().oneshot(mk()).await.unwrap();
5529 assert_eq!(
5530 first.status(),
5531 StatusCode::OK,
5532 "first request consumes quota"
5533 );
5534 let second = app.clone().oneshot(mk()).await.unwrap();
5535 assert_eq!(
5536 second.status(),
5537 StatusCode::TOO_MANY_REQUESTS,
5538 "unattributable requests must share a bounded bucket, not bypass the limiter"
5539 );
5540 }
5541
5542 #[test]
5543 fn limiter_client_key_falls_back_to_unattributed() {
5544 let empty = axum::http::Extensions::new();
5545 assert_eq!(limiter_client_key(&empty), RateLimitKey::Unattributed);
5546 }
5547
5548 #[test]
5549 fn unattributed_key_is_distinct_from_unspecified_ip() {
5550 let unspecified = RateLimitKey::Ip("0.0.0.0".parse::<IpAddr>().unwrap());
5554 assert_ne!(unspecified, RateLimitKey::Unattributed);
5555
5556 let mut set = std::collections::HashSet::new();
5557 set.insert(unspecified);
5558 set.insert(RateLimitKey::Unattributed);
5559 assert_eq!(set.len(), 2, "the two keys must hash to distinct buckets");
5560 }
5561
5562 #[test]
5563 fn rate_limit_key_display_does_not_fabricate_an_ip() {
5564 assert_eq!(
5565 RateLimitKey::Ip("10.1.2.3".parse::<IpAddr>().unwrap()).to_string(),
5566 "10.1.2.3"
5567 );
5568 assert_eq!(RateLimitKey::Unattributed.to_string(), "unattributed");
5569 }
5570
5571 #[tokio::test]
5572 async fn extra_route_limiter_extracts_tls_conn_info() {
5573 let app = limited_router(2);
5574 let mk = || {
5575 let addr: SocketAddr = "192.168.9.9:55555".parse().unwrap();
5576 Request::builder()
5577 .uri("/limited")
5578 .extension(ConnectInfo(TlsConnInfo::new(addr, None)))
5579 .body(Body::empty())
5580 .unwrap()
5581 };
5582 for _ in 0..2 {
5583 assert_eq!(
5584 app.clone().oneshot(mk()).await.unwrap().status(),
5585 StatusCode::OK
5586 );
5587 }
5588 let resp = app.clone().oneshot(mk()).await.unwrap();
5589 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
5590 }
5591
5592 #[tokio::test]
5593 async fn extra_route_limiter_exempt_path_bypasses_quota() {
5594 let app = limited_router_full(1, None, &["/exempt"]);
5597 for i in 0..5 {
5598 let resp = app
5599 .clone()
5600 .oneshot(limited_req_to("10.6.6.6", "/exempt"))
5601 .await
5602 .unwrap();
5603 assert_eq!(resp.status(), StatusCode::OK, "exempt request {i}");
5604 }
5605 let resp = app.clone().oneshot(limited_req("10.6.6.6")).await.unwrap();
5607 assert_eq!(resp.status(), StatusCode::OK);
5608 let resp = app.clone().oneshot(limited_req("10.6.6.6")).await.unwrap();
5610 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
5611 }
5612
5613 #[tokio::test]
5614 async fn extra_route_limiter_exemption_is_raw_exact_match() {
5615 let app = limited_router_full(1, None, &["/exempt"]);
5618 let ok = app
5619 .clone()
5620 .oneshot(limited_req_to("10.7.7.7", "/exempt/"))
5621 .await
5622 .unwrap();
5623 assert_eq!(
5624 ok.status(),
5625 StatusCode::NOT_FOUND,
5626 "variant path routes 404"
5627 );
5628 let denied = app
5630 .clone()
5631 .oneshot(limited_req_to("10.7.7.7", "/limited"))
5632 .await
5633 .unwrap();
5634 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
5635 }
5636
5637 #[cfg(feature = "metrics")]
5638 #[tokio::test]
5639 async fn extra_route_limiter_deny_increments_counter_exempt_does_not() {
5640 let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
5641 let app = limited_router_full(1, None, &["/exempt"]);
5642 let mk = |path: &str| {
5643 let addr: SocketAddr = "10.8.8.8:40000".parse().unwrap();
5644 Request::builder()
5645 .uri(path)
5646 .extension(ConnectInfo(addr))
5647 .extension(Arc::clone(&metrics))
5648 .body(Body::empty())
5649 .unwrap()
5650 };
5651 let counter = || {
5652 metrics
5653 .rate_limited_total
5654 .with_label_values(&["extra_route"])
5655 .get()
5656 };
5657 for _ in 0..3 {
5659 assert_eq!(
5660 app.clone().oneshot(mk("/exempt")).await.unwrap().status(),
5661 StatusCode::OK
5662 );
5663 }
5664 assert_eq!(counter(), 0, "exempt requests must not count as denies");
5665 assert_eq!(
5667 app.clone().oneshot(mk("/limited")).await.unwrap().status(),
5668 StatusCode::OK
5669 );
5670 assert_eq!(counter(), 0);
5671 assert_eq!(
5672 app.clone().oneshot(mk("/limited")).await.unwrap().status(),
5673 StatusCode::TOO_MANY_REQUESTS
5674 );
5675 assert_eq!(counter(), 1, "deny must increment the extra_route label");
5676 }
5677
5678 #[test]
5679 fn validate_rejects_exempt_paths_without_base_knob() {
5680 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5681 .with_extra_route_rate_limit_exempt_paths(["/ok"]);
5682 let err = cfg.validate().expect_err("exempt paths without rate limit");
5683 assert!(err.to_string().contains("requires extra_route_rate_limit"));
5684 }
5685
5686 #[test]
5687 fn validate_rejects_malformed_exempt_paths() {
5688 for bad in ["", "no-slash"] {
5689 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5690 .with_extra_route_rate_limit(10)
5691 .with_extra_route_rate_limit_exempt_paths([bad]);
5692 let err = cfg.validate().expect_err("malformed exempt path");
5693 assert!(
5694 err.to_string()
5695 .contains("must be non-empty and start with '/'"),
5696 "entry {bad:?}: {err}"
5697 );
5698 }
5699 }
5700
5701 #[test]
5702 fn validate_accepts_wellformed_exempt_paths() {
5703 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5704 .with_extra_route_rate_limit(10)
5705 .with_extra_route_rate_limit_exempt_paths(["/.well-known/oauth-authorization-server"]);
5706 assert!(cfg.validate().is_ok());
5707 }
5708
5709 #[test]
5710 fn validate_rejects_zero_extra_route_rate_limit() {
5711 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5712 .with_extra_route_rate_limit(0);
5713 let err = cfg.validate().expect_err("zero extra route rate limit");
5714 assert!(err.to_string().contains("extra_route_rate_limit"));
5715 }
5716
5717 #[tokio::test]
5718 async fn extra_route_limiter_burst_allows_initial_spike() {
5719 let app = limited_router_with_burst(1, Some(3));
5720 for i in 0..3 {
5721 let resp = app.clone().oneshot(limited_req("10.4.4.4")).await.unwrap();
5722 assert_eq!(resp.status(), StatusCode::OK, "burst request {i}");
5723 }
5724 let resp = app.clone().oneshot(limited_req("10.4.4.4")).await.unwrap();
5725 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
5726 }
5727
5728 #[tokio::test]
5729 async fn extra_route_limiter_deny_sets_retry_after() {
5730 let app = limited_router(1);
5731 let ok = app.clone().oneshot(limited_req("10.5.5.5")).await.unwrap();
5732 assert_eq!(ok.status(), StatusCode::OK);
5733 let denied = app.clone().oneshot(limited_req("10.5.5.5")).await.unwrap();
5734 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
5735 let retry_after = denied
5736 .headers()
5737 .get(header::RETRY_AFTER)
5738 .expect("Retry-After present")
5739 .to_str()
5740 .unwrap()
5741 .parse::<u64>()
5742 .unwrap();
5743 assert!(retry_after >= 1, "delta-seconds must be >= 1");
5744 }
5745
5746 #[test]
5747 fn validate_rejects_zero_burst_knobs() {
5748 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5749 .with_tool_rate_limit(10)
5750 .with_tool_rate_limit_burst(0)
5751 .validate()
5752 .expect_err("zero tool burst");
5753 assert!(err.to_string().contains("tool_rate_limit_burst"));
5754
5755 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5756 .with_extra_route_rate_limit(10)
5757 .with_extra_route_rate_limit_burst(0)
5758 .validate()
5759 .expect_err("zero extra route burst");
5760 assert!(err.to_string().contains("extra_route_rate_limit_burst"));
5761 }
5762
5763 #[test]
5764 fn validate_rejects_orphan_burst_knobs() {
5765 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5766 .with_tool_rate_limit_burst(5)
5767 .validate()
5768 .expect_err("orphan tool burst");
5769 assert!(err.to_string().contains("requires tool_rate_limit"));
5770
5771 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5772 .with_extra_route_rate_limit_burst(5)
5773 .validate()
5774 .expect_err("orphan extra route burst");
5775 assert!(err.to_string().contains("requires extra_route_rate_limit"));
5776 }
5777
5778 #[test]
5779 fn validate_rejects_zero_auth_bursts() {
5780 let auth = AuthConfig::with_keys(vec![])
5781 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_burst(0));
5782 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5783 .with_auth(auth)
5784 .validate()
5785 .expect_err("zero auth burst");
5786 assert!(err.to_string().contains("rate_limit.burst"));
5787
5788 let auth = AuthConfig::with_keys(vec![])
5789 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(0));
5790 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5791 .with_auth(auth)
5792 .validate()
5793 .expect_err("zero pre-auth burst");
5794 assert!(err.to_string().contains("pre_auth_burst"));
5795 }
5796
5797 #[test]
5798 fn validate_rejects_zero_pre_auth_max_per_minute() {
5799 let auth = AuthConfig::with_keys(vec![])
5800 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_max_per_minute(0));
5801 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5802 .with_auth(auth)
5803 .validate()
5804 .expect_err("zero pre-auth rate");
5805 assert!(err.to_string().contains("pre_auth_max_per_minute"));
5806 }
5807
5808 fn valid_mtls_config() -> MtlsConfig {
5809 MtlsConfig {
5810 ca_cert_path: "memory://ca.pem".into(),
5811 required: true,
5812 default_role: "viewer".into(),
5813 crl_enabled: true,
5814 crl_refresh_interval: None,
5815 crl_fetch_timeout: Duration::from_secs(30),
5816 crl_stale_grace: Duration::from_secs(24 * 60 * 60),
5817 crl_deny_on_unavailable: false,
5818 crl_end_entity_only: false,
5819 crl_allow_http: true,
5820 crl_enforce_expiration: true,
5821 crl_max_concurrent_fetches: 4,
5822 crl_max_response_bytes: 5 * 1024 * 1024,
5823 crl_discovery_rate_per_min: 60,
5824 crl_max_host_semaphores: 1024,
5825 crl_max_seen_urls: 4096,
5826 crl_max_cache_entries: 1024,
5827 }
5828 }
5829
5830 #[test]
5831 fn validate_rejects_zero_crl_max_response_bytes() {
5832 let mut mtls = valid_mtls_config();
5833 mtls.crl_max_response_bytes = 0;
5834 let mut auth = AuthConfig::with_keys(vec![]);
5835 auth.mtls = Some(mtls);
5836
5837 let mut cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
5840 cfg.tls_cert_path = Some("cert.pem".into());
5841 cfg.tls_key_path = Some("key.pem".into());
5842
5843 let err = cfg.validate().expect_err("zero CRL response cap");
5844 assert!(err.to_string().contains("crl_max_response_bytes"));
5845 }
5846
5847 #[test]
5850 fn validate_accepts_pre_auth_burst_without_explicit_pre_auth_rate() {
5851 let auth = AuthConfig::with_keys(vec![])
5852 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(50));
5853 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
5854 assert!(cfg.validate().is_ok(), "pre_auth_burst has no orphan rule");
5855 }
5856
5857 #[test]
5860 fn trusted_forwarder_max_entries_bounds_are_enforced() {
5861 let cfg = |n: usize| {
5862 McpServerConfig::new("127.0.0.1:8080", "t", "0")
5863 .with_trusted_forwarder_max_entries(n)
5864 .validate()
5865 };
5866 assert!(cfg(0).is_err(), "0 would pin every client to the proxy");
5867 assert!(
5868 cfg(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES + 1).is_err(),
5869 "above the ceiling would re-open the header-bomb vector"
5870 );
5871 assert!(cfg(1).is_ok());
5872 assert!(cfg(crate::forwarded::MAX_SCANNED_ENTRIES).is_ok());
5873 assert!(cfg(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES).is_ok());
5874 }
5875
5876 #[test]
5877 fn trusted_forwarder_max_entries_defaults_to_the_module_constant() {
5878 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "0");
5879 assert_eq!(
5880 cfg.trusted_forwarder_max_entries,
5881 crate::forwarded::MAX_SCANNED_ENTRIES
5882 );
5883 }
5884
5885 fn forward_resolver(trusted: &[&str], mode: ForwardedHeaderMode) -> Arc<ForwardResolver> {
5886 Arc::new(ForwardResolver {
5887 trusted: trusted.iter().map(|s| s.parse().unwrap()).collect(),
5888 mode,
5889 max_scanned_entries: crate::forwarded::MAX_SCANNED_ENTRIES,
5890 })
5891 }
5892
5893 fn forwarded_probe_router(resolver: Option<Arc<ForwardResolver>>) -> axum::Router {
5895 async fn probe(req: Request<Body>) -> String {
5896 let pa = req
5897 .extensions()
5898 .get::<PeerAddr>()
5899 .map(|p| p.addr.ip().to_string())
5900 .unwrap_or_default();
5901 let ci = req
5902 .extensions()
5903 .get::<ClientIp>()
5904 .map(|c| c.ip.to_string())
5905 .unwrap_or_default();
5906 format!("{pa}|{ci}")
5907 }
5908 axum::Router::new()
5909 .route("/probe", axum::routing::get(probe))
5910 .layer(axum::middleware::from_fn(move |req, next| {
5911 let r = resolver.clone();
5912 normalize_peer_addr_middleware(r, req, next)
5913 }))
5914 }
5915
5916 fn probe_req(peer: &str, header: Option<(&str, &str)>) -> Request<Body> {
5917 let addr: SocketAddr = peer.parse().unwrap();
5918 let mut builder = Request::builder()
5919 .uri("/probe")
5920 .extension(ConnectInfo(addr));
5921 if let Some((name, value)) = header {
5922 builder = builder.header(name, value);
5923 }
5924 builder.body(Body::empty()).unwrap()
5925 }
5926
5927 #[tokio::test]
5928 async fn client_ip_equals_direct_without_resolver() {
5929 let app = forwarded_probe_router(None);
5930 let resp = app
5931 .oneshot(probe_req(
5932 "10.1.2.3:4444",
5933 Some(("x-forwarded-for", "203.0.113.7")),
5934 ))
5935 .await
5936 .unwrap();
5937 assert_eq!(
5938 body_string(resp).await,
5939 "10.1.2.3|10.1.2.3",
5940 "feature off: header ignored, ClientIp == direct"
5941 );
5942 }
5943
5944 #[tokio::test]
5945 async fn client_ip_resolved_for_trusted_peer() {
5946 let app = forwarded_probe_router(Some(forward_resolver(
5947 &["10.0.0.0/8"],
5948 ForwardedHeaderMode::XForwardedFor,
5949 )));
5950 let resp = app
5951 .oneshot(probe_req(
5952 "10.0.0.1:9999",
5953 Some(("x-forwarded-for", "203.0.113.7")),
5954 ))
5955 .await
5956 .unwrap();
5957 assert_eq!(
5958 body_string(resp).await,
5959 "10.0.0.1|203.0.113.7",
5960 "PeerAddr stays direct while ClientIp resolves"
5961 );
5962 }
5963
5964 #[tokio::test]
5965 async fn client_ip_falls_back_to_direct_on_malformed_header() {
5966 let app = forwarded_probe_router(Some(forward_resolver(
5967 &["10.0.0.0/8"],
5968 ForwardedHeaderMode::XForwardedFor,
5969 )));
5970 let resp = app
5971 .oneshot(probe_req(
5972 "10.0.0.1:9999",
5973 Some(("x-forwarded-for", "not-an-ip")),
5974 ))
5975 .await
5976 .unwrap();
5977 assert_eq!(
5978 body_string(resp).await,
5979 "10.0.0.1|10.0.0.1",
5980 "malformed chain falls back to the direct peer"
5981 );
5982 }
5983
5984 #[test]
5985 fn forwarded_header_mode_deserializes_kebab_case() {
5986 #[derive(serde::Deserialize)]
5987 struct Wrapper {
5988 mode: ForwardedHeaderMode,
5989 }
5990 let w: Wrapper = toml::from_str(r#"mode = "x-forwarded-for""#).unwrap();
5991 assert_eq!(w.mode, ForwardedHeaderMode::XForwardedFor);
5992 let w: Wrapper = toml::from_str(r#"mode = "forwarded""#).unwrap();
5993 assert_eq!(w.mode, ForwardedHeaderMode::Forwarded);
5994 assert!(
5995 toml::from_str::<Wrapper>(r#"mode = "XForwardedFor""#).is_err(),
5996 "PascalCase wire value must be rejected"
5997 );
5998 }
5999
6000 #[test]
6001 fn validate_rejects_bad_trusted_proxy_entry() {
6002 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
6003 .with_trusted_proxies(["not-a-cidr"]);
6004 let err = cfg.validate().expect_err("bad CIDR");
6005 assert!(err.to_string().contains("trusted_proxies"));
6006 }
6007
6008 #[test]
6009 fn validate_rejects_zero_prefix_trusted_proxy() {
6010 for entry in ["0.0.0.0/0", "::/0"] {
6011 let cfg =
6012 McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_trusted_proxies([entry]);
6013 let err = cfg.validate().expect_err("zero-prefix CIDR");
6014 assert!(
6015 err.to_string().contains("prefix length 0"),
6016 "entry {entry}: {err}"
6017 );
6018 }
6019 }
6020
6021 #[test]
6022 fn validate_accepts_cidr_and_bare_ip_proxy_entries() {
6023 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_trusted_proxies([
6024 "10.0.0.0/8",
6025 "192.0.2.1",
6026 "2001:db8::1",
6027 ]);
6028 assert!(cfg.validate().is_ok(), "CIDRs and bare IPs are accepted");
6029 }
6030
6031 #[test]
6032 fn validate_rejects_forwarded_header_without_proxies() {
6033 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
6034 .with_forwarded_header(ForwardedHeaderMode::Forwarded);
6035 let err = cfg.validate().expect_err("mode without proxies");
6036 assert!(err.to_string().contains("requires trusted_proxies"));
6037 }
6038
6039 fn origin_router(origins: Vec<String>, log_request_headers: bool) -> axum::Router {
6043 let allowed: Arc<[AllowedOrigin]> = Arc::from(
6044 origins
6045 .into_iter()
6046 .filter_map(|origin| parse_allowed_origin(&origin))
6047 .collect::<Vec<_>>(),
6048 );
6049 axum::Router::new()
6050 .route("/test", axum::routing::get(|| async { "ok" }))
6051 .layer(axum::middleware::from_fn(move |req, next| {
6052 let a = Arc::clone(&allowed);
6053 origin_check_middleware(a, log_request_headers, req, next)
6054 }))
6055 }
6056
6057 #[tokio::test]
6058 async fn origin_allowed_passes() {
6059 let app = origin_router(vec!["http://localhost:3000".into()], false);
6060 let req = Request::builder()
6061 .uri("/test")
6062 .header(header::ORIGIN, "http://localhost:3000")
6063 .body(Body::empty())
6064 .unwrap();
6065 let resp = app.oneshot(req).await.unwrap();
6066 assert_eq!(resp.status(), StatusCode::OK);
6067 }
6068
6069 #[tokio::test]
6070 async fn origin_rejected_returns_403() {
6071 let app = origin_router(vec!["http://localhost:3000".into()], false);
6072 let req = Request::builder()
6073 .uri("/test")
6074 .header(header::ORIGIN, "http://evil.com")
6075 .body(Body::empty())
6076 .unwrap();
6077 let resp = app.oneshot(req).await.unwrap();
6078 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
6079 }
6080
6081 #[tokio::test]
6082 async fn no_origin_header_passes() {
6083 let app = origin_router(vec!["http://localhost:3000".into()], false);
6084 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
6085 let resp = app.oneshot(req).await.unwrap();
6086 assert_eq!(resp.status(), StatusCode::OK);
6087 }
6088
6089 #[tokio::test]
6090 async fn empty_allowlist_rejects_any_origin() {
6091 let app = origin_router(vec![], false);
6092 let req = Request::builder()
6093 .uri("/test")
6094 .header(header::ORIGIN, "http://anything.com")
6095 .body(Body::empty())
6096 .unwrap();
6097 let resp = app.oneshot(req).await.unwrap();
6098 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
6099 }
6100
6101 #[tokio::test]
6102 async fn empty_allowlist_passes_without_origin() {
6103 let app = origin_router(vec![], false);
6104 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
6105 let resp = app.oneshot(req).await.unwrap();
6106 assert_eq!(resp.status(), StatusCode::OK);
6107 }
6108
6109 #[test]
6110 fn format_request_headers_redacts_sensitive_values() {
6111 let mut headers = axum::http::HeaderMap::new();
6112 headers.insert("authorization", "Bearer secret-token".parse().unwrap());
6113 headers.insert("cookie", "sid=abc".parse().unwrap());
6114 headers.insert("x-request-id", "req-123".parse().unwrap());
6115
6116 let out = format_request_headers_for_log(&headers);
6117 assert!(out.contains("authorization: [REDACTED]"));
6118 assert!(out.contains("cookie: [REDACTED]"));
6119 assert!(out.contains("x-request-id: req-123"));
6120 assert!(!out.contains("secret-token"));
6121 }
6122
6123 #[test]
6124 fn format_request_headers_redacts_forwarding_headers() {
6125 let mut headers = axum::http::HeaderMap::new();
6126 headers.insert("forwarded", "for=203.0.113.9;by=10.1.2.3".parse().unwrap());
6127 headers.insert("x-forwarded-for", "203.0.113.9, 10.1.2.3".parse().unwrap());
6128 headers.insert("x-real-ip", "203.0.113.9".parse().unwrap());
6129 headers.insert("x-request-id", "req-123".parse().unwrap());
6130
6131 let out = format_request_headers_for_log(&headers);
6132 for name in ["forwarded", "x-forwarded-for", "x-real-ip"] {
6133 assert!(
6134 out.contains(&format!("{name}: [REDACTED]")),
6135 "{name} carries client IP / proxy topology and must not reach logs; got {out}"
6136 );
6137 }
6138 assert!(
6139 !out.contains("203.0.113.9") && !out.contains("10.1.2.3"),
6140 "no forwarded address may survive redaction; got {out}"
6141 );
6142 assert!(out.contains("x-request-id: req-123"));
6143 }
6144
6145 fn security_router(is_tls: bool) -> axum::Router {
6148 security_router_with(is_tls, SecurityHeadersConfig::default())
6149 }
6150
6151 fn security_router_with(is_tls: bool, cfg: SecurityHeadersConfig) -> axum::Router {
6152 let cfg = Arc::new(cfg);
6153 axum::Router::new()
6154 .route("/test", axum::routing::get(|| async { "ok" }))
6155 .layer(axum::middleware::from_fn(move |req, next| {
6156 let c = Arc::clone(&cfg);
6157 security_headers_middleware(is_tls, c, req, next)
6158 }))
6159 }
6160
6161 #[tokio::test]
6162 async fn security_headers_set_on_response() {
6163 let app = security_router(false);
6164 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
6165 let resp = app.oneshot(req).await.unwrap();
6166 assert_eq!(resp.status(), StatusCode::OK);
6167
6168 let h = resp.headers();
6169 assert_eq!(h.get("x-content-type-options").unwrap(), "nosniff");
6170 assert_eq!(h.get("x-frame-options").unwrap(), "deny");
6171 assert_eq!(h.get("cache-control").unwrap(), "no-store, max-age=0");
6172 assert_eq!(h.get("referrer-policy").unwrap(), "no-referrer");
6173 assert_eq!(h.get("cross-origin-opener-policy").unwrap(), "same-origin");
6174 assert_eq!(
6175 h.get("cross-origin-resource-policy").unwrap(),
6176 "same-origin"
6177 );
6178 assert_eq!(
6179 h.get("cross-origin-embedder-policy").unwrap(),
6180 "require-corp"
6181 );
6182 assert_eq!(h.get("x-permitted-cross-domain-policies").unwrap(), "none");
6183 assert!(
6184 h.get("permissions-policy")
6185 .unwrap()
6186 .to_str()
6187 .unwrap()
6188 .contains("camera=()"),
6189 "permissions-policy must restrict browser features"
6190 );
6191 assert_eq!(
6192 h.get("content-security-policy").unwrap(),
6193 "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
6194 );
6195 assert_eq!(h.get("x-dns-prefetch-control").unwrap(), "off");
6196 assert!(h.get("strict-transport-security").is_none());
6198 }
6199
6200 #[tokio::test]
6201 async fn hsts_set_when_tls_enabled() {
6202 let app = security_router(true);
6203 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
6204 let resp = app.oneshot(req).await.unwrap();
6205
6206 let hsts = resp.headers().get("strict-transport-security").unwrap();
6207 assert!(
6208 hsts.to_str().unwrap().contains("max-age=63072000"),
6209 "HSTS must set 2-year max-age"
6210 );
6211 }
6212
6213 #[tokio::test]
6214 async fn default_csp_matches_guideline() {
6215 let app = security_router(false);
6216 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
6217 let resp = app.oneshot(req).await.unwrap();
6218 assert_eq!(
6219 resp.headers().get("content-security-policy").unwrap(),
6220 "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
6221 );
6222 }
6223
6224 #[tokio::test]
6225 async fn operator_csp_override_still_wins() {
6226 let cfg = SecurityHeadersConfig {
6227 content_security_policy: Some("default-src 'self'".into()),
6228 ..SecurityHeadersConfig::default()
6229 };
6230 let app = security_router_with(false, cfg);
6231 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
6232 let resp = app.oneshot(req).await.unwrap();
6233 assert_eq!(
6234 resp.headers().get("content-security-policy").unwrap(),
6235 "default-src 'self'"
6236 );
6237 }
6238
6239 fn check_with_security_headers(
6245 headers: SecurityHeadersConfig,
6246 ) -> Result<(), RmcpServerKitError> {
6247 let cfg =
6248 McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0").with_security_headers(headers);
6249 cfg.check()
6250 }
6251
6252 #[test]
6253 fn security_headers_config_default_validates() {
6254 check_with_security_headers(SecurityHeadersConfig::default())
6255 .expect("default SecurityHeadersConfig must validate");
6256 }
6257
6258 #[test]
6259 fn security_headers_config_validate_accepts_empty_string() {
6260 let h = SecurityHeadersConfig {
6262 x_content_type_options: Some(String::new()),
6263 x_frame_options: Some(String::new()),
6264 cache_control: Some(String::new()),
6265 referrer_policy: Some(String::new()),
6266 cross_origin_opener_policy: Some(String::new()),
6267 cross_origin_resource_policy: Some(String::new()),
6268 cross_origin_embedder_policy: Some(String::new()),
6269 permissions_policy: Some(String::new()),
6270 x_permitted_cross_domain_policies: Some(String::new()),
6271 content_security_policy: Some(String::new()),
6272 x_dns_prefetch_control: Some(String::new()),
6273 strict_transport_security: Some(String::new()),
6274 };
6275 check_with_security_headers(h).expect("Some(\"\") on every field must validate (omit-all)");
6276 }
6277
6278 #[test]
6279 fn security_headers_config_validate_rejects_bad_value() {
6280 let h = SecurityHeadersConfig {
6282 referrer_policy: Some("\u{0007}".into()),
6283 ..SecurityHeadersConfig::default()
6284 };
6285 let err = check_with_security_headers(h)
6286 .expect_err("control char in referrer_policy must reject");
6287 let msg = err.to_string();
6288 assert!(
6289 msg.contains("referrer_policy"),
6290 "error must name the offending field, got: {msg}"
6291 );
6292 }
6293
6294 #[test]
6295 fn security_headers_config_validate_rejects_hsts_preload() {
6296 let h = SecurityHeadersConfig {
6297 strict_transport_security: Some("max-age=63072000; includeSubDomains; preload".into()),
6298 ..SecurityHeadersConfig::default()
6299 };
6300 let err = check_with_security_headers(h).expect_err("HSTS with preload must reject");
6301 let msg = err.to_string();
6302 assert!(
6303 msg.contains("strict_transport_security"),
6304 "error must name the field, got: {msg}"
6305 );
6306 assert!(
6307 msg.to_lowercase().contains("preload"),
6308 "error must mention `preload`, got: {msg}"
6309 );
6310 }
6311
6312 #[test]
6313 fn security_headers_config_validate_rejects_hsts_preload_uppercase() {
6314 let h = SecurityHeadersConfig {
6316 strict_transport_security: Some("max-age=600; PRELOAD".into()),
6317 ..SecurityHeadersConfig::default()
6318 };
6319 check_with_security_headers(h).expect_err("HSTS preload check must be case-insensitive");
6320 }
6321
6322 #[tokio::test]
6323 async fn security_headers_override_honored() {
6324 let h = SecurityHeadersConfig {
6326 x_frame_options: Some("SAMEORIGIN".into()),
6327 ..SecurityHeadersConfig::default()
6328 };
6329 let app = security_router_with(false, h);
6330 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
6331 let resp = app.oneshot(req).await.unwrap();
6332 assert_eq!(resp.status(), StatusCode::OK);
6333
6334 let xfo = resp.headers().get("x-frame-options").unwrap();
6335 assert_eq!(xfo, "SAMEORIGIN");
6336 }
6337
6338 #[tokio::test]
6339 async fn security_headers_empty_string_omits() {
6340 let h = SecurityHeadersConfig {
6342 referrer_policy: Some(String::new()),
6343 ..SecurityHeadersConfig::default()
6344 };
6345 let app = security_router_with(false, h);
6346 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
6347 let resp = app.oneshot(req).await.unwrap();
6348 assert_eq!(resp.status(), StatusCode::OK);
6349
6350 assert!(
6351 resp.headers().get("referrer-policy").is_none(),
6352 "Some(\"\") must omit the header"
6353 );
6354 assert_eq!(
6356 resp.headers().get("x-content-type-options").unwrap(),
6357 "nosniff"
6358 );
6359 }
6360
6361 #[tokio::test]
6362 async fn security_headers_hsts_only_when_tls() {
6363 let h = SecurityHeadersConfig {
6365 strict_transport_security: Some("max-age=600".into()),
6366 ..SecurityHeadersConfig::default()
6367 };
6368 let app = security_router_with(false, h);
6369 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
6370 let resp = app.oneshot(req).await.unwrap();
6371 assert!(
6372 resp.headers().get("strict-transport-security").is_none(),
6373 "HSTS must remain absent on plaintext deployments even with override"
6374 );
6375 }
6376
6377 #[cfg(feature = "oauth")]
6380 #[tokio::test]
6381 async fn oauth_token_cache_headers_set_pragma_and_vary() {
6382 let app = axum::Router::new()
6383 .route("/token", axum::routing::post(|| async { "{}" }))
6384 .layer(axum::middleware::from_fn(
6385 oauth_token_cache_headers_middleware,
6386 ));
6387 let req = Request::builder()
6388 .method("POST")
6389 .uri("/token")
6390 .body(Body::from("{}"))
6391 .unwrap();
6392 let resp = app.oneshot(req).await.unwrap();
6393 assert_eq!(resp.status(), StatusCode::OK);
6394
6395 let h = resp.headers();
6396 assert_eq!(
6397 h.get("pragma").unwrap(),
6398 "no-cache",
6399 "RFC 6749 §5.1: token responses must set Pragma: no-cache"
6400 );
6401 let vary_values: Vec<String> = h
6402 .get_all("vary")
6403 .iter()
6404 .filter_map(|v| v.to_str().ok().map(str::to_owned))
6405 .collect();
6406 assert!(
6407 vary_values
6408 .iter()
6409 .any(|v| v.eq_ignore_ascii_case("Authorization")),
6410 "RFC 6750 §5.4: Vary must include Authorization, got {vary_values:?}"
6411 );
6412 }
6413
6414 #[cfg(feature = "oauth")]
6415 #[tokio::test]
6416 async fn oauth_token_cache_headers_preserve_existing_vary() {
6417 let app = axum::Router::new()
6420 .route(
6421 "/token",
6422 axum::routing::post(|| async {
6423 axum::response::Response::builder()
6424 .header("vary", "Accept-Encoding")
6425 .body(Body::from("{}"))
6426 .unwrap()
6427 }),
6428 )
6429 .layer(axum::middleware::from_fn(
6430 oauth_token_cache_headers_middleware,
6431 ));
6432 let req = Request::builder()
6433 .method("POST")
6434 .uri("/token")
6435 .body(Body::empty())
6436 .unwrap();
6437 let resp = app.oneshot(req).await.unwrap();
6438
6439 let vary: Vec<String> = resp
6440 .headers()
6441 .get_all("vary")
6442 .iter()
6443 .filter_map(|v| v.to_str().ok().map(str::to_owned))
6444 .collect();
6445 assert!(
6446 vary.iter().any(|v| v.contains("Accept-Encoding")),
6447 "must preserve pre-existing Vary value, got {vary:?}"
6448 );
6449 assert!(
6450 vary.iter().any(|v| v.contains("Authorization")),
6451 "must append Authorization to Vary, got {vary:?}"
6452 );
6453 }
6454
6455 #[test]
6458 fn version_omits_build_fingerprint_by_default() {
6459 let v = version_payload("my-server", "1.2.3", false);
6460 assert_eq!(v["name"], "my-server");
6461 assert_eq!(v["version"], "1.2.3");
6462 assert!(v["rmcp_server_kit_version"].is_string());
6463 assert!(
6464 v.get("build_git_sha").is_none(),
6465 "build sha must be hidden by default"
6466 );
6467 assert!(v.get("build_timestamp").is_none());
6468 assert!(v.get("rust_version").is_none());
6469 }
6470
6471 #[test]
6472 fn version_exposes_all_when_enabled() {
6473 let v = version_payload("my-server", "1.2.3", true);
6474 assert!(v["build_git_sha"].is_string());
6475 assert!(v["build_timestamp"].is_string());
6476 assert!(v["rust_version"].is_string());
6477 assert!(v["rmcp_server_kit_version"].is_string());
6478 }
6479
6480 #[tokio::test]
6483 async fn concurrency_limit_layer_composes_and_serves() {
6484 let app = axum::Router::new()
6488 .route("/ok", axum::routing::get(|| async { "ok" }))
6489 .layer(
6490 tower::ServiceBuilder::new()
6491 .layer(axum::error_handling::HandleErrorLayer::new(
6492 |_err: tower::BoxError| async { StatusCode::SERVICE_UNAVAILABLE },
6493 ))
6494 .layer(tower::load_shed::LoadShedLayer::new())
6495 .layer(tower::limit::ConcurrencyLimitLayer::new(4)),
6496 );
6497 let resp = app
6498 .oneshot(Request::builder().uri("/ok").body(Body::empty()).unwrap())
6499 .await
6500 .unwrap();
6501 assert_eq!(resp.status(), StatusCode::OK);
6502 }
6503
6504 #[tokio::test]
6507 async fn compression_layer_gzip_encodes_response() {
6508 use tower_http::compression::Predicate as _;
6509
6510 let big_body = "a".repeat(4096);
6511 let app = axum::Router::new()
6512 .route(
6513 "/big",
6514 axum::routing::get(move || {
6515 let body = big_body.clone();
6516 async move { body }
6517 }),
6518 )
6519 .layer(
6520 tower_http::compression::CompressionLayer::new()
6521 .gzip(true)
6522 .br(true)
6523 .compress_when(
6524 tower_http::compression::DefaultPredicate::new()
6525 .and(tower_http::compression::predicate::SizeAbove::new(1024)),
6526 ),
6527 );
6528
6529 let req = Request::builder()
6530 .uri("/big")
6531 .header(header::ACCEPT_ENCODING, "gzip")
6532 .body(Body::empty())
6533 .unwrap();
6534 let resp = app.oneshot(req).await.unwrap();
6535 assert_eq!(resp.status(), StatusCode::OK);
6536 assert_eq!(
6537 resp.headers().get(header::CONTENT_ENCODING).unwrap(),
6538 "gzip"
6539 );
6540 }
6541
6542 #[tokio::test]
6545 async fn tls_handshake_timeout_reaps_idle_connections() {
6546 use tokio::io::AsyncReadExt as _;
6547
6548 let _ = rustls::crypto::ring::default_provider().install_default();
6549
6550 let key = rcgen::KeyPair::generate().expect("generate key");
6552 let cert = rcgen::CertificateParams::new(vec!["localhost".to_owned()])
6553 .expect("cert params")
6554 .self_signed(&key)
6555 .expect("self-signed cert");
6556 let dir = std::env::temp_dir().join(format!(
6557 "rmcp-server-kit-hs-timeout-{}",
6558 std::time::SystemTime::now()
6559 .duration_since(std::time::UNIX_EPOCH)
6560 .expect("clock after epoch")
6561 .as_nanos()
6562 ));
6563 tokio::fs::create_dir_all(&dir).await.expect("temp dir");
6564 let cert_path = dir.join("server.crt");
6565 let key_path = dir.join("server.key");
6566 tokio::fs::write(&cert_path, cert.pem())
6567 .await
6568 .expect("write cert");
6569 tokio::fs::write(&key_path, key.serialize_pem())
6570 .await
6571 .expect("write key");
6572
6573 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
6574 let tls = TlsListener::new(
6575 listener,
6576 &cert_path,
6577 &key_path,
6578 None,
6579 None,
6580 Duration::from_millis(200),
6581 8, )
6583 .expect("tls listener");
6584 let addr = axum::serve::Listener::local_addr(&tls).expect("local addr");
6585
6586 let mut idle = tokio::net::TcpStream::connect(addr).await.expect("connect");
6590 let mut buf = [0_u8; 16];
6591 let read = tokio::time::timeout(Duration::from_secs(2), idle.read(&mut buf))
6592 .await
6593 .expect("server must reap the idle handshake within its timeout");
6594 match read {
6595 Ok(0) | Err(_) => {} Ok(n) => panic!("unexpected {n} bytes from server during reaped handshake"),
6597 }
6598
6599 drop(tls);
6600 }
6601
6602 fn assert_owasp_headers(resp: &axum::response::Response, ctx: &str) {
6605 let h = resp.headers();
6606 assert!(
6607 h.contains_key("x-content-type-options"),
6608 "{ctx}: missing X-Content-Type-Options"
6609 );
6610 assert!(
6611 h.contains_key("x-frame-options"),
6612 "{ctx}: missing X-Frame-Options"
6613 );
6614 assert!(
6615 h.contains_key("strict-transport-security"),
6616 "{ctx}: missing Strict-Transport-Security"
6617 );
6618 assert!(
6619 h.contains_key(header::CONTENT_SECURITY_POLICY),
6620 "{ctx}: missing Content-Security-Policy"
6621 );
6622 }
6623
6624 fn m5_router(configure: impl FnOnce(&mut McpServerConfig)) -> axum::Router {
6625 #[derive(Clone)]
6626 struct H;
6627 impl ServerHandler for H {}
6628 let mut config = McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0")
6632 .with_allowed_origins(["http://good.example"])
6633 .with_tls("unused.crt", "unused.key");
6634 configure(&mut config);
6635 let (router, _params) = build_app_router(config, || H).expect("build_app_router");
6636 router
6637 }
6638
6639 #[test]
6644 #[should_panic(expected = "Overlapping method route")]
6645 fn extra_router_exact_overlap_with_framework_route_panics() {
6646 #[derive(Clone)]
6647 struct H;
6648 impl ServerHandler for H {}
6649 let config = McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0").with_extra_router(
6650 axum::Router::new().route("/healthz", axum::routing::get(|| async { "mine" })),
6651 );
6652 let _ = build_app_router(config, || H);
6653 }
6654
6655 #[test]
6659 fn extra_router_non_overlapping_path_under_framework_prefix_is_accepted() {
6660 #[derive(Clone)]
6661 struct H;
6662 impl ServerHandler for H {}
6663 let config = McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0").with_extra_router(
6664 axum::Router::new().route("/admin/custom", axum::routing::get(|| async { "mine" })),
6665 );
6666 assert!(
6667 build_app_router(config, || H).is_ok(),
6668 "non-overlapping path under a framework prefix must merge cleanly"
6669 );
6670 }
6671
6672 #[tokio::test]
6673 async fn headers_on_rejected_origin_403() {
6674 let app = m5_router(|_| {});
6675 let req = Request::builder()
6676 .uri("/healthz")
6677 .header(header::ORIGIN, "http://evil.example")
6678 .body(Body::empty())
6679 .unwrap();
6680 let resp = app.oneshot(req).await.unwrap();
6681 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
6682 assert_owasp_headers(&resp, "origin-403");
6683 }
6684
6685 #[tokio::test]
6686 async fn headers_on_cors_preflight() {
6687 let app = m5_router(|_| {});
6688 let req = Request::builder()
6689 .method(axum::http::Method::OPTIONS)
6690 .uri("/mcp")
6691 .header(header::ORIGIN, "http://good.example")
6692 .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
6693 .body(Body::empty())
6694 .unwrap();
6695 let resp = app.oneshot(req).await.unwrap();
6696 assert_owasp_headers(&resp, "cors-preflight");
6697 }
6698
6699 #[tokio::test]
6700 async fn headers_on_404_fallback() {
6701 let app = m5_router(|_| {});
6702 let req = Request::builder()
6703 .uri("/no-such-route")
6704 .body(Body::empty())
6705 .unwrap();
6706 let resp = app.oneshot(req).await.unwrap();
6707 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6708 assert_owasp_headers(&resp, "404-fallback");
6709 }
6710
6711 #[tokio::test]
6712 async fn headers_on_overload_503() {
6713 let app = m5_router(|c| c.max_concurrent_requests = Some(0));
6716 let req = Request::builder()
6717 .uri("/healthz")
6718 .body(Body::empty())
6719 .unwrap();
6720 let resp = app.oneshot(req).await.unwrap();
6721 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6722 assert_owasp_headers(&resp, "overload-503");
6723 }
6724
6725 #[cfg(feature = "oauth")]
6728 fn m6_auth_state() -> (Arc<AuthState>, String, String) {
6729 let (admin_token, admin_hash) = crate::auth::generate_api_key().unwrap();
6730 let (viewer_token, viewer_hash) = crate::auth::generate_api_key().unwrap();
6731 let state = Arc::new(AuthState {
6732 api_keys: ArcSwap::from_pointee(vec![
6733 crate::auth::ApiKeyEntry::new("admin-key", admin_hash, "admin"),
6734 crate::auth::ApiKeyEntry::new("viewer-key", viewer_hash, "viewer"),
6735 ]),
6736 rate_limiter: None,
6737 pre_auth_limiter: None,
6738 jwks_cache: None,
6739 seen_identities: crate::auth::SeenIdentitySet::new(),
6740 counters: crate::auth::AuthCounters::default(),
6741 resource_metadata_url: None,
6742 });
6743 (state, admin_token, viewer_token)
6744 }
6745
6746 #[cfg(feature = "oauth")]
6747 fn m6_admin_router(state: &Arc<AuthState>) -> axum::Router {
6748 let proxy = crate::oauth::OAuthProxyConfig::builder(
6749 "https://idp.example/authorize",
6750 "https://idp.example/token",
6751 "client",
6752 )
6753 .introspection_url("http://127.0.0.1:1/introspect")
6754 .revocation_url("http://127.0.0.1:1/revoke")
6755 .expose_admin_endpoints(true)
6756 .require_auth_on_admin_endpoints(true)
6757 .build();
6758 let http = crate::oauth::OauthHttpClient::new().expect("oauth http client");
6759 build_oauth_admin_router(&proxy, http, Some(state), "admin").expect("admin router")
6760 }
6761
6762 #[cfg(feature = "oauth")]
6763 fn m6_req(path: &str, token: &str) -> Request<Body> {
6764 Request::builder()
6765 .method(axum::http::Method::POST)
6766 .uri(path)
6767 .header(header::AUTHORIZATION, format!("Bearer {token}"))
6768 .body(Body::from("token=abc"))
6769 .unwrap()
6770 }
6771
6772 #[cfg(feature = "oauth")]
6773 #[tokio::test]
6774 async fn oauth_proxy_admin_requires_admin_role() {
6775 let (state, _admin, viewer) = m6_auth_state();
6776 for path in ["/introspect", "/revoke"] {
6777 let app = m6_admin_router(&state);
6778 let resp = app.oneshot(m6_req(path, &viewer)).await.unwrap();
6779 assert_eq!(
6780 resp.status(),
6781 StatusCode::FORBIDDEN,
6782 "an authenticated viewer must be rejected with 403 on {path}"
6783 );
6784 }
6785 }
6786
6787 #[cfg(feature = "oauth")]
6788 #[tokio::test]
6789 async fn oauth_proxy_admin_allows_admin_role() {
6790 let (state, admin, _viewer) = m6_auth_state();
6791 for path in ["/introspect", "/revoke"] {
6792 let app = m6_admin_router(&state);
6793 let resp = app.oneshot(m6_req(path, &admin)).await.unwrap();
6794 assert_ne!(
6798 resp.status(),
6799 StatusCode::FORBIDDEN,
6800 "an authenticated admin must pass the role gate on {path}"
6801 );
6802 assert_ne!(
6803 resp.status(),
6804 StatusCode::UNAUTHORIZED,
6805 "an authenticated admin must pass the auth gate on {path}"
6806 );
6807 }
6808 }
6809
6810 #[cfg(feature = "metrics")]
6818 mod metrics_labels_bounded {
6819 use super::*;
6820
6821 fn labels_for(method: &str, uri: &str) -> (&'static str, String) {
6822 let req = Request::builder()
6823 .method(method)
6824 .uri(uri)
6825 .body(Body::empty())
6826 .unwrap();
6827 metrics_labels(&req)
6828 }
6829
6830 #[test]
6831 fn many_unmatched_paths_collapse_to_one_label() {
6832 let mut seen = std::collections::HashSet::new();
6833 for i in 0..500 {
6834 let (_, path) = labels_for("GET", &format!("/nonexistent-{i}"));
6835 seen.insert(path);
6836 }
6837 assert_eq!(
6838 seen.len(),
6839 1,
6840 "unmatched paths must collapse to a single label, got {seen:?}"
6841 );
6842 assert!(seen.contains("<unmatched>"));
6843 }
6844
6845 #[test]
6846 fn nested_mcp_paths_collapse_to_the_mount_point() {
6847 let mut seen = std::collections::HashSet::new();
6848 for i in 0..200 {
6849 let (_, path) = labels_for("POST", &format!("/mcp/{i}"));
6850 seen.insert(path);
6851 }
6852 let (_, root) = labels_for("POST", "/mcp");
6853 seen.insert(root);
6854 assert_eq!(
6855 seen.len(),
6856 1,
6857 "nested /mcp paths must collapse to one label, got {seen:?}"
6858 );
6859 assert!(seen.contains("/mcp"));
6860 }
6861
6862 #[test]
6863 fn unusual_methods_collapse_to_one_bucket() {
6864 let mut seen = std::collections::HashSet::new();
6865 for verb in ["FROBNICATE", "WIBBLE", "QUUX", "M-SEARCH"] {
6866 let (method, _) = labels_for(verb, "/healthz");
6867 seen.insert(method);
6868 }
6869 assert_eq!(seen, std::collections::HashSet::from(["OTHER"]));
6870 }
6871
6872 #[test]
6873 fn known_methods_keep_their_identity() {
6874 for verb in ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] {
6875 let (method, _) = labels_for(verb, "/healthz");
6876 assert_eq!(method, verb);
6877 }
6878 }
6879
6880 #[test]
6881 fn raw_path_never_leaks_into_a_label() {
6882 let (_, path) = labels_for("GET", "/secret-token-abc123");
6883 assert!(
6884 !path.contains("secret-token"),
6885 "raw request path must never become a label value: {path}"
6886 );
6887 }
6888 }
6889
6890 mod origin_semantics {
6893 use super::*;
6894
6895 fn allowed(entries: &[&str]) -> Vec<AllowedOrigin> {
6896 entries
6897 .iter()
6898 .map(|entry| parse_allowed_origin(entry).expect("valid test entry"))
6899 .collect()
6900 }
6901
6902 #[test]
6903 fn request_parse_normalizes_scheme_host_and_ports() {
6904 assert_eq!(
6905 parse_request_origin_tuple("https://example.com"),
6906 Some(("https".to_owned(), "example.com".to_owned(), 443))
6907 );
6908 assert_eq!(
6909 parse_request_origin_tuple("HTTPS://EXAMPLE.COM"),
6910 Some(("https".to_owned(), "example.com".to_owned(), 443))
6911 );
6912 assert_eq!(
6913 parse_request_origin_tuple("http://example.com"),
6914 Some(("http".to_owned(), "example.com".to_owned(), 80))
6915 );
6916 assert_eq!(
6917 parse_request_origin_tuple("https://example.com:444"),
6918 Some(("https".to_owned(), "example.com".to_owned(), 444))
6919 );
6920 assert_eq!(
6921 parse_request_origin_tuple("https://example.com:443"),
6922 parse_request_origin_tuple("https://example.com"),
6923 "explicit default port must equal the implicit form"
6924 );
6925 }
6926
6927 #[test]
6928 fn request_parse_rejects_paths_queries_fragments_and_odd_schemes() {
6929 for value in [
6930 "https://example.com/",
6931 "https://example.com/path",
6932 "https://example.com?x=1",
6933 "https://example.com#frag",
6934 "ws://example.com",
6935 "https://",
6936 "https://:443",
6937 "",
6938 ] {
6939 assert_eq!(
6940 parse_request_origin_tuple(value),
6941 None,
6942 "{value:?} must be rejected"
6943 );
6944 }
6945 }
6946
6947 #[test]
6948 fn config_parse_tolerates_one_root_trailing_slash_only() {
6949 assert_eq!(
6950 parse_config_origin_tuple("https://example.com/"),
6951 parse_config_origin_tuple("https://example.com")
6952 );
6953 assert_eq!(
6954 parse_config_origin_tuple("https://example.com:443"),
6955 parse_config_origin_tuple("https://example.com")
6956 );
6957 for value in [
6958 "https://example.com//",
6959 "https://example.com/path/",
6960 "https://example.com?x=1",
6961 "https://example.com#frag",
6962 "ws://example.com",
6963 ] {
6964 assert_eq!(
6965 parse_config_origin_tuple(value),
6966 None,
6967 "{value:?} must be rejected"
6968 );
6969 }
6970 }
6971
6972 #[test]
6973 fn matching_uses_normalized_equality_not_raw_strings() {
6974 let set = allowed(&["HTTPS://Example.COM:443/"]);
6975 assert!(request_origin_allowed("https://example.com", &set));
6976 assert!(request_origin_allowed("https://EXAMPLE.com:443", &set));
6977 assert!(
6978 !request_origin_allowed("https://example.com:444", &set),
6979 "non-default ports must match exactly; there is no wildcard"
6980 );
6981 }
6982
6983 #[test]
6984 fn null_is_opt_in() {
6985 let without = allowed(&["https://example.com"]);
6986 assert!(!request_origin_allowed("null", &without));
6987 assert!(!request_origin_allowed("NULL", &without));
6988
6989 let with = allowed(&["null"]);
6990 assert!(request_origin_allowed("null", &with));
6991 assert!(request_origin_allowed("NULL", &with));
6992 assert!(!request_origin_allowed("https://example.com", &with));
6993 }
6994
6995 #[test]
6996 fn malformed_or_non_matching_origins_fail_closed() {
6997 let set = allowed(&["https://example.com"]);
6998 for value in [
6999 "",
7000 "garbage",
7001 "https://example.com/",
7002 "https://example.com:0",
7003 "https://evil.example",
7004 ] {
7005 assert!(
7006 !request_origin_allowed(value, &set),
7007 "{value:?} must not match"
7008 );
7009 }
7010 }
7011
7012 #[test]
7013 fn non_canonical_port_spellings_are_rejected() {
7014 for value in [
7017 "https://example.com:+443",
7018 "https://example.com:0443",
7019 "https://example.com: 443",
7020 "https://example.com:-443",
7021 "https://example.com:44 3",
7022 "https://example.com:65536",
7023 ] {
7024 assert_eq!(
7025 parse_request_origin_tuple(value),
7026 None,
7027 "{value:?} must be rejected"
7028 );
7029 assert_eq!(
7030 parse_config_origin_tuple(value),
7031 None,
7032 "{value:?} must be rejected in config too"
7033 );
7034 }
7035 }
7036
7037 #[tokio::test]
7038 async fn duplicate_origin_headers_are_rejected() {
7039 for values in [
7042 ["https://example.com", "https://example.com"],
7043 ["https://example.com", "https://evil.example"],
7044 ] {
7045 let app = origin_router(vec!["https://example.com".into()], false);
7046 let req = Request::builder()
7047 .uri("/test")
7048 .header(header::ORIGIN, values[0])
7049 .header(header::ORIGIN, values[1])
7050 .body(Body::empty())
7051 .unwrap();
7052 let resp = app.oneshot(req).await.unwrap();
7053 assert_eq!(
7054 resp.status(),
7055 StatusCode::FORBIDDEN,
7056 "duplicated Origin headers must fail closed: {values:?}"
7057 );
7058 }
7059 }
7060 }
7061
7062 #[cfg(feature = "metrics")]
7065 mod framework_metrics_guard {
7066 use prometheus::{IntCounterVec, opts};
7067
7068 use super::*;
7069
7070 fn identical_squatter() -> IntCounterVec {
7073 IntCounterVec::new(
7074 opts!("rmcp_server_kit_http_requests_total", "Total HTTP requests"),
7075 &["method", "path", "status"],
7076 )
7077 .expect("counter builds")
7078 }
7079
7080 #[test]
7081 fn identical_squatter_is_evicted_and_the_real_collector_rebound() {
7082 let metrics = crate::metrics::McpMetrics::new().expect("metrics build");
7083 metrics
7086 .registry
7087 .unregister(Box::new(metrics.http_requests_total.clone()))
7088 .expect("real collector was registered");
7089 metrics
7090 .registry
7091 .register(Box::new(identical_squatter()))
7092 .expect("squatter registers under the freed name");
7093
7094 ensure_framework_metrics_registered(&metrics).expect("guard repairs the registry");
7095
7096 metrics
7101 .http_requests_total
7102 .with_label_values(&["GET", "/healthz", "200"])
7103 .inc();
7104 let gathered = metrics.registry.gather();
7105 let family = gathered
7106 .iter()
7107 .find(|family| family.name() == "rmcp_server_kit_http_requests_total")
7108 .expect("framework family is served");
7109 assert_eq!(
7110 family.get_metric().len(),
7111 1,
7112 "the real collector's sample must be served exactly once"
7113 );
7114 }
7115
7116 #[test]
7117 fn idempotent_on_a_healthy_registry() {
7118 let metrics = crate::metrics::McpMetrics::new().expect("metrics build");
7119 ensure_framework_metrics_registered(&metrics).expect("first call is a no-op");
7120 ensure_framework_metrics_registered(&metrics).expect("second call is a no-op");
7121
7122 metrics
7123 .http_requests_total
7124 .with_label_values(&["GET", "/healthz", "200"])
7125 .inc();
7126 let gathered = metrics.registry.gather();
7127 let samples: usize = gathered
7128 .iter()
7129 .filter(|family| family.name() == "rmcp_server_kit_http_requests_total")
7130 .map(|family| family.get_metric().len())
7131 .sum();
7132 assert_eq!(
7133 samples, 1,
7134 "re-running the guard must not duplicate families"
7135 );
7136 }
7137
7138 #[test]
7139 fn divergent_help_cannot_be_registered_under_a_reserved_name() {
7140 let metrics = crate::metrics::McpMetrics::new().expect("metrics build");
7141 metrics
7142 .registry
7143 .unregister(Box::new(metrics.http_requests_total.clone()))
7144 .expect("real collector was registered");
7145
7146 let squatter = IntCounterVec::new(
7150 opts!("rmcp_server_kit_http_requests_total", "different help"),
7151 &["method", "path", "status"],
7152 )
7153 .expect("counter builds");
7154 let error = metrics
7155 .registry
7156 .register(Box::new(squatter))
7157 .expect_err("divergent-help squatter must be rejected");
7158 let rendered = format!("{error}");
7159 assert!(
7160 rendered.contains("rmcp_server_kit_http_requests_total")
7161 && rendered.contains("different"),
7162 "rejection must name the conflicting family: {rendered}"
7163 );
7164
7165 ensure_framework_metrics_registered(&metrics)
7167 .expect("guard restores the real collector");
7168 }
7169
7170 #[test]
7171 fn added_const_label_name_cannot_be_registered_under_a_reserved_name() {
7172 let metrics = crate::metrics::McpMetrics::new().expect("metrics build");
7173 metrics
7174 .registry
7175 .unregister(Box::new(metrics.rate_limited_total.clone()))
7176 .expect("real collector was registered");
7177
7178 let squatter = IntCounterVec::new(
7179 prometheus::Opts::new(
7180 "rmcp_server_kit_rate_limited_total",
7181 "Rate-limiter denials by limiter",
7182 )
7183 .const_label("squatter", "yes"),
7184 &["limiter"],
7185 )
7186 .expect("counter builds");
7187 metrics
7188 .registry
7189 .register(Box::new(squatter))
7190 .expect_err("const-label-divergent squatter must be rejected");
7191
7192 ensure_framework_metrics_registered(&metrics)
7193 .expect("guard restores the real collector");
7194 }
7195 }
7196}