1use std::{
2 future::Future,
3 net::{IpAddr, SocketAddr},
4 path::{Path, PathBuf},
5 pin::Pin,
6 sync::Arc,
7 time::Duration,
8};
9
10use arc_swap::ArcSwap;
11use axum::{
12 body::Body,
13 extract::{ConnectInfo, Request},
14 middleware::Next,
15 response::IntoResponse,
16};
17use rmcp::{
18 ServerHandler,
19 transport::streamable_http_server::{
20 StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager,
21 },
22};
23use rustls::RootCertStore;
24use tokio::{
25 net::TcpListener,
26 sync::{Semaphore, mpsc},
27};
28use tokio_util::sync::CancellationToken;
29
30use crate::{
31 auth::{
32 AuthConfig, AuthIdentity, AuthState, MtlsConfig, TlsConnInfo, auth_middleware,
33 build_rate_limiter, extract_mtls_identity,
34 },
35 bounded_limiter::BoundedKeyedLimiter,
36 error::McpxError,
37 mtls_revocation::{self, CrlSet, DynamicClientCertVerifier},
38 rbac::{RbacPolicy, ToolRateLimiter, build_tool_rate_limiter, rbac_middleware},
39};
40
41#[allow(
45 clippy::needless_pass_by_value,
46 reason = "consumed at .map_err(anyhow_to_startup) call sites; by-value matches the closure shape"
47)]
48fn anyhow_to_startup(e: anyhow::Error) -> McpxError {
49 McpxError::Startup(format!("{e:#}"))
50}
51
52#[allow(
58 clippy::needless_pass_by_value,
59 reason = "consumed at .map_err(|e| io_to_startup(...)) call sites; by-value matches the closure shape"
60)]
61fn io_to_startup(op: &str, e: std::io::Error) -> McpxError {
62 McpxError::Startup(format!("{op}: {e}"))
63}
64
65pub type ReadinessCheck =
70 Arc<dyn Fn() -> Pin<Box<dyn Future<Output = serde_json::Value> + Send>> + Send + Sync>;
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
117#[non_exhaustive]
118pub struct PeerAddr {
119 pub addr: SocketAddr,
121}
122
123impl PeerAddr {
124 #[must_use]
127 pub(crate) const fn new(addr: SocketAddr) -> Self {
128 Self { addr }
129 }
130}
131
132impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
141 type Rejection = (axum::http::StatusCode, &'static str);
142
143 #[allow(
144 clippy::unused_async_trait_impl,
145 reason = "async is mandated by the axum FromRequestParts trait signature; this impl only reads a request extension synchronously"
146 )]
147 async fn from_request_parts(
148 parts: &mut axum::http::request::Parts,
149 _state: &S,
150 ) -> Result<Self, Self::Rejection> {
151 parts.extensions.get::<Self>().copied().ok_or((
152 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
153 "peer address unavailable: not running under rmcp-server-kit serve()",
154 ))
155 }
156}
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
181#[non_exhaustive]
182pub struct ClientIp {
183 pub ip: IpAddr,
185}
186
187impl ClientIp {
188 #[must_use]
191 pub(crate) const fn new(ip: IpAddr) -> Self {
192 Self { ip }
193 }
194}
195
196#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
201#[serde(rename_all = "kebab-case")]
202#[non_exhaustive]
203pub enum ForwardedHeaderMode {
204 XForwardedFor,
206 Forwarded,
208}
209
210struct ForwardResolver {
213 trusted: Vec<ipnet::IpNet>,
214 mode: ForwardedHeaderMode,
215}
216
217#[derive(Debug, Clone, Default)]
238#[non_exhaustive]
239pub struct SecurityHeadersConfig {
240 pub x_content_type_options: Option<String>,
242 pub x_frame_options: Option<String>,
244 pub cache_control: Option<String>,
246 pub referrer_policy: Option<String>,
248 pub cross_origin_opener_policy: Option<String>,
250 pub cross_origin_resource_policy: Option<String>,
252 pub cross_origin_embedder_policy: Option<String>,
254 pub permissions_policy: Option<String>,
257 pub x_permitted_cross_domain_policies: Option<String>,
259 pub content_security_policy: Option<String>,
262 pub x_dns_prefetch_control: Option<String>,
264 pub strict_transport_security: Option<String>,
269}
270
271#[allow(
273 missing_debug_implementations,
274 reason = "contains callback/trait objects that don't impl Debug"
275)]
276#[allow(
277 clippy::struct_excessive_bools,
278 reason = "server configuration naturally has many boolean feature flags"
279)]
280#[non_exhaustive]
281pub struct McpServerConfig {
282 #[deprecated(
284 since = "0.13.0",
285 note = "use McpServerConfig::new() / with_bind_addr(); direct field access will become pub(crate) in a future major release"
286 )]
287 pub bind_addr: String,
288 #[deprecated(
290 since = "0.13.0",
291 note = "set via McpServerConfig::new(); direct field access will become pub(crate) in a future major release"
292 )]
293 pub name: String,
294 #[deprecated(
296 since = "0.13.0",
297 note = "set via McpServerConfig::new(); direct field access will become pub(crate) in a future major release"
298 )]
299 pub version: String,
300 #[deprecated(
302 since = "0.13.0",
303 note = "use McpServerConfig::with_tls(); direct field access will become pub(crate) in a future major release"
304 )]
305 pub tls_cert_path: Option<PathBuf>,
306 #[deprecated(
308 since = "0.13.0",
309 note = "use McpServerConfig::with_tls(); direct field access will become pub(crate) in a future major release"
310 )]
311 pub tls_key_path: Option<PathBuf>,
312 #[deprecated(
315 since = "0.13.0",
316 note = "use McpServerConfig::with_auth(); direct field access will become pub(crate) in a future major release"
317 )]
318 pub auth: Option<AuthConfig>,
319 #[deprecated(
322 since = "0.13.0",
323 note = "use McpServerConfig::with_rbac(); direct field access will become pub(crate) in a future major release"
324 )]
325 pub rbac: Option<Arc<RbacPolicy>>,
326 #[deprecated(
332 since = "0.13.0",
333 note = "use McpServerConfig::with_allowed_origins(); direct field access will become pub(crate) in a future major release"
334 )]
335 pub allowed_origins: Vec<String>,
336 #[deprecated(
339 since = "0.13.0",
340 note = "use McpServerConfig::with_tool_rate_limit(); direct field access will become pub(crate) in a future major release"
341 )]
342 pub tool_rate_limit: Option<u32>,
343 #[deprecated(
349 since = "1.12.0",
350 note = "use McpServerConfig::with_tool_rate_limit_burst(); direct field access will become pub(crate) in a future major release"
351 )]
352 pub tool_rate_limit_burst: Option<u32>,
353 #[deprecated(
366 since = "1.11.0",
367 note = "use McpServerConfig::with_extra_route_rate_limit(); direct field access will become pub(crate) in a future major release"
368 )]
369 pub extra_route_rate_limit: Option<u32>,
370 #[deprecated(
377 since = "1.12.0",
378 note = "use McpServerConfig::with_extra_route_rate_limit_burst(); direct field access will become pub(crate) in a future major release"
379 )]
380 pub extra_route_rate_limit_burst: Option<u32>,
381 #[deprecated(
394 since = "1.14.0",
395 note = "use McpServerConfig::with_extra_route_rate_limit_exempt_paths(); direct field access will become pub(crate) in a future major release"
396 )]
397 pub extra_route_rate_limit_exempt_paths: Vec<String>,
398 #[deprecated(
406 since = "1.13.0",
407 note = "use McpServerConfig::with_trusted_proxies(); direct field access will become pub(crate) in a future major release"
408 )]
409 pub trusted_proxies: Vec<String>,
410 #[deprecated(
415 since = "1.13.0",
416 note = "use McpServerConfig::with_forwarded_header(); direct field access will become pub(crate) in a future major release"
417 )]
418 pub forwarded_header: Option<ForwardedHeaderMode>,
419 #[deprecated(
422 since = "0.13.0",
423 note = "use McpServerConfig::with_readiness_check(); direct field access will become pub(crate) in a future major release"
424 )]
425 pub readiness_check: Option<ReadinessCheck>,
426 #[deprecated(
429 since = "0.13.0",
430 note = "use McpServerConfig::with_max_request_body(); direct field access will become pub(crate) in a future major release"
431 )]
432 pub max_request_body: usize,
433 #[deprecated(
436 since = "0.13.0",
437 note = "use McpServerConfig::with_request_timeout(); direct field access will become pub(crate) in a future major release"
438 )]
439 pub request_timeout: Duration,
440 #[deprecated(
443 since = "0.13.0",
444 note = "use McpServerConfig::with_shutdown_timeout(); direct field access will become pub(crate) in a future major release"
445 )]
446 pub shutdown_timeout: Duration,
447 #[deprecated(
450 since = "0.13.0",
451 note = "use McpServerConfig::with_session_idle_timeout(); direct field access will become pub(crate) in a future major release"
452 )]
453 pub session_idle_timeout: Duration,
454 #[deprecated(
457 since = "0.13.0",
458 note = "use McpServerConfig::with_sse_keep_alive(); direct field access will become pub(crate) in a future major release"
459 )]
460 pub sse_keep_alive: Duration,
461 #[deprecated(
465 since = "0.13.0",
466 note = "use McpServerConfig::with_reload_callback(); direct field access will become pub(crate) in a future major release"
467 )]
468 pub on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
469 #[deprecated(
476 since = "0.13.0",
477 note = "use McpServerConfig::with_extra_router(); direct field access will become pub(crate) in a future major release"
478 )]
479 pub extra_router: Option<axum::Router>,
480 #[deprecated(
485 since = "0.13.0",
486 note = "use McpServerConfig::with_public_url(); direct field access will become pub(crate) in a future major release"
487 )]
488 pub public_url: Option<String>,
489 #[deprecated(
492 since = "0.13.0",
493 note = "use McpServerConfig::enable_request_header_logging(); direct field access will become pub(crate) in a future major release"
494 )]
495 pub log_request_headers: bool,
496 pub expose_build_metadata: bool,
503 #[deprecated(
506 since = "0.13.0",
507 note = "use McpServerConfig::enable_compression(); direct field access will become pub(crate) in a future major release"
508 )]
509 pub compression_enabled: bool,
510 #[deprecated(
513 since = "0.13.0",
514 note = "use McpServerConfig::enable_compression(); direct field access will become pub(crate) in a future major release"
515 )]
516 pub compression_min_size: u16,
517 #[deprecated(
521 since = "0.13.0",
522 note = "use McpServerConfig::with_max_concurrent_requests(); direct field access will become pub(crate) in a future major release"
523 )]
524 pub max_concurrent_requests: Option<usize>,
525 #[deprecated(
528 since = "0.13.0",
529 note = "use McpServerConfig::enable_admin(); direct field access will become pub(crate) in a future major release"
530 )]
531 pub admin_enabled: bool,
532 #[deprecated(
534 since = "0.13.0",
535 note = "use McpServerConfig::enable_admin(); direct field access will become pub(crate) in a future major release"
536 )]
537 pub admin_role: String,
538 #[cfg(feature = "metrics")]
541 #[deprecated(
542 since = "0.13.0",
543 note = "use McpServerConfig::with_metrics(); direct field access will become pub(crate) in a future major release"
544 )]
545 pub metrics_enabled: bool,
546 #[cfg(feature = "metrics")]
548 #[deprecated(
549 since = "0.13.0",
550 note = "use McpServerConfig::with_metrics(); direct field access will become pub(crate) in a future major release"
551 )]
552 pub metrics_bind: String,
553 #[deprecated(
557 since = "1.5.0",
558 note = "use McpServerConfig::with_security_headers(); direct field access will become pub(crate) in a future major release"
559 )]
560 pub security_headers: SecurityHeadersConfig,
561 #[deprecated(
567 since = "1.9.0",
568 note = "use McpServerConfig::with_tls_handshake_timeout(); direct field access will become pub(crate) in a future major release"
569 )]
570 pub tls_handshake_timeout: Duration,
571 #[deprecated(
578 since = "1.9.0",
579 note = "use McpServerConfig::with_max_concurrent_tls_handshakes(); direct field access will become pub(crate) in a future major release"
580 )]
581 pub max_concurrent_tls_handshakes: usize,
582}
583
584#[allow(
642 missing_debug_implementations,
643 reason = "wraps T which may not implement Debug; manual impl below avoids leaking inner contents into logs"
644)]
645pub struct Validated<T>(T);
646
647impl<T> std::fmt::Debug for Validated<T> {
648 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
649 f.debug_struct("Validated").finish_non_exhaustive()
650 }
651}
652
653impl<T> Validated<T> {
654 #[must_use]
656 pub fn as_inner(&self) -> &T {
657 &self.0
658 }
659
660 #[must_use]
665 pub fn into_inner(self) -> T {
666 self.0
667 }
668}
669
670#[allow(
671 deprecated,
672 reason = "internal builders/validators legitimately read/write the deprecated `pub` fields they were designed to manage"
673)]
674impl McpServerConfig {
675 #[must_use]
683 pub fn new(
684 bind_addr: impl Into<String>,
685 name: impl Into<String>,
686 version: impl Into<String>,
687 ) -> Self {
688 Self {
689 bind_addr: bind_addr.into(),
690 name: name.into(),
691 version: version.into(),
692 tls_cert_path: None,
693 tls_key_path: None,
694 auth: None,
695 rbac: None,
696 allowed_origins: Vec::new(),
697 tool_rate_limit: None,
698 readiness_check: None,
699 max_request_body: 1024 * 1024,
700 request_timeout: Duration::from_mins(2),
701 shutdown_timeout: Duration::from_secs(30),
702 session_idle_timeout: Duration::from_mins(20),
703 sse_keep_alive: Duration::from_secs(15),
704 on_reload_ready: None,
705 extra_router: None,
706 public_url: None,
707 log_request_headers: false,
708 expose_build_metadata: false,
709 compression_enabled: false,
710 compression_min_size: 1024,
711 max_concurrent_requests: None,
712 admin_enabled: false,
713 admin_role: "admin".to_owned(),
714 #[cfg(feature = "metrics")]
715 metrics_enabled: false,
716 #[cfg(feature = "metrics")]
717 metrics_bind: "127.0.0.1:9090".into(),
718 security_headers: SecurityHeadersConfig::default(),
719 tls_handshake_timeout: DEFAULT_TLS_HANDSHAKE_TIMEOUT,
720 max_concurrent_tls_handshakes: DEFAULT_MAX_CONCURRENT_TLS_HANDSHAKES,
721 extra_route_rate_limit: None,
722 tool_rate_limit_burst: None,
723 extra_route_rate_limit_burst: None,
724 extra_route_rate_limit_exempt_paths: Vec::new(),
725 trusted_proxies: Vec::new(),
726 forwarded_header: None,
727 }
728 }
729
730 #[must_use]
740 pub fn with_auth(mut self, auth: AuthConfig) -> Self {
741 self.auth = Some(auth);
742 self
743 }
744
745 #[must_use]
750 pub fn with_security_headers(mut self, headers: SecurityHeadersConfig) -> Self {
751 self.security_headers = headers;
752 self
753 }
754
755 #[must_use]
759 pub fn with_bind_addr(mut self, addr: impl Into<String>) -> Self {
760 self.bind_addr = addr.into();
761 self
762 }
763
764 #[must_use]
767 pub fn with_rbac(mut self, rbac: Arc<RbacPolicy>) -> Self {
768 self.rbac = Some(rbac);
769 self
770 }
771
772 #[must_use]
776 pub fn with_tls(mut self, cert_path: impl Into<PathBuf>, key_path: impl Into<PathBuf>) -> Self {
777 self.tls_cert_path = Some(cert_path.into());
778 self.tls_key_path = Some(key_path.into());
779 self
780 }
781
782 #[must_use]
786 pub fn with_public_url(mut self, url: impl Into<String>) -> Self {
787 self.public_url = Some(url.into());
788 self
789 }
790
791 #[must_use]
795 pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Self
796 where
797 I: IntoIterator<Item = S>,
798 S: Into<String>,
799 {
800 self.allowed_origins = origins.into_iter().map(Into::into).collect();
801 self
802 }
803
804 #[must_use]
817 pub fn with_extra_router(mut self, router: axum::Router) -> Self {
818 self.extra_router = Some(router);
819 self
820 }
821
822 #[must_use]
825 pub fn with_readiness_check(mut self, check: ReadinessCheck) -> Self {
826 self.readiness_check = Some(check);
827 self
828 }
829
830 #[must_use]
833 pub fn with_max_request_body(mut self, bytes: usize) -> Self {
834 self.max_request_body = bytes;
835 self
836 }
837
838 #[must_use]
840 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
841 self.request_timeout = timeout;
842 self
843 }
844
845 #[must_use]
847 pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
848 self.shutdown_timeout = timeout;
849 self
850 }
851
852 #[must_use]
854 pub fn with_session_idle_timeout(mut self, timeout: Duration) -> Self {
855 self.session_idle_timeout = timeout;
856 self
857 }
858
859 #[must_use]
861 pub fn with_sse_keep_alive(mut self, interval: Duration) -> Self {
862 self.sse_keep_alive = interval;
863 self
864 }
865
866 #[must_use]
870 pub fn with_max_concurrent_requests(mut self, limit: usize) -> Self {
871 self.max_concurrent_requests = Some(limit);
872 self
873 }
874
875 #[must_use]
883 pub fn with_tls_handshake_timeout(mut self, timeout: Duration) -> Self {
884 self.tls_handshake_timeout = timeout;
885 self
886 }
887
888 #[must_use]
897 pub fn with_max_concurrent_tls_handshakes(mut self, limit: usize) -> Self {
898 self.max_concurrent_tls_handshakes = limit;
899 self
900 }
901
902 #[must_use]
905 pub fn with_tool_rate_limit(mut self, per_minute: u32) -> Self {
906 self.tool_rate_limit = Some(per_minute);
907 self
908 }
909
910 #[must_use]
921 pub fn with_extra_route_rate_limit(mut self, per_minute: u32) -> Self {
922 self.extra_route_rate_limit = Some(per_minute);
923 self
924 }
925
926 #[must_use]
931 pub fn with_tool_rate_limit_burst(mut self, burst: u32) -> Self {
932 self.tool_rate_limit_burst = Some(burst);
933 self
934 }
935
936 #[must_use]
942 pub fn with_extra_route_rate_limit_burst(mut self, burst: u32) -> Self {
943 self.extra_route_rate_limit_burst = Some(burst);
944 self
945 }
946
947 #[must_use]
967 pub fn with_extra_route_rate_limit_exempt_paths<I, S>(mut self, paths: I) -> Self
968 where
969 I: IntoIterator<Item = S>,
970 S: Into<String>,
971 {
972 self.extra_route_rate_limit_exempt_paths = paths.into_iter().map(Into::into).collect();
973 self
974 }
975
976 #[must_use]
988 pub fn with_trusted_proxies<I, S>(mut self, proxies: I) -> Self
989 where
990 I: IntoIterator<Item = S>,
991 S: Into<String>,
992 {
993 self.trusted_proxies = proxies.into_iter().map(Into::into).collect();
994 self
995 }
996
997 #[must_use]
1002 pub fn with_forwarded_header(mut self, mode: ForwardedHeaderMode) -> Self {
1003 self.forwarded_header = Some(mode);
1004 self
1005 }
1006
1007 #[must_use]
1011 pub fn with_reload_callback<F>(mut self, callback: F) -> Self
1012 where
1013 F: FnOnce(ReloadHandle) + Send + 'static,
1014 {
1015 self.on_reload_ready = Some(Box::new(callback));
1016 self
1017 }
1018
1019 #[must_use]
1023 pub fn enable_compression(mut self, min_size: u16) -> Self {
1024 self.compression_enabled = true;
1025 self.compression_min_size = min_size;
1026 self
1027 }
1028
1029 #[must_use]
1034 pub fn enable_admin(mut self, role: impl Into<String>) -> Self {
1035 self.admin_enabled = true;
1036 self.admin_role = role.into();
1037 self
1038 }
1039
1040 #[must_use]
1043 pub fn enable_request_header_logging(mut self) -> Self {
1044 self.log_request_headers = true;
1045 self
1046 }
1047
1048 #[must_use]
1053 pub fn expose_build_metadata(mut self) -> Self {
1054 self.expose_build_metadata = true;
1055 self
1056 }
1057
1058 #[cfg(feature = "metrics")]
1061 #[must_use]
1062 pub fn with_metrics(mut self, bind: impl Into<String>) -> Self {
1063 self.metrics_enabled = true;
1064 self.metrics_bind = bind.into();
1065 self
1066 }
1067
1068 pub fn validate(self) -> Result<Validated<Self>, McpxError> {
1101 self.check()?;
1102 Ok(Validated(self))
1103 }
1104
1105 fn check_burst_knobs(&self) -> Result<(), McpxError> {
1112 if self.tool_rate_limit_burst == Some(0) {
1113 return Err(McpxError::Config(
1114 "tool_rate_limit_burst must be greater than zero".into(),
1115 ));
1116 }
1117 if self.extra_route_rate_limit_burst == Some(0) {
1118 return Err(McpxError::Config(
1119 "extra_route_rate_limit_burst must be greater than zero".into(),
1120 ));
1121 }
1122 if self.tool_rate_limit_burst.is_some() && self.tool_rate_limit.is_none() {
1123 return Err(McpxError::Config(
1124 "tool_rate_limit_burst requires tool_rate_limit to be set".into(),
1125 ));
1126 }
1127 if self.extra_route_rate_limit_burst.is_some() && self.extra_route_rate_limit.is_none() {
1128 return Err(McpxError::Config(
1129 "extra_route_rate_limit_burst requires extra_route_rate_limit to be set".into(),
1130 ));
1131 }
1132 if !self.extra_route_rate_limit_exempt_paths.is_empty()
1133 && self.extra_route_rate_limit.is_none()
1134 {
1135 return Err(McpxError::Config(
1136 "extra_route_rate_limit_exempt_paths requires extra_route_rate_limit to be set"
1137 .into(),
1138 ));
1139 }
1140 for path in &self.extra_route_rate_limit_exempt_paths {
1141 if path.is_empty() || !path.starts_with('/') {
1142 return Err(McpxError::Config(format!(
1143 "extra_route_rate_limit_exempt_paths entries must be non-empty and start with '/': {path:?}"
1144 )));
1145 }
1146 }
1147 if let Some(rl) = self.auth.as_ref().and_then(|a| a.rate_limit.as_ref()) {
1148 if rl.burst == Some(0) {
1149 return Err(McpxError::Config(
1150 "auth rate_limit.burst must be greater than zero".into(),
1151 ));
1152 }
1153 if rl.pre_auth_burst == Some(0) {
1154 return Err(McpxError::Config(
1155 "auth rate_limit.pre_auth_burst must be greater than zero".into(),
1156 ));
1157 }
1158 }
1159 Ok(())
1160 }
1161
1162 fn check_trusted_forwarder(&self) -> Result<(), McpxError> {
1167 for entry in &self.trusted_proxies {
1168 validate_trusted_proxy_entry(entry).map_err(McpxError::Config)?;
1169 }
1170 if self.forwarded_header.is_some() && self.trusted_proxies.is_empty() {
1171 return Err(McpxError::Config(
1172 "forwarded_header requires trusted_proxies to be nonempty".into(),
1173 ));
1174 }
1175 Ok(())
1176 }
1177
1178 fn check(&self) -> Result<(), McpxError> {
1182 if self.admin_enabled {
1186 let auth_enabled = self.auth.as_ref().is_some_and(|a| a.enabled);
1187 if !auth_enabled {
1188 return Err(McpxError::Config(
1189 "admin_enabled=true requires auth to be configured and enabled".into(),
1190 ));
1191 }
1192 }
1193
1194 match (&self.tls_cert_path, &self.tls_key_path) {
1196 (Some(_), None) => {
1197 return Err(McpxError::Config(
1198 "tls_cert_path is set but tls_key_path is missing".into(),
1199 ));
1200 }
1201 (None, Some(_)) => {
1202 return Err(McpxError::Config(
1203 "tls_key_path is set but tls_cert_path is missing".into(),
1204 ));
1205 }
1206 _ => {}
1207 }
1208
1209 if self.bind_addr.parse::<SocketAddr>().is_err() {
1211 return Err(McpxError::Config(format!(
1212 "bind_addr {:?} is not a valid socket address (expected e.g. 127.0.0.1:8080)",
1213 self.bind_addr
1214 )));
1215 }
1216
1217 if let Some(ref url) = self.public_url
1219 && !(url.starts_with("http://") || url.starts_with("https://"))
1220 {
1221 return Err(McpxError::Config(format!(
1222 "public_url {url:?} must start with http:// or https://"
1223 )));
1224 }
1225
1226 for origin in &self.allowed_origins {
1228 if !(origin.starts_with("http://") || origin.starts_with("https://")) {
1229 return Err(McpxError::Config(format!(
1230 "allowed_origins entry {origin:?} must start with http:// or https://"
1231 )));
1232 }
1233 }
1234
1235 if self.max_request_body == 0 {
1237 return Err(McpxError::Config(
1238 "max_request_body must be greater than zero".into(),
1239 ));
1240 }
1241
1242 if self.extra_route_rate_limit == Some(0) {
1246 return Err(McpxError::Config(
1247 "extra_route_rate_limit must be greater than zero".into(),
1248 ));
1249 }
1250
1251 self.check_burst_knobs()?;
1253
1254 self.check_trusted_forwarder()?;
1256
1257 #[cfg(feature = "oauth")]
1259 if let Some(auth_cfg) = &self.auth
1260 && let Some(oauth_cfg) = &auth_cfg.oauth
1261 {
1262 oauth_cfg.validate()?;
1263 }
1264
1265 validate_security_headers(&self.security_headers)?;
1268
1269 if self.max_concurrent_requests == Some(0) {
1273 return Err(McpxError::Config(
1274 "max_concurrent_requests must be greater than zero when set".into(),
1275 ));
1276 }
1277
1278 if let Some(auth_cfg) = &self.auth
1282 && let Some(rl) = &auth_cfg.rate_limit
1283 && rl.max_tracked_keys == 0
1284 {
1285 return Err(McpxError::Config(
1286 "auth.rate_limit.max_tracked_keys must be greater than zero".into(),
1287 ));
1288 }
1289
1290 if self.tls_handshake_timeout == Duration::ZERO {
1295 return Err(McpxError::Config(
1296 "tls_handshake_timeout must be greater than zero".into(),
1297 ));
1298 }
1299
1300 if self.max_concurrent_tls_handshakes == 0 {
1305 return Err(McpxError::Config(
1306 "max_concurrent_tls_handshakes must be greater than zero".into(),
1307 ));
1308 }
1309
1310 Ok(())
1311 }
1312}
1313
1314#[allow(
1320 missing_debug_implementations,
1321 reason = "contains Arc<AuthState> with non-Debug fields"
1322)]
1323pub struct ReloadHandle {
1324 auth: Option<Arc<AuthState>>,
1325 rbac: Option<Arc<ArcSwap<RbacPolicy>>>,
1326 crl_set: Option<Arc<CrlSet>>,
1327}
1328
1329impl ReloadHandle {
1330 pub fn reload_auth_keys(&self, keys: Vec<crate::auth::ApiKeyEntry>) {
1332 if let Some(ref auth) = self.auth {
1333 auth.reload_keys(keys);
1334 }
1335 }
1336
1337 pub fn reload_rbac(&self, policy: RbacPolicy) {
1339 if let Some(ref rbac) = self.rbac {
1340 rbac.store(Arc::new(policy));
1341 tracing::info!("RBAC policy reloaded");
1342 }
1343 }
1344
1345 pub async fn refresh_crls(&self) -> Result<(), McpxError> {
1351 let Some(ref crl_set) = self.crl_set else {
1352 return Err(McpxError::Config(
1353 "CRL refresh requested but mTLS CRL support is not configured".into(),
1354 ));
1355 };
1356
1357 crl_set.force_refresh().await
1358 }
1359}
1360
1361#[allow(
1378 clippy::too_many_lines,
1379 clippy::cognitive_complexity,
1380 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"
1381)]
1382struct AppRunParams {
1386 tls_paths: Option<(PathBuf, PathBuf)>,
1388 tls_handshake_timeout: Duration,
1390 max_concurrent_tls_handshakes: usize,
1392 mtls_config: Option<MtlsConfig>,
1394 shutdown_timeout: Duration,
1396 auth_state: Option<Arc<AuthState>>,
1398 rbac_swap: Arc<ArcSwap<RbacPolicy>>,
1400 on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
1402 ct: CancellationToken,
1406 scheme: &'static str,
1408 name: String,
1410}
1411
1412#[allow(
1422 clippy::cognitive_complexity,
1423 reason = "router assembly is intrinsically sequential; splitting harms readability"
1424)]
1425#[allow(
1426 deprecated,
1427 reason = "internal router assembly reads deprecated `pub` config fields by design until 1.0 makes them pub(crate)"
1428)]
1429fn build_app_router<H, F>(
1430 mut config: McpServerConfig,
1431 handler_factory: F,
1432) -> anyhow::Result<(axum::Router, AppRunParams)>
1433where
1434 H: ServerHandler + 'static,
1435 F: Fn() -> H + Send + Sync + Clone + 'static,
1436{
1437 let ct = CancellationToken::new();
1438
1439 let allowed_hosts = derive_allowed_hosts(&config.bind_addr, config.public_url.as_deref());
1440 tracing::info!(allowed_hosts = %allowed_hosts.join(", "), "configured Streamable HTTP allowed hosts");
1441
1442 let mcp_service = StreamableHttpService::new(
1443 move || Ok(handler_factory()),
1444 {
1445 let mut mgr = LocalSessionManager::default();
1446 mgr.session_config.keep_alive = Some(config.session_idle_timeout);
1447 mgr.into()
1448 },
1449 StreamableHttpServerConfig::default()
1450 .with_allowed_hosts(allowed_hosts)
1451 .with_sse_keep_alive(Some(config.sse_keep_alive))
1452 .with_cancellation_token(ct.child_token()),
1453 );
1454
1455 let mut mcp_router = axum::Router::new().nest_service("/mcp", mcp_service);
1457
1458 let auth_state: Option<Arc<AuthState>> = match config.auth {
1462 Some(ref auth_config) if auth_config.enabled => {
1463 let rate_limiter = auth_config.rate_limit.as_ref().map(build_rate_limiter);
1464 let pre_auth_limiter = auth_config
1465 .rate_limit
1466 .as_ref()
1467 .map(crate::auth::build_pre_auth_limiter);
1468
1469 #[cfg(feature = "oauth")]
1470 let jwks_cache = auth_config
1471 .oauth
1472 .as_ref()
1473 .map(|c| crate::oauth::JwksCache::new(c).map(Arc::new))
1474 .transpose()
1475 .map_err(|e| std::io::Error::other(format!("JWKS HTTP client: {e}")))?;
1476
1477 Some(Arc::new(AuthState {
1478 api_keys: ArcSwap::new(Arc::new(auth_config.api_keys.clone())),
1479 rate_limiter,
1480 pre_auth_limiter,
1481 #[cfg(feature = "oauth")]
1482 jwks_cache,
1483 seen_identities: crate::auth::SeenIdentitySet::new(),
1484 counters: crate::auth::AuthCounters::default(),
1485 }))
1486 }
1487 _ => None,
1488 };
1489
1490 let rbac_swap = Arc::new(ArcSwap::new(
1493 config
1494 .rbac
1495 .clone()
1496 .unwrap_or_else(|| Arc::new(RbacPolicy::disabled())),
1497 ));
1498
1499 if config.admin_enabled {
1502 let Some(ref auth_state_ref) = auth_state else {
1503 return Err(anyhow::anyhow!(
1504 "admin_enabled=true requires auth to be configured and enabled"
1505 ));
1506 };
1507 let admin_state = crate::admin::AdminState {
1508 started_at: std::time::Instant::now(),
1509 name: config.name.clone(),
1510 version: config.version.clone(),
1511 auth: Some(Arc::clone(auth_state_ref)),
1512 rbac: Arc::clone(&rbac_swap),
1513 };
1514 let admin_cfg = crate::admin::AdminConfig {
1515 role: config.admin_role.clone(),
1516 };
1517 mcp_router = mcp_router.merge(crate::admin::admin_router(admin_state, &admin_cfg));
1518 tracing::info!(role = %config.admin_role, "/admin/* endpoints enabled");
1519 }
1520
1521 {
1554 let tool_limiter: Option<Arc<ToolRateLimiter>> = config
1555 .tool_rate_limit
1556 .map(|per_minute| build_tool_rate_limiter(per_minute, config.tool_rate_limit_burst));
1557
1558 if rbac_swap.load().is_enabled() {
1559 tracing::info!("RBAC enforcement enabled on /mcp");
1560 }
1561 if let Some(limit) = config.tool_rate_limit {
1562 tracing::info!(limit, "tool rate limiting enabled (calls/min per IP)");
1563 }
1564
1565 let rbac_for_mw = Arc::clone(&rbac_swap);
1566 mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
1567 let p = rbac_for_mw.load_full();
1568 let tl = tool_limiter.clone();
1569 rbac_middleware(p, tl, req, next)
1570 }));
1571 }
1572
1573 if let Some(ref auth_config) = config.auth
1575 && auth_config.enabled
1576 {
1577 let Some(ref state) = auth_state else {
1578 return Err(anyhow::anyhow!("auth state missing despite enabled config"));
1579 };
1580
1581 let methods: Vec<&str> = [
1582 auth_config.mtls.is_some().then_some("mTLS"),
1583 (!auth_config.api_keys.is_empty()).then_some("bearer"),
1584 #[cfg(feature = "oauth")]
1585 auth_config.oauth.is_some().then_some("oauth-jwt"),
1586 ]
1587 .into_iter()
1588 .flatten()
1589 .collect();
1590
1591 tracing::info!(
1592 methods = %methods.join(", "),
1593 api_keys = auth_config.api_keys.len(),
1594 "auth enabled on /mcp"
1595 );
1596
1597 let state_for_mw = Arc::clone(state);
1598 mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
1599 let s = Arc::clone(&state_for_mw);
1600 auth_middleware(s, req, next)
1601 }));
1602 }
1603
1604 mcp_router = mcp_router.layer(tower_http::timeout::TimeoutLayer::with_status_code(
1607 axum::http::StatusCode::REQUEST_TIMEOUT,
1608 config.request_timeout,
1609 ));
1610
1611 mcp_router = mcp_router.layer(tower_http::limit::RequestBodyLimitLayer::new(
1615 config.max_request_body,
1616 ));
1617
1618 let mut effective_origins = config.allowed_origins.clone();
1625 if effective_origins.is_empty()
1626 && let Some(ref url) = config.public_url
1627 {
1628 if let Some(scheme_end) = url.find("://") {
1633 let scheme_with_sep = url.get(..scheme_end + 3).unwrap_or_default();
1634 let after_scheme = url.get(scheme_end + 3..).unwrap_or_default();
1635 let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
1636 let host = after_scheme.get(..host_end).unwrap_or_default();
1637 let origin = format!("{scheme_with_sep}{host}");
1638 tracing::info!(
1639 %origin,
1640 "auto-derived allowed origin from public_url"
1641 );
1642 effective_origins.push(origin);
1643 }
1644 }
1645 let allowed_origins: Arc<[String]> = Arc::from(effective_origins);
1646 let cors_origins = Arc::clone(&allowed_origins);
1647 let log_request_headers = config.log_request_headers;
1648
1649 let readyz_route = if let Some(check) = config.readiness_check.take() {
1650 axum::routing::get(move || readyz(Arc::clone(&check)))
1651 } else {
1652 axum::routing::get(healthz)
1653 };
1654
1655 #[allow(unused_mut)] let mut router = axum::Router::new()
1657 .route("/healthz", axum::routing::get(healthz))
1658 .route("/readyz", readyz_route)
1659 .route(
1660 "/version",
1661 axum::routing::get({
1662 let payload_bytes: Arc<[u8]> = serialize_version_payload(
1667 &config.name,
1668 &config.version,
1669 config.expose_build_metadata,
1670 );
1671 move || {
1672 let p = Arc::clone(&payload_bytes);
1673 async move {
1674 (
1675 [(axum::http::header::CONTENT_TYPE, "application/json")],
1676 p.to_vec(),
1677 )
1678 }
1679 }
1680 }),
1681 )
1682 .merge(mcp_router);
1683
1684 if let Some(extra) = config.extra_router.take() {
1691 let extra = match config.extra_route_rate_limit {
1692 Some(per_minute) => {
1693 let limiter =
1694 build_extra_route_rate_limiter(per_minute, config.extra_route_rate_limit_burst);
1695 let exempt: Arc<std::collections::HashSet<String>> = Arc::new(
1696 config
1697 .extra_route_rate_limit_exempt_paths
1698 .iter()
1699 .cloned()
1700 .collect(),
1701 );
1702 tracing::info!(
1703 per_minute,
1704 exempt_paths = exempt.len(),
1705 "extra-route per-IP rate limit enabled"
1706 );
1707 extra.layer(axum::middleware::from_fn(move |req, next| {
1708 let l = Arc::clone(&limiter);
1709 let e = Arc::clone(&exempt);
1710 extra_route_rate_limit_middleware(l, e, req, next)
1711 }))
1712 }
1713 None => extra,
1714 };
1715 router = router.merge(extra);
1716 }
1717
1718 let server_url = if let Some(ref url) = config.public_url {
1725 url.trim_end_matches('/').to_owned()
1726 } else {
1727 let prm_scheme = if config.tls_cert_path.is_some() {
1728 "https"
1729 } else {
1730 "http"
1731 };
1732 format!("{prm_scheme}://{}", config.bind_addr)
1733 };
1734 let resource_url = format!("{server_url}/mcp");
1735
1736 #[cfg(feature = "oauth")]
1737 let prm_metadata = if let Some(ref auth_config) = config.auth
1738 && let Some(ref oauth_config) = auth_config.oauth
1739 {
1740 crate::oauth::protected_resource_metadata(&resource_url, &server_url, oauth_config)
1741 } else {
1742 serde_json::json!({ "resource": resource_url })
1743 };
1744 #[cfg(not(feature = "oauth"))]
1745 let prm_metadata = serde_json::json!({ "resource": resource_url });
1746
1747 router = router.route(
1748 "/.well-known/oauth-protected-resource",
1749 axum::routing::get(move || {
1750 let m = prm_metadata.clone();
1751 async move { axum::Json(m) }
1752 }),
1753 );
1754
1755 #[cfg(feature = "oauth")]
1760 if let Some(ref auth_config) = config.auth
1761 && let Some(ref oauth_config) = auth_config.oauth
1762 && oauth_config.proxy.is_some()
1763 {
1764 router = install_oauth_proxy_routes(
1765 router,
1766 &server_url,
1767 oauth_config,
1768 auth_state.as_ref(),
1769 config.max_request_body,
1770 &config.admin_role,
1771 )?;
1772 }
1773
1774 if !cors_origins.is_empty() {
1783 let cors = tower_http::cors::CorsLayer::new()
1784 .allow_origin(
1785 cors_origins
1786 .iter()
1787 .filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
1788 .collect::<Vec<_>>(),
1789 )
1790 .allow_methods([
1791 axum::http::Method::GET,
1792 axum::http::Method::POST,
1793 axum::http::Method::OPTIONS,
1794 ])
1795 .allow_headers([
1796 axum::http::header::CONTENT_TYPE,
1797 axum::http::header::AUTHORIZATION,
1798 ]);
1799 router = router.layer(cors);
1800 }
1801
1802 if config.compression_enabled {
1806 use tower_http::compression::Predicate as _;
1807 let predicate = tower_http::compression::DefaultPredicate::new().and(
1808 tower_http::compression::predicate::SizeAbove::new(u64::from(
1809 config.compression_min_size,
1810 )),
1811 );
1812 router = router.layer(
1813 tower_http::compression::CompressionLayer::new()
1814 .gzip(true)
1815 .br(true)
1816 .compress_when(predicate),
1817 );
1818 tracing::info!(
1819 min_size = config.compression_min_size,
1820 "response compression enabled (gzip, br)"
1821 );
1822 }
1823
1824 if let Some(max) = config.max_concurrent_requests {
1827 let overload_handler = tower::ServiceBuilder::new()
1828 .layer(axum::error_handling::HandleErrorLayer::new(
1829 |_err: tower::BoxError| async {
1830 (
1831 axum::http::StatusCode::SERVICE_UNAVAILABLE,
1832 axum::Json(serde_json::json!({
1833 "error": "overloaded",
1834 "error_description": "server is at capacity, retry later"
1835 })),
1836 )
1837 },
1838 ))
1839 .layer(tower::load_shed::LoadShedLayer::new())
1840 .layer(tower::limit::ConcurrencyLimitLayer::new(max));
1841 router = router.layer(overload_handler);
1842 tracing::info!(max, "global concurrency limit enabled");
1843 }
1844
1845 router = router.fallback(|| async {
1849 (
1850 axum::http::StatusCode::NOT_FOUND,
1851 axum::Json(serde_json::json!({
1852 "error": "not_found",
1853 "error_description": "The requested endpoint does not exist"
1854 })),
1855 )
1856 });
1857
1858 #[cfg(feature = "metrics")]
1860 if config.metrics_enabled {
1861 let metrics = Arc::new(
1862 crate::metrics::McpMetrics::new().map_err(|e| anyhow::anyhow!("metrics init: {e}"))?,
1863 );
1864 let m = Arc::clone(&metrics);
1865 router = router.layer(axum::middleware::from_fn(
1866 move |req: Request<Body>, next: Next| {
1867 let m = Arc::clone(&m);
1868 metrics_middleware(m, req, next)
1869 },
1870 ));
1871 let metrics_bind = config.metrics_bind.clone();
1872 let metrics_shutdown = ct.clone();
1873 tokio::spawn(async move {
1874 if let Err(e) =
1875 crate::metrics::serve_metrics(metrics_bind, metrics, metrics_shutdown).await
1876 {
1877 tracing::error!("metrics listener failed: {e}");
1878 }
1879 });
1880 }
1881
1882 let forward_resolver: Option<Arc<ForwardResolver>> = if config.trusted_proxies.is_empty() {
1890 None
1891 } else {
1892 Some(Arc::new(ForwardResolver {
1895 trusted: config
1896 .trusted_proxies
1897 .iter()
1898 .filter_map(|entry| parse_proxy_net(entry))
1899 .collect(),
1900 mode: config
1901 .forwarded_header
1902 .unwrap_or(ForwardedHeaderMode::XForwardedFor),
1903 }))
1904 };
1905 if forward_resolver.is_some() {
1906 tracing::info!(
1907 proxies = config.trusted_proxies.len(),
1908 "trusted-forwarder mode enabled: limiters key by resolved client IP"
1909 );
1910 }
1911 router = router.layer(axum::middleware::from_fn(move |req, next| {
1912 let r = forward_resolver.clone();
1913 normalize_peer_addr_middleware(r, req, next)
1914 }));
1915
1916 router = router.layer(axum::middleware::from_fn(move |req, next| {
1928 let origins = Arc::clone(&allowed_origins);
1929 origin_check_middleware(origins, log_request_headers, req, next)
1930 }));
1931
1932 let is_tls = config.tls_cert_path.is_some();
1941 let security_headers_cfg = Arc::new(config.security_headers.clone());
1942 router = router.layer(axum::middleware::from_fn(move |req, next| {
1943 let cfg = Arc::clone(&security_headers_cfg);
1944 security_headers_middleware(is_tls, cfg, req, next)
1945 }));
1946
1947 let scheme = if config.tls_cert_path.is_some() {
1948 "https"
1949 } else {
1950 "http"
1951 };
1952
1953 let tls_paths = match (&config.tls_cert_path, &config.tls_key_path) {
1954 (Some(cert), Some(key)) => Some((cert.clone(), key.clone())),
1955 _ => None,
1956 };
1957 let tls_handshake_timeout = config.tls_handshake_timeout;
1958 let max_concurrent_tls_handshakes = config.max_concurrent_tls_handshakes;
1959 let mtls_config = config.auth.as_ref().and_then(|a| a.mtls.as_ref()).cloned();
1960
1961 Ok((
1962 router,
1963 AppRunParams {
1964 tls_paths,
1965 tls_handshake_timeout,
1966 max_concurrent_tls_handshakes,
1967 mtls_config,
1968 shutdown_timeout: config.shutdown_timeout,
1969 auth_state,
1970 rbac_swap,
1971 on_reload_ready: config.on_reload_ready.take(),
1972 ct,
1973 scheme,
1974 name: config.name.clone(),
1975 },
1976 ))
1977}
1978
1979pub async fn serve<H, F>(
1996 config: Validated<McpServerConfig>,
1997 handler_factory: F,
1998) -> Result<(), McpxError>
1999where
2000 H: ServerHandler + 'static,
2001 F: Fn() -> H + Send + Sync + Clone + 'static,
2002{
2003 let config = config.into_inner();
2004 #[allow(
2005 deprecated,
2006 reason = "internal serve() reads `bind_addr` to construct the listener; field becomes pub(crate) in 1.0"
2007 )]
2008 let bind_addr = config.bind_addr.clone();
2009 let (router, params) = build_app_router(config, handler_factory).map_err(anyhow_to_startup)?;
2010
2011 let listener = TcpListener::bind(&bind_addr)
2012 .await
2013 .map_err(|e| io_to_startup(&format!("bind {bind_addr}"), e))?;
2014 log_listening(¶ms.name, params.scheme, &bind_addr);
2015
2016 run_server(
2017 router,
2018 listener,
2019 params.tls_paths,
2020 params.tls_handshake_timeout,
2021 params.max_concurrent_tls_handshakes,
2022 params.mtls_config,
2023 params.shutdown_timeout,
2024 params.auth_state,
2025 params.rbac_swap,
2026 params.on_reload_ready,
2027 params.ct,
2028 )
2029 .await
2030 .map_err(anyhow_to_startup)
2031}
2032
2033pub async fn serve_with_listener<H, F>(
2063 listener: TcpListener,
2064 config: Validated<McpServerConfig>,
2065 handler_factory: F,
2066 ready_tx: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
2067 shutdown: Option<CancellationToken>,
2068) -> Result<(), McpxError>
2069where
2070 H: ServerHandler + 'static,
2071 F: Fn() -> H + Send + Sync + Clone + 'static,
2072{
2073 let config = config.into_inner();
2074 let local_addr = listener
2075 .local_addr()
2076 .map_err(|e| io_to_startup("listener.local_addr", e))?;
2077 let (router, params) = build_app_router(config, handler_factory).map_err(anyhow_to_startup)?;
2078
2079 log_listening(¶ms.name, params.scheme, &local_addr.to_string());
2080
2081 if let Some(external) = shutdown {
2085 let internal = params.ct.clone();
2086 tokio::spawn(async move {
2087 external.cancelled().await;
2088 internal.cancel();
2089 });
2090 }
2091
2092 if let Some(tx) = ready_tx {
2096 let _ = tx.send(local_addr);
2098 }
2099
2100 run_server(
2101 router,
2102 listener,
2103 params.tls_paths,
2104 params.tls_handshake_timeout,
2105 params.max_concurrent_tls_handshakes,
2106 params.mtls_config,
2107 params.shutdown_timeout,
2108 params.auth_state,
2109 params.rbac_swap,
2110 params.on_reload_ready,
2111 params.ct,
2112 )
2113 .await
2114 .map_err(anyhow_to_startup)
2115}
2116
2117#[allow(
2120 clippy::cognitive_complexity,
2121 reason = "tracing::info! macro expansions inflate the score; logic is trivial"
2122)]
2123fn log_listening(name: &str, scheme: &str, addr: &str) {
2124 tracing::info!("{name} listening on {addr}");
2125 tracing::info!(" MCP endpoint: {scheme}://{addr}/mcp");
2126 tracing::info!(" Health check: {scheme}://{addr}/healthz");
2127 tracing::info!(" Readiness: {scheme}://{addr}/readyz");
2128}
2129
2130#[allow(
2153 clippy::too_many_arguments,
2154 clippy::cognitive_complexity,
2155 reason = "server start-up threads TLS, reload state, and graceful shutdown through one flow"
2156)]
2157async fn run_server(
2158 router: axum::Router,
2159 listener: TcpListener,
2160 tls_paths: Option<(PathBuf, PathBuf)>,
2161 tls_handshake_timeout: Duration,
2162 max_concurrent_tls_handshakes: usize,
2163 mtls_config: Option<MtlsConfig>,
2164 shutdown_timeout: Duration,
2165 auth_state: Option<Arc<AuthState>>,
2166 rbac_swap: Arc<ArcSwap<RbacPolicy>>,
2167 mut on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
2168 ct: CancellationToken,
2169) -> anyhow::Result<()> {
2170 let shutdown_trigger = CancellationToken::new();
2174 {
2175 let trigger = shutdown_trigger.clone();
2176 let parent = ct.clone();
2177 tokio::spawn(async move {
2178 tokio::select! {
2181 () = shutdown_signal() => {}
2182 () = parent.cancelled() => {}
2183 }
2184 trigger.cancel();
2185 });
2186 }
2187
2188 let graceful = {
2189 let trigger = shutdown_trigger.clone();
2190 let ct = ct.clone();
2191 async move {
2192 trigger.cancelled().await;
2193 tracing::info!("shutting down (grace period: {shutdown_timeout:?})");
2194 ct.cancel();
2195 }
2196 };
2197
2198 let force_exit_timer = {
2199 let trigger = shutdown_trigger.clone();
2200 async move {
2201 trigger.cancelled().await;
2202 tokio::time::sleep(shutdown_timeout).await;
2203 }
2204 };
2205
2206 if let Some((cert_path, key_path)) = tls_paths {
2207 let crl_set = if let Some(mtls) = mtls_config.as_ref()
2208 && mtls.crl_enabled
2209 {
2210 let (ca_certs, roots) = load_client_auth_roots(&mtls.ca_cert_path)?;
2211 let (crl_set, discover_rx) =
2212 mtls_revocation::bootstrap_fetch(roots, &ca_certs, mtls.clone())
2213 .await
2214 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
2215 tokio::spawn(mtls_revocation::run_crl_refresher(
2216 Arc::clone(&crl_set),
2217 discover_rx,
2218 ct.clone(),
2219 ));
2220 Some(crl_set)
2221 } else {
2222 None
2223 };
2224
2225 if let Some(cb) = on_reload_ready.take() {
2226 cb(ReloadHandle {
2227 auth: auth_state.clone(),
2228 rbac: Some(Arc::clone(&rbac_swap)),
2229 crl_set: crl_set.clone(),
2230 });
2231 }
2232
2233 let tls_listener = TlsListener::new(
2234 listener,
2235 &cert_path,
2236 &key_path,
2237 mtls_config.as_ref(),
2238 crl_set,
2239 tls_handshake_timeout,
2240 max_concurrent_tls_handshakes,
2241 )?;
2242 let make_svc = router.into_make_service_with_connect_info::<TlsConnInfo>();
2243 tokio::select! {
2246 result = axum::serve(tls_listener, make_svc)
2247 .with_graceful_shutdown(graceful) => { result?; }
2248 () = force_exit_timer => {
2249 tracing::warn!("shutdown timeout exceeded, forcing exit");
2250 }
2251 }
2252 } else {
2253 if let Some(cb) = on_reload_ready.take() {
2254 cb(ReloadHandle {
2255 auth: auth_state,
2256 rbac: Some(rbac_swap),
2257 crl_set: None,
2258 });
2259 }
2260
2261 let make_svc = router.into_make_service_with_connect_info::<SocketAddr>();
2262 tokio::select! {
2265 result = axum::serve(listener, make_svc)
2266 .with_graceful_shutdown(graceful) => { result?; }
2267 () = force_exit_timer => {
2268 tracing::warn!("shutdown timeout exceeded, forcing exit");
2269 }
2270 }
2271 }
2272
2273 Ok(())
2274}
2275
2276#[cfg(feature = "oauth")]
2285fn install_oauth_proxy_routes(
2286 router: axum::Router,
2287 server_url: &str,
2288 oauth_config: &crate::oauth::OAuthConfig,
2289 auth_state: Option<&Arc<AuthState>>,
2290 max_request_body: usize,
2291 admin_role: &str,
2292) -> Result<axum::Router, McpxError> {
2293 let Some(ref proxy) = oauth_config.proxy else {
2294 return Ok(router);
2295 };
2296
2297 let http = crate::oauth::OauthHttpClient::with_config(oauth_config)?;
2300
2301 let proxy_router = axum::Router::new();
2307
2308 let asm = crate::oauth::authorization_server_metadata(server_url, oauth_config);
2309 let proxy_router = proxy_router.route(
2310 "/.well-known/oauth-authorization-server",
2311 axum::routing::get(move || {
2312 let m = asm.clone();
2313 async move { axum::Json(m) }
2314 }),
2315 );
2316
2317 let proxy_authorize = proxy.clone();
2318 let proxy_router = proxy_router.route(
2319 "/authorize",
2320 axum::routing::get(
2321 move |axum::extract::RawQuery(query): axum::extract::RawQuery| {
2322 let p = proxy_authorize.clone();
2323 async move { crate::oauth::handle_authorize(&p, &query.unwrap_or_default()) }
2324 },
2325 ),
2326 );
2327
2328 let proxy_token = proxy.clone();
2329 let token_http = http.clone();
2330 let proxy_router = proxy_router.route(
2331 "/token",
2332 axum::routing::post(move |body: String| {
2333 let p = proxy_token.clone();
2334 let h = token_http.clone();
2335 async move { crate::oauth::handle_token(&h, &p, &body).await }
2336 })
2337 .layer(axum::middleware::from_fn(
2338 oauth_token_cache_headers_middleware,
2339 )),
2340 );
2341
2342 let proxy_register = proxy.clone();
2343 let proxy_router = proxy_router.route(
2344 "/register",
2345 axum::routing::post(move |axum::Json(body): axum::Json<serde_json::Value>| {
2346 let p = proxy_register;
2347 async move { axum::Json(crate::oauth::handle_register(&p, &body)) }
2348 })
2349 .layer(axum::middleware::from_fn(
2350 oauth_token_cache_headers_middleware,
2351 )),
2352 );
2353
2354 let admin_routes_enabled = proxy.expose_admin_endpoints
2355 && (proxy.introspection_url.is_some() || proxy.revocation_url.is_some());
2356 if proxy.expose_admin_endpoints
2357 && !proxy.require_auth_on_admin_endpoints
2358 && proxy.allow_unauthenticated_admin_endpoints
2359 {
2360 tracing::warn!(
2364 "OAuth introspect/revoke endpoints are unauthenticated by explicit \
2365 allow_unauthenticated_admin_endpoints opt-out; ensure an \
2366 authenticated reverse proxy fronts these routes"
2367 );
2368 }
2369
2370 let admin_router = if admin_routes_enabled {
2371 build_oauth_admin_router(proxy, http, auth_state, admin_role)?
2372 } else {
2373 axum::Router::new()
2374 };
2375
2376 let proxy_router =
2380 proxy_router
2381 .merge(admin_router)
2382 .layer(tower_http::limit::RequestBodyLimitLayer::new(
2383 max_request_body,
2384 ));
2385
2386 let router = router.merge(proxy_router);
2387
2388 tracing::info!(
2389 introspect = proxy.expose_admin_endpoints && proxy.introspection_url.is_some(),
2390 revoke = proxy.expose_admin_endpoints && proxy.revocation_url.is_some(),
2391 max_request_body,
2392 "OAuth 2.1 proxy endpoints enabled (/authorize, /token, /register)"
2393 );
2394 Ok(router)
2395}
2396
2397#[cfg(feature = "oauth")]
2403fn build_oauth_admin_router(
2404 proxy: &crate::oauth::OAuthProxyConfig,
2405 http: crate::oauth::OauthHttpClient,
2406 auth_state: Option<&Arc<AuthState>>,
2407 admin_role: &str,
2408) -> Result<axum::Router, McpxError> {
2409 let mut admin_router = axum::Router::new();
2410 if proxy.introspection_url.is_some() {
2411 let proxy_introspect = proxy.clone();
2412 let introspect_http = http.clone();
2413 admin_router = admin_router.route(
2414 "/introspect",
2415 axum::routing::post(move |body: String| {
2416 let p = proxy_introspect.clone();
2417 let h = introspect_http.clone();
2418 async move { crate::oauth::handle_introspect(&h, &p, &body).await }
2419 }),
2420 );
2421 }
2422 if proxy.revocation_url.is_some() {
2423 let proxy_revoke = proxy.clone();
2424 let revoke_http = http;
2425 admin_router = admin_router.route(
2426 "/revoke",
2427 axum::routing::post(move |body: String| {
2428 let p = proxy_revoke.clone();
2429 let h = revoke_http.clone();
2430 async move { crate::oauth::handle_revoke(&h, &p, &body).await }
2431 }),
2432 );
2433 }
2434
2435 let admin_router = admin_router.layer(axum::middleware::from_fn(
2436 oauth_token_cache_headers_middleware,
2437 ));
2438
2439 if proxy.require_auth_on_admin_endpoints {
2440 let Some(state) = auth_state else {
2441 return Err(McpxError::Startup(
2442 "oauth proxy admin endpoints require auth state".into(),
2443 ));
2444 };
2445 let state_for_mw = Arc::clone(state);
2446 let required_role: Arc<str> = Arc::from(admin_role);
2447 Ok(admin_router
2453 .layer(axum::middleware::from_fn(move |req, next| {
2454 let r = Arc::clone(&required_role);
2455 crate::admin::require_admin_role(r, req, next)
2456 }))
2457 .layer(axum::middleware::from_fn(move |req, next| {
2458 let s = Arc::clone(&state_for_mw);
2459 auth_middleware(s, req, next)
2460 })))
2461 } else {
2462 Ok(admin_router)
2463 }
2464}
2465
2466fn derive_allowed_hosts(bind_addr: &str, public_url: Option<&str>) -> Vec<String> {
2471 let mut hosts = vec![
2472 "localhost".to_owned(),
2473 "127.0.0.1".to_owned(),
2474 "::1".to_owned(),
2475 ];
2476
2477 if let Some(url) = public_url
2478 && let Ok(uri) = url.parse::<axum::http::Uri>()
2479 && let Some(authority) = uri.authority()
2480 {
2481 let host = authority.host().to_owned();
2482 if !hosts.iter().any(|h| h == &host) {
2483 hosts.push(host);
2484 }
2485
2486 let authority = authority.as_str().to_owned();
2487 if !hosts.iter().any(|h| h == &authority) {
2488 hosts.push(authority);
2489 }
2490 }
2491
2492 if let Ok(uri) = format!("http://{bind_addr}").parse::<axum::http::Uri>()
2493 && let Some(authority) = uri.authority()
2494 {
2495 let host = authority.host().to_owned();
2496 if !hosts.iter().any(|h| h == &host) {
2497 hosts.push(host);
2498 }
2499
2500 let authority = authority.as_str().to_owned();
2501 if !hosts.iter().any(|h| h == &authority) {
2502 hosts.push(authority);
2503 }
2504 }
2505
2506 hosts
2507}
2508
2509impl axum::extract::connect_info::Connected<axum::serve::IncomingStream<'_, TlsListener>>
2522 for TlsConnInfo
2523{
2524 fn connect_info(target: axum::serve::IncomingStream<'_, TlsListener>) -> Self {
2525 let addr = *target.remote_addr();
2526 let identity = target.io().identity().cloned();
2527 Self::new(addr, identity)
2528 }
2529}
2530
2531const DEFAULT_TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
2538
2539const DEFAULT_MAX_CONCURRENT_TLS_HANDSHAKES: usize = 256;
2547
2548const TLS_ACCEPT_CHANNEL_CAPACITY: usize = 32;
2553
2554struct TlsListener {
2570 local_addr: SocketAddr,
2573 rx: mpsc::Receiver<(AuthenticatedTlsStream, SocketAddr)>,
2575 acceptor_task: tokio::task::JoinHandle<()>,
2578}
2579
2580impl TlsListener {
2581 fn new(
2582 inner: TcpListener,
2583 cert_path: &Path,
2584 key_path: &Path,
2585 mtls_config: Option<&MtlsConfig>,
2586 crl_set: Option<Arc<CrlSet>>,
2587 handshake_timeout: Duration,
2588 max_concurrent_handshakes: usize,
2589 ) -> anyhow::Result<Self> {
2590 rustls::crypto::ring::default_provider()
2592 .install_default()
2593 .ok();
2594
2595 let certs = load_certs(cert_path)?;
2596 let key = load_key(key_path)?;
2597
2598 let mtls_default_role;
2599
2600 let tls_config = if let Some(mtls) = mtls_config {
2601 mtls_default_role = mtls.default_role.clone();
2602 let verifier: Arc<dyn rustls::server::danger::ClientCertVerifier> = if mtls.crl_enabled
2603 {
2604 let Some(crl_set) = crl_set else {
2605 return Err(anyhow::anyhow!(
2606 "mTLS CRL verifier requested but CRL state was not initialized"
2607 ));
2608 };
2609 Arc::new(DynamicClientCertVerifier::new(crl_set))
2610 } else {
2611 let (_, root_store) = load_client_auth_roots(&mtls.ca_cert_path)?;
2612 if mtls.required {
2613 rustls::server::WebPkiClientVerifier::builder(root_store)
2614 .build()
2615 .map_err(|e| anyhow::anyhow!("mTLS verifier error: {e}"))?
2616 } else {
2617 rustls::server::WebPkiClientVerifier::builder(root_store)
2618 .allow_unauthenticated()
2619 .build()
2620 .map_err(|e| anyhow::anyhow!("mTLS verifier error: {e}"))?
2621 }
2622 };
2623
2624 tracing::info!(
2625 ca = %mtls.ca_cert_path.display(),
2626 required = mtls.required,
2627 crl_enabled = mtls.crl_enabled,
2628 "mTLS client auth configured"
2629 );
2630
2631 rustls::ServerConfig::builder_with_protocol_versions(&[
2632 &rustls::version::TLS12,
2633 &rustls::version::TLS13,
2634 ])
2635 .with_client_cert_verifier(verifier)
2636 .with_single_cert(certs, key)?
2637 } else {
2638 mtls_default_role = "viewer".to_owned();
2639 rustls::ServerConfig::builder_with_protocol_versions(&[
2640 &rustls::version::TLS12,
2641 &rustls::version::TLS13,
2642 ])
2643 .with_no_client_auth()
2644 .with_single_cert(certs, key)?
2645 };
2646
2647 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
2648 tracing::info!(
2649 "TLS enabled (cert: {}, key: {})",
2650 cert_path.display(),
2651 key_path.display()
2652 );
2653 let local_addr = inner.local_addr()?;
2654 let (tx, rx) = mpsc::channel(TLS_ACCEPT_CHANNEL_CAPACITY);
2655 let acceptor_task = tokio::spawn(run_tls_acceptor(
2656 inner,
2657 acceptor,
2658 mtls_default_role,
2659 tx,
2660 handshake_timeout,
2661 max_concurrent_handshakes,
2662 ));
2663 Ok(Self {
2664 local_addr,
2665 rx,
2666 acceptor_task,
2667 })
2668 }
2669
2670 fn extract_handshake_identity(
2674 tls_stream: &tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
2675 default_role: &str,
2676 addr: SocketAddr,
2677 ) -> Option<AuthIdentity> {
2678 let (_, server_conn) = tls_stream.get_ref();
2679 let cert_der = server_conn.peer_certificates()?.first()?;
2680 let id = extract_mtls_identity(cert_der.as_ref(), default_role)?;
2681 tracing::debug!(name = %id.name, peer = %addr, "mTLS client cert accepted");
2682 Some(id)
2683 }
2684}
2685
2686async fn run_tls_acceptor(
2694 listener: TcpListener,
2695 acceptor: tokio_rustls::TlsAcceptor,
2696 default_role: String,
2697 tx: mpsc::Sender<(AuthenticatedTlsStream, SocketAddr)>,
2698 handshake_timeout: Duration,
2699 max_concurrent_handshakes: usize,
2700) {
2701 let inflight = Arc::new(Semaphore::new(max_concurrent_handshakes));
2702 loop {
2703 let Ok(permit) = Arc::clone(&inflight).acquire_owned().await else {
2707 return;
2709 };
2710 let (stream, addr) = match listener.accept().await {
2711 Ok(pair) => pair,
2712 Err(e) => {
2713 tracing::debug!("TCP accept error: {e}");
2714 continue;
2715 }
2716 };
2717 if tx.is_closed() {
2718 return;
2720 }
2721 let acceptor = acceptor.clone();
2722 let default_role = default_role.clone();
2723 let tx = tx.clone();
2724 tokio::spawn(async move {
2725 let _permit = permit;
2726 match tokio::time::timeout(handshake_timeout, acceptor.accept(stream)).await {
2727 Ok(Ok(tls_stream)) => {
2728 let identity =
2729 TlsListener::extract_handshake_identity(&tls_stream, &default_role, addr);
2730 let wrapped = AuthenticatedTlsStream {
2731 inner: tls_stream,
2732 identity,
2733 };
2734 let _ = tx.send((wrapped, addr)).await;
2737 }
2738 Ok(Err(e)) => {
2739 tracing::debug!("TLS handshake failed from {addr}: {e}");
2740 }
2741 Err(_elapsed) => {
2742 tracing::debug!(
2743 "TLS handshake timed out from {addr} after {handshake_timeout:?}"
2744 );
2745 }
2746 }
2747 });
2748 }
2749}
2750
2751pub(crate) struct AuthenticatedTlsStream {
2763 inner: tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
2764 identity: Option<AuthIdentity>,
2765}
2766
2767impl AuthenticatedTlsStream {
2768 #[must_use]
2770 pub(crate) const fn identity(&self) -> Option<&AuthIdentity> {
2771 self.identity.as_ref()
2772 }
2773}
2774
2775impl std::fmt::Debug for AuthenticatedTlsStream {
2776 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2777 f.debug_struct("AuthenticatedTlsStream")
2778 .field("identity", &self.identity.as_ref().map(|id| &id.name))
2779 .finish_non_exhaustive()
2780 }
2781}
2782
2783impl tokio::io::AsyncRead for AuthenticatedTlsStream {
2784 fn poll_read(
2785 mut self: Pin<&mut Self>,
2786 cx: &mut std::task::Context<'_>,
2787 buf: &mut tokio::io::ReadBuf<'_>,
2788 ) -> std::task::Poll<std::io::Result<()>> {
2789 Pin::new(&mut self.inner).poll_read(cx, buf)
2790 }
2791}
2792
2793impl tokio::io::AsyncWrite for AuthenticatedTlsStream {
2794 fn poll_write(
2795 mut self: Pin<&mut Self>,
2796 cx: &mut std::task::Context<'_>,
2797 buf: &[u8],
2798 ) -> std::task::Poll<std::io::Result<usize>> {
2799 Pin::new(&mut self.inner).poll_write(cx, buf)
2800 }
2801
2802 fn poll_flush(
2803 mut self: Pin<&mut Self>,
2804 cx: &mut std::task::Context<'_>,
2805 ) -> std::task::Poll<std::io::Result<()>> {
2806 Pin::new(&mut self.inner).poll_flush(cx)
2807 }
2808
2809 fn poll_shutdown(
2810 mut self: Pin<&mut Self>,
2811 cx: &mut std::task::Context<'_>,
2812 ) -> std::task::Poll<std::io::Result<()>> {
2813 Pin::new(&mut self.inner).poll_shutdown(cx)
2814 }
2815
2816 fn poll_write_vectored(
2817 mut self: Pin<&mut Self>,
2818 cx: &mut std::task::Context<'_>,
2819 bufs: &[std::io::IoSlice<'_>],
2820 ) -> std::task::Poll<std::io::Result<usize>> {
2821 Pin::new(&mut self.inner).poll_write_vectored(cx, bufs)
2822 }
2823
2824 fn is_write_vectored(&self) -> bool {
2825 self.inner.is_write_vectored()
2826 }
2827}
2828
2829impl axum::serve::Listener for TlsListener {
2830 type Io = AuthenticatedTlsStream;
2831 type Addr = SocketAddr;
2832
2833 async fn accept(&mut self) -> (Self::Io, Self::Addr) {
2839 if let Some(pair) = self.rx.recv().await {
2840 return pair;
2841 }
2842 tracing::error!("TLS acceptor task terminated; no further connections will be accepted");
2848 std::future::pending().await
2849 }
2850
2851 fn local_addr(&self) -> std::io::Result<Self::Addr> {
2852 Ok(self.local_addr)
2853 }
2854}
2855
2856impl Drop for TlsListener {
2857 fn drop(&mut self) {
2858 self.acceptor_task.abort();
2861 }
2862}
2863
2864fn load_certs(path: &Path) -> anyhow::Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
2865 use rustls::pki_types::pem::PemObject;
2866 let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_file_iter(path)
2867 .map_err(|e| anyhow::anyhow!("failed to read certs from {}: {e}", path.display()))?
2868 .collect::<Result<_, _>>()
2869 .map_err(|e| anyhow::anyhow!("invalid cert in {}: {e}", path.display()))?;
2870 anyhow::ensure!(
2871 !certs.is_empty(),
2872 "no certificates found in {}",
2873 path.display()
2874 );
2875 Ok(certs)
2876}
2877
2878fn load_client_auth_roots(
2879 path: &Path,
2880) -> anyhow::Result<(
2881 Vec<rustls::pki_types::CertificateDer<'static>>,
2882 Arc<RootCertStore>,
2883)> {
2884 let ca_certs = load_certs(path)?;
2885 let mut root_store = RootCertStore::empty();
2886 for cert in &ca_certs {
2887 root_store
2888 .add(cert.clone())
2889 .map_err(|error| anyhow::anyhow!("invalid CA cert: {error}"))?;
2890 }
2891
2892 Ok((ca_certs, Arc::new(root_store)))
2893}
2894
2895fn load_key(path: &Path) -> anyhow::Result<rustls::pki_types::PrivateKeyDer<'static>> {
2896 use rustls::pki_types::pem::PemObject;
2897 rustls::pki_types::PrivateKeyDer::from_pem_file(path)
2898 .map_err(|e| anyhow::anyhow!("failed to read key from {}: {e}", path.display()))
2899}
2900
2901#[allow(
2902 clippy::unused_async,
2903 reason = "axum route handler signature requires `async fn` even when the body is synchronous"
2904)]
2905async fn healthz() -> impl IntoResponse {
2906 axum::Json(serde_json::json!({
2907 "status": "ok",
2908 }))
2909}
2910
2911fn version_payload(name: &str, version: &str, expose_build_metadata: bool) -> serde_json::Value {
2921 let mut map = serde_json::Map::new();
2922 map.insert("name".into(), name.into());
2923 map.insert("version".into(), version.into());
2924 map.insert("mcpx_version".into(), env!("CARGO_PKG_VERSION").into());
2925 if expose_build_metadata {
2926 map.insert(
2927 "build_git_sha".into(),
2928 option_env!("RMCP_SERVER_KIT_BUILD_SHA")
2929 .unwrap_or("unknown")
2930 .into(),
2931 );
2932 map.insert(
2933 "build_timestamp".into(),
2934 option_env!("RMCP_SERVER_KIT_BUILD_TIME")
2935 .unwrap_or("unknown")
2936 .into(),
2937 );
2938 map.insert(
2939 "rust_version".into(),
2940 option_env!("RMCP_SERVER_KIT_RUSTC_VERSION")
2941 .unwrap_or("unknown")
2942 .into(),
2943 );
2944 }
2945 serde_json::Value::Object(map)
2946}
2947
2948fn serialize_version_payload(name: &str, version: &str, expose_build_metadata: bool) -> Arc<[u8]> {
2958 let value = version_payload(name, version, expose_build_metadata);
2959 serde_json::to_vec(&value).map_or_else(|_| Arc::from(&b"{}"[..]), Arc::from)
2960}
2961
2962async fn readyz(check: ReadinessCheck) -> impl IntoResponse {
2963 let status = check().await;
2964 let ready = status
2965 .get("ready")
2966 .and_then(serde_json::Value::as_bool)
2967 .unwrap_or(false);
2968 let code = if ready {
2969 axum::http::StatusCode::OK
2970 } else {
2971 axum::http::StatusCode::SERVICE_UNAVAILABLE
2972 };
2973 (code, axum::Json(status))
2974}
2975
2976async fn shutdown_signal() {
2980 let ctrl_c = tokio::signal::ctrl_c();
2981
2982 #[cfg(unix)]
2983 {
2984 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
2985 Ok(mut term) => {
2986 tokio::select! {
2989 _ = ctrl_c => {}
2990 _ = term.recv() => {}
2991 }
2992 }
2993 Err(e) => {
2994 tracing::warn!(error = %e, "failed to register SIGTERM handler, using SIGINT only");
2995 ctrl_c.await.ok();
2996 }
2997 }
2998 }
2999
3000 #[cfg(not(unix))]
3001 {
3002 ctrl_c.await.ok();
3003 }
3004}
3005
3006#[cfg(feature = "metrics")]
3017async fn metrics_middleware(
3018 metrics: Arc<crate::metrics::McpMetrics>,
3019 mut req: Request<Body>,
3020 next: Next,
3021) -> axum::response::Response {
3022 let method = req.method().to_string();
3023 let path = req.uri().path().to_owned();
3024 let start = std::time::Instant::now();
3025
3026 req.extensions_mut().insert(Arc::clone(&metrics));
3027 let response = next.run(req).await;
3028
3029 let status = response.status().as_u16().to_string();
3030 let duration = start.elapsed().as_secs_f64();
3031
3032 metrics
3033 .http_requests_total
3034 .with_label_values(&[&method, &path, &status])
3035 .inc();
3036 metrics
3037 .http_request_duration_seconds
3038 .with_label_values(&[&method, &path])
3039 .observe(duration);
3040
3041 response
3042}
3043
3044async fn security_headers_middleware(
3056 is_tls: bool,
3057 cfg: Arc<SecurityHeadersConfig>,
3058 req: Request<Body>,
3059 next: Next,
3060) -> axum::response::Response {
3061 use axum::http::{HeaderName, header};
3062
3063 let mut resp = next.run(req).await;
3064 let headers = resp.headers_mut();
3065
3066 headers.remove(header::SERVER);
3068 headers.remove(HeaderName::from_static("x-powered-by"));
3069
3070 apply_security_header(
3071 headers,
3072 header::X_CONTENT_TYPE_OPTIONS,
3073 cfg.x_content_type_options.as_deref(),
3074 "nosniff",
3075 );
3076 apply_security_header(
3077 headers,
3078 header::X_FRAME_OPTIONS,
3079 cfg.x_frame_options.as_deref(),
3080 "deny",
3081 );
3082 apply_security_header(
3083 headers,
3084 header::CACHE_CONTROL,
3085 cfg.cache_control.as_deref(),
3086 "no-store, max-age=0",
3087 );
3088 apply_security_header(
3089 headers,
3090 header::REFERRER_POLICY,
3091 cfg.referrer_policy.as_deref(),
3092 "no-referrer",
3093 );
3094 apply_security_header(
3095 headers,
3096 HeaderName::from_static("cross-origin-opener-policy"),
3097 cfg.cross_origin_opener_policy.as_deref(),
3098 "same-origin",
3099 );
3100 apply_security_header(
3101 headers,
3102 HeaderName::from_static("cross-origin-resource-policy"),
3103 cfg.cross_origin_resource_policy.as_deref(),
3104 "same-origin",
3105 );
3106 apply_security_header(
3107 headers,
3108 HeaderName::from_static("cross-origin-embedder-policy"),
3109 cfg.cross_origin_embedder_policy.as_deref(),
3110 "require-corp",
3111 );
3112 apply_security_header(
3113 headers,
3114 HeaderName::from_static("permissions-policy"),
3115 cfg.permissions_policy.as_deref(),
3116 "accelerometer=(), camera=(), geolocation=(), microphone=()",
3117 );
3118 apply_security_header(
3119 headers,
3120 HeaderName::from_static("x-permitted-cross-domain-policies"),
3121 cfg.x_permitted_cross_domain_policies.as_deref(),
3122 "none",
3123 );
3124 apply_security_header(
3125 headers,
3126 HeaderName::from_static("content-security-policy"),
3127 cfg.content_security_policy.as_deref(),
3128 "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests",
3129 );
3130 apply_security_header(
3131 headers,
3132 HeaderName::from_static("x-dns-prefetch-control"),
3133 cfg.x_dns_prefetch_control.as_deref(),
3134 "off",
3135 );
3136
3137 if is_tls {
3138 apply_security_header(
3139 headers,
3140 header::STRICT_TRANSPORT_SECURITY,
3141 cfg.strict_transport_security.as_deref(),
3142 "max-age=63072000; includeSubDomains",
3143 );
3144 }
3145
3146 resp
3147}
3148
3149fn apply_security_header(
3160 headers: &mut axum::http::HeaderMap,
3161 name: axum::http::HeaderName,
3162 override_value: Option<&str>,
3163 default: &'static str,
3164) {
3165 use axum::http::HeaderValue;
3166
3167 match override_value {
3168 None => {
3169 headers.insert(name, HeaderValue::from_static(default));
3170 }
3171 Some("") => {
3172 }
3174 Some(v) => match HeaderValue::from_str(v) {
3175 Ok(hv) => {
3176 headers.insert(name, hv);
3177 }
3178 Err(err) => {
3179 tracing::error!(
3180 header = %name,
3181 error = %err,
3182 "invalid security header override reached middleware; using default"
3183 );
3184 headers.insert(name, HeaderValue::from_static(default));
3185 }
3186 },
3187 }
3188}
3189
3190fn validate_security_headers(cfg: &SecurityHeadersConfig) -> Result<(), McpxError> {
3201 use axum::http::HeaderValue;
3202
3203 let fields: &[(&str, Option<&str>)] = &[
3204 (
3205 "x_content_type_options",
3206 cfg.x_content_type_options.as_deref(),
3207 ),
3208 ("x_frame_options", cfg.x_frame_options.as_deref()),
3209 ("cache_control", cfg.cache_control.as_deref()),
3210 ("referrer_policy", cfg.referrer_policy.as_deref()),
3211 (
3212 "cross_origin_opener_policy",
3213 cfg.cross_origin_opener_policy.as_deref(),
3214 ),
3215 (
3216 "cross_origin_resource_policy",
3217 cfg.cross_origin_resource_policy.as_deref(),
3218 ),
3219 (
3220 "cross_origin_embedder_policy",
3221 cfg.cross_origin_embedder_policy.as_deref(),
3222 ),
3223 ("permissions_policy", cfg.permissions_policy.as_deref()),
3224 (
3225 "x_permitted_cross_domain_policies",
3226 cfg.x_permitted_cross_domain_policies.as_deref(),
3227 ),
3228 (
3229 "content_security_policy",
3230 cfg.content_security_policy.as_deref(),
3231 ),
3232 (
3233 "x_dns_prefetch_control",
3234 cfg.x_dns_prefetch_control.as_deref(),
3235 ),
3236 (
3237 "strict_transport_security",
3238 cfg.strict_transport_security.as_deref(),
3239 ),
3240 ];
3241
3242 for (field, value) in fields {
3243 let Some(v) = value else { continue };
3244 if v.is_empty() {
3245 continue;
3246 }
3247 if let Err(err) = HeaderValue::from_str(v) {
3248 return Err(McpxError::Config(format!(
3249 "invalid security_headers.{field}: {err}"
3250 )));
3251 }
3252 }
3253
3254 if let Some(v) = cfg.strict_transport_security.as_deref()
3255 && !v.is_empty()
3256 && v.to_ascii_lowercase().contains("preload")
3257 {
3258 return Err(McpxError::Config(format!(
3259 "invalid security_headers.strict_transport_security: {v:?} contains the `preload` directive; \
3260 HSTS preload must be opted into explicitly via a dedicated builder, not via this knob"
3261 )));
3262 }
3263
3264 Ok(())
3265}
3266
3267#[cfg(feature = "oauth")]
3282async fn oauth_token_cache_headers_middleware(
3283 req: Request<Body>,
3284 next: Next,
3285) -> axum::response::Response {
3286 use axum::http::{HeaderValue, header};
3287
3288 let mut resp = next.run(req).await;
3289 let headers = resp.headers_mut();
3290 headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
3291 headers.append(header::VARY, HeaderValue::from_static("Authorization"));
3292 resp
3293}
3294
3295async fn normalize_peer_addr_middleware(
3324 resolver: Option<Arc<ForwardResolver>>,
3325 mut req: Request<Body>,
3326 next: Next,
3327) -> axum::response::Response {
3328 let direct = req
3329 .extensions()
3330 .get::<ConnectInfo<SocketAddr>>()
3331 .map(|ci| ci.0);
3332 let from_tls = req
3333 .extensions()
3334 .get::<ConnectInfo<TlsConnInfo>>()
3335 .map(|ci| ci.0.addr);
3336 if let Some(addr) = direct.or(from_tls) {
3337 if direct.is_none() {
3338 req.extensions_mut().insert(ConnectInfo(addr));
3339 }
3340 req.extensions_mut().insert(PeerAddr::new(addr));
3341 let client_ip = match &resolver {
3342 Some(r) => {
3343 crate::forwarded::resolve_client_ip(addr.ip(), req.headers(), &r.trusted, r.mode)
3344 .unwrap_or_else(|reason| {
3345 tracing::debug!(
3346 reason = ?reason,
3347 "forwarded-header resolution fell back to direct peer"
3348 );
3349 addr.ip()
3350 })
3351 }
3352 None => addr.ip(),
3353 };
3354 req.extensions_mut().insert(ClientIp::new(client_ip));
3355 }
3356 next.run(req).await
3357}
3358
3359fn parse_proxy_net(entry: &str) -> Option<ipnet::IpNet> {
3362 if let Ok(net) = entry.parse::<ipnet::IpNet>() {
3363 return Some(net);
3364 }
3365 entry.parse::<IpAddr>().ok().map(ipnet::IpNet::from)
3366}
3367
3368pub(crate) fn validate_trusted_proxy_entry(entry: &str) -> Result<(), String> {
3378 match parse_proxy_net(entry) {
3379 None => Err(format!(
3380 "trusted_proxies entry {entry:?} is neither a CIDR nor an IP address"
3381 )),
3382 Some(net) if net.prefix_len() == 0 => Err(format!(
3383 "trusted_proxies entry {entry:?}: prefix length 0 is forbidden (marks every peer trusted, enabling client-IP spoofing)"
3384 )),
3385 Some(_) => Ok(()),
3386 }
3387}
3388
3389pub(crate) fn limiter_client_ip(extensions: &axum::http::Extensions) -> Option<IpAddr> {
3393 if let Some(client) = extensions.get::<ClientIp>() {
3394 return Some(client.ip);
3395 }
3396 extensions
3397 .get::<ConnectInfo<SocketAddr>>()
3398 .map(|ci| ci.0.ip())
3399 .or_else(|| {
3400 extensions
3401 .get::<ConnectInfo<TlsConnInfo>>()
3402 .map(|ci| ci.0.addr.ip())
3403 })
3404}
3405
3406pub(crate) type ExtraRouteRateLimiter = BoundedKeyedLimiter<IpAddr>;
3410
3411const EXTRA_ROUTE_MAX_TRACKED_KEYS: usize = 10_000;
3417
3418const EXTRA_ROUTE_IDLE_EVICTION: Duration = Duration::from_mins(15);
3421
3422fn build_extra_route_rate_limiter(
3429 per_minute: u32,
3430 burst: Option<u32>,
3431) -> Arc<ExtraRouteRateLimiter> {
3432 let rate = std::num::NonZeroU32::new(per_minute.max(1)).unwrap_or(std::num::NonZeroU32::MIN);
3433 let mut quota = governor::Quota::per_minute(rate);
3434 if let Some(b) = burst.and_then(std::num::NonZeroU32::new) {
3435 quota = quota.allow_burst(b);
3436 }
3437 Arc::new(BoundedKeyedLimiter::new(
3438 quota,
3439 EXTRA_ROUTE_MAX_TRACKED_KEYS,
3440 EXTRA_ROUTE_IDLE_EVICTION,
3441 ))
3442}
3443
3444async fn extra_route_rate_limit_middleware(
3466 limiter: Arc<ExtraRouteRateLimiter>,
3467 exempt: Arc<std::collections::HashSet<String>>,
3468 req: Request<Body>,
3469 next: Next,
3470) -> axum::response::Response {
3471 if exempt.contains(req.uri().path()) {
3472 return next.run(req).await;
3473 }
3474 let peer_ip: Option<IpAddr> = limiter_client_ip(req.extensions());
3475 if let Some(ip) = peer_ip
3476 && let Err(wait) = limiter.check_key_wait(&ip)
3477 {
3478 #[cfg(feature = "metrics")]
3479 crate::metrics::record_rate_limit_deny(req.extensions(), "extra_route");
3480 tracing::warn!(%ip, "extra route request rate limited");
3481 return McpxError::RateLimitedFor {
3482 message: "too many requests to application routes from this source".into(),
3483 retry_after: wait,
3484 }
3485 .into_response();
3486 }
3487 next.run(req).await
3488}
3489
3490async fn origin_check_middleware(
3494 allowed: Arc<[String]>,
3495 log_request_headers: bool,
3496 req: Request<Body>,
3497 next: Next,
3498) -> axum::response::Response {
3499 let method = req.method().clone();
3500 let path = req.uri().path().to_owned();
3501
3502 log_incoming_request(&method, &path, req.headers(), log_request_headers);
3503
3504 if let Some(origin) = req.headers().get(axum::http::header::ORIGIN) {
3505 let origin_str = origin.to_str().unwrap_or("");
3506 if !allowed.iter().any(|a| a == origin_str) {
3507 tracing::warn!(
3508 origin = origin_str,
3509 %method,
3510 %path,
3511 allowed = ?&*allowed,
3512 "rejected request: Origin not allowed"
3513 );
3514 return (
3515 axum::http::StatusCode::FORBIDDEN,
3516 "Forbidden: Origin not allowed",
3517 )
3518 .into_response();
3519 }
3520 }
3521 next.run(req).await
3522}
3523
3524fn log_incoming_request(
3527 method: &axum::http::Method,
3528 path: &str,
3529 headers: &axum::http::HeaderMap,
3530 log_request_headers: bool,
3531) {
3532 if log_request_headers {
3533 tracing::debug!(
3534 %method,
3535 %path,
3536 headers = %format_request_headers_for_log(headers),
3537 "incoming request"
3538 );
3539 } else {
3540 tracing::debug!(%method, %path, "incoming request");
3541 }
3542}
3543
3544fn format_request_headers_for_log(headers: &axum::http::HeaderMap) -> String {
3545 headers
3546 .iter()
3547 .map(|(k, v)| {
3548 let name = k.as_str();
3549 if name == "authorization" || name == "cookie" || name == "proxy-authorization" {
3550 format!("{name}: [REDACTED]")
3551 } else {
3552 format!("{name}: {}", v.to_str().unwrap_or("<non-utf8>"))
3553 }
3554 })
3555 .collect::<Vec<_>>()
3556 .join(", ")
3557}
3558
3559#[allow(
3583 clippy::cognitive_complexity,
3584 reason = "complexity is purely tracing macro expansion (info/warn + match arms); 18 lines of straight-line code, nothing meaningful to extract"
3585)]
3586pub async fn serve_stdio<H>(handler: H) -> Result<(), McpxError>
3587where
3588 H: ServerHandler + 'static,
3589{
3590 use rmcp::ServiceExt as _;
3591
3592 tracing::info!("stdio transport: serving on stdin/stdout");
3593 tracing::warn!("stdio mode: auth, RBAC, TLS, and Origin checks are DISABLED");
3594
3595 let transport = rmcp::transport::io::stdio();
3596
3597 let service = handler
3598 .serve(transport)
3599 .await
3600 .map_err(|e| McpxError::Startup(format!("stdio initialize failed: {e}")))?;
3601
3602 if let Err(e) = service.waiting().await {
3603 tracing::warn!(error = %e, "stdio session ended with error");
3604 }
3605 tracing::info!("stdio session ended");
3606 Ok(())
3607}
3608
3609#[cfg(test)]
3610mod tests {
3611 #![allow(
3612 clippy::unwrap_used,
3613 clippy::expect_used,
3614 clippy::panic,
3615 clippy::indexing_slicing,
3616 clippy::unwrap_in_result,
3617 clippy::print_stdout,
3618 clippy::print_stderr,
3619 deprecated,
3620 reason = "internal unit tests legitimately read/write the deprecated `pub` fields they were designed to verify"
3621 )]
3622 use std::{sync::Arc, time::Duration};
3623
3624 use axum::{
3625 body::Body,
3626 http::{Request, StatusCode, header},
3627 response::IntoResponse,
3628 };
3629 use http_body_util::BodyExt;
3630 use tower::ServiceExt as _;
3631
3632 use super::*;
3633
3634 #[test]
3637 fn server_config_new_defaults() {
3638 let cfg = McpServerConfig::new("0.0.0.0:8443", "test-server", "1.0.0");
3639 assert_eq!(cfg.bind_addr, "0.0.0.0:8443");
3640 assert_eq!(cfg.name, "test-server");
3641 assert_eq!(cfg.version, "1.0.0");
3642 assert!(cfg.tls_cert_path.is_none());
3643 assert!(cfg.tls_key_path.is_none());
3644 assert!(cfg.auth.is_none());
3645 assert!(cfg.rbac.is_none());
3646 assert!(cfg.allowed_origins.is_empty());
3647 assert!(cfg.tool_rate_limit.is_none());
3648 assert!(cfg.readiness_check.is_none());
3649 assert_eq!(cfg.max_request_body, 1024 * 1024);
3650 assert_eq!(cfg.request_timeout, Duration::from_mins(2));
3651 assert_eq!(cfg.shutdown_timeout, Duration::from_secs(30));
3652 assert!(!cfg.log_request_headers);
3653 assert_eq!(cfg.tls_handshake_timeout, Duration::from_secs(10));
3654 assert_eq!(cfg.max_concurrent_tls_handshakes, 256);
3655 }
3656
3657 #[test]
3658 fn tls_handshake_builders_set_fields() {
3659 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
3660 .with_tls_handshake_timeout(Duration::from_secs(3))
3661 .with_max_concurrent_tls_handshakes(64);
3662 assert_eq!(cfg.tls_handshake_timeout, Duration::from_secs(3));
3663 assert_eq!(cfg.max_concurrent_tls_handshakes, 64);
3664 }
3665
3666 #[test]
3667 fn validate_rejects_zero_tls_handshake_timeout() {
3668 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
3669 .with_tls_handshake_timeout(Duration::ZERO);
3670 let err = cfg.validate().expect_err("zero handshake timeout");
3671 assert!(err.to_string().contains("tls_handshake_timeout"));
3672 }
3673
3674 #[test]
3675 fn validate_rejects_zero_max_concurrent_tls_handshakes() {
3676 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
3677 .with_max_concurrent_tls_handshakes(0);
3678 let err = cfg.validate().expect_err("zero handshake concurrency");
3679 assert!(err.to_string().contains("max_concurrent_tls_handshakes"));
3680 }
3681
3682 #[test]
3683 fn validate_consumes_and_proves() {
3684 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
3686 let validated = cfg.validate().expect("valid config");
3687 assert_eq!(validated.as_inner().name, "test-server");
3689 let raw = validated.into_inner();
3691 assert_eq!(raw.name, "test-server");
3692
3693 let mut bad = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
3695 bad.max_request_body = 0;
3696 assert!(bad.validate().is_err(), "zero body cap must fail validate");
3697 }
3698
3699 #[test]
3700 fn validate_rejects_zero_max_concurrent_requests() {
3701 let cfg =
3702 McpServerConfig::new("127.0.0.1:8080", "test", "1.0.0").with_max_concurrent_requests(0);
3703 let err = cfg.validate().expect_err("zero concurrency cap must fail");
3704 assert!(
3705 format!("{err}").contains("max_concurrent_requests"),
3706 "error should mention max_concurrent_requests, got: {err}"
3707 );
3708 }
3709
3710 #[test]
3711 fn validate_rejects_zero_max_tracked_keys() {
3712 let rl = crate::auth::RateLimitConfig {
3715 max_attempts_per_minute: 30,
3716 pre_auth_max_per_minute: None,
3717 max_tracked_keys: 0,
3718 idle_eviction: Duration::from_secs(15 * 60),
3719 burst: None,
3720 pre_auth_burst: None,
3721 };
3722 let auth_cfg = AuthConfig {
3723 enabled: true,
3724 api_keys: Vec::new(),
3725 mtls: None,
3726 rate_limit: Some(rl),
3727 #[cfg(feature = "oauth")]
3728 oauth: None,
3729 };
3730 let cfg = McpServerConfig::new("127.0.0.1:8080", "test", "1.0.0").with_auth(auth_cfg);
3731 let err = cfg.validate().expect_err("zero max_tracked_keys must fail");
3732 assert!(
3733 format!("{err}").contains("max_tracked_keys"),
3734 "error should mention max_tracked_keys, got: {err}"
3735 );
3736 }
3737
3738 #[test]
3739 fn derive_allowed_hosts_includes_public_host() {
3740 let hosts = derive_allowed_hosts("0.0.0.0:8080", Some("https://mcp.example.com/mcp"));
3741 assert!(
3742 hosts.iter().any(|h| h == "mcp.example.com"),
3743 "public_url host must be allowed"
3744 );
3745 }
3746
3747 #[test]
3748 fn derive_allowed_hosts_includes_bind_authority() {
3749 let hosts = derive_allowed_hosts("127.0.0.1:8080", None);
3750 assert!(
3751 hosts.iter().any(|h| h == "127.0.0.1"),
3752 "bind host must be allowed"
3753 );
3754 assert!(
3755 hosts.iter().any(|h| h == "127.0.0.1:8080"),
3756 "bind authority must be allowed"
3757 );
3758 }
3759
3760 #[tokio::test]
3763 async fn healthz_returns_ok_json() {
3764 let resp = healthz().await.into_response();
3765 assert_eq!(resp.status(), StatusCode::OK);
3766 let body = resp.into_body().collect().await.unwrap().to_bytes();
3767 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3768 assert_eq!(json["status"], "ok");
3769 assert!(
3770 json.get("name").is_none(),
3771 "healthz must not expose server name"
3772 );
3773 assert!(
3774 json.get("version").is_none(),
3775 "healthz must not expose version"
3776 );
3777 }
3778
3779 #[tokio::test]
3782 async fn readyz_returns_ok_when_ready() {
3783 let check: ReadinessCheck =
3784 Arc::new(|| Box::pin(async { serde_json::json!({"ready": true, "db": "connected"}) }));
3785 let resp = readyz(check).await.into_response();
3786 assert_eq!(resp.status(), StatusCode::OK);
3787 let body = resp.into_body().collect().await.unwrap().to_bytes();
3788 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3789 assert_eq!(json["ready"], true);
3790 assert!(
3791 json.get("name").is_none(),
3792 "readyz must not expose server name"
3793 );
3794 assert!(
3795 json.get("version").is_none(),
3796 "readyz must not expose version"
3797 );
3798 assert_eq!(json["db"], "connected");
3799 }
3800
3801 #[tokio::test]
3802 async fn readyz_returns_503_when_not_ready() {
3803 let check: ReadinessCheck =
3804 Arc::new(|| Box::pin(async { serde_json::json!({"ready": false}) }));
3805 let resp = readyz(check).await.into_response();
3806 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
3807 }
3808
3809 #[tokio::test]
3810 async fn readyz_returns_503_when_ready_missing() {
3811 let check: ReadinessCheck =
3812 Arc::new(|| Box::pin(async { serde_json::json!({"status": "starting"}) }));
3813 let resp = readyz(check).await.into_response();
3814 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
3816 }
3817
3818 fn peer_probe_router() -> axum::Router {
3823 async fn probe(req: Request<Body>) -> String {
3824 let ci = req
3825 .extensions()
3826 .get::<ConnectInfo<SocketAddr>>()
3827 .map(|c| c.0.to_string())
3828 .unwrap_or_default();
3829 let pa = req
3830 .extensions()
3831 .get::<PeerAddr>()
3832 .map(|p| p.addr.to_string())
3833 .unwrap_or_default();
3834 format!("{ci}|{pa}")
3835 }
3836 axum::Router::new()
3837 .route("/probe", axum::routing::get(probe))
3838 .layer(axum::middleware::from_fn(|req, next| {
3839 normalize_peer_addr_middleware(None, req, next)
3840 }))
3841 }
3842
3843 async fn body_string(resp: axum::response::Response) -> String {
3844 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
3845 String::from_utf8(bytes.to_vec()).unwrap()
3846 }
3847
3848 #[tokio::test]
3849 async fn normalize_preserves_existing_connect_info_and_mirrors_peer_addr() {
3850 let plain: SocketAddr = "10.0.0.1:1111".parse().unwrap();
3853 let tls: SocketAddr = "10.0.0.2:2222".parse().unwrap();
3854 let req = Request::builder()
3855 .uri("/probe")
3856 .extension(ConnectInfo(plain))
3857 .extension(ConnectInfo(TlsConnInfo::new(tls, None)))
3858 .body(Body::empty())
3859 .unwrap();
3860 let resp = peer_probe_router().oneshot(req).await.unwrap();
3861 assert_eq!(resp.status(), StatusCode::OK);
3862 assert_eq!(body_string(resp).await, format!("{plain}|{plain}"));
3863 }
3864
3865 #[tokio::test]
3866 async fn normalize_inserts_connect_info_and_peer_addr_from_tls() {
3867 let tls: SocketAddr = "192.168.1.7:50443".parse().unwrap();
3868 let req = Request::builder()
3869 .uri("/probe")
3870 .extension(ConnectInfo(TlsConnInfo::new(tls, None)))
3871 .body(Body::empty())
3872 .unwrap();
3873 let resp = peer_probe_router().oneshot(req).await.unwrap();
3874 assert_eq!(resp.status(), StatusCode::OK);
3875 assert_eq!(body_string(resp).await, format!("{tls}|{tls}"));
3876 }
3877
3878 #[tokio::test]
3879 async fn normalize_no_op_without_any_connect_info() {
3880 let req = Request::builder()
3881 .uri("/probe")
3882 .body(Body::empty())
3883 .unwrap();
3884 let resp = peer_probe_router().oneshot(req).await.unwrap();
3885 assert_eq!(resp.status(), StatusCode::OK);
3886 assert_eq!(body_string(resp).await, "|");
3887 }
3888
3889 #[tokio::test]
3890 async fn peer_addr_extractor_rejects_when_absent() {
3891 async fn h(peer: PeerAddr) -> String {
3892 peer.addr.to_string()
3893 }
3894 let app = axum::Router::new().route("/p", axum::routing::get(h));
3895 let req = Request::builder().uri("/p").body(Body::empty()).unwrap();
3896 let resp = app.oneshot(req).await.unwrap();
3897 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
3898 }
3899
3900 #[tokio::test]
3901 async fn peer_addr_extractor_returns_value_when_present() {
3902 async fn h(peer: PeerAddr) -> String {
3903 peer.addr.to_string()
3904 }
3905 let addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
3906 let app = axum::Router::new().route("/p", axum::routing::get(h));
3907 let req = Request::builder()
3908 .uri("/p")
3909 .extension(PeerAddr::new(addr))
3910 .body(Body::empty())
3911 .unwrap();
3912 let resp = app.oneshot(req).await.unwrap();
3913 assert_eq!(resp.status(), StatusCode::OK);
3914 assert_eq!(body_string(resp).await, addr.to_string());
3915 }
3916
3917 #[tokio::test]
3918 async fn peer_addr_via_extension_extractor() {
3919 async fn h(axum::Extension(peer): axum::Extension<PeerAddr>) -> String {
3920 peer.addr.to_string()
3921 }
3922 let addr: SocketAddr = "127.0.0.1:4242".parse().unwrap();
3923 let app = axum::Router::new().route("/p", axum::routing::get(h));
3924 let req = Request::builder()
3925 .uri("/p")
3926 .extension(PeerAddr::new(addr))
3927 .body(Body::empty())
3928 .unwrap();
3929 let resp = app.oneshot(req).await.unwrap();
3930 assert_eq!(resp.status(), StatusCode::OK);
3931 assert_eq!(body_string(resp).await, addr.to_string());
3932 }
3933
3934 fn limited_router(per_minute: u32) -> axum::Router {
3939 limited_router_with_burst(per_minute, None)
3940 }
3941
3942 fn limited_router_with_burst(per_minute: u32, burst: Option<u32>) -> axum::Router {
3944 limited_router_full(per_minute, burst, &[])
3945 }
3946
3947 fn limited_router_full(
3951 per_minute: u32,
3952 burst: Option<u32>,
3953 exempt_paths: &[&str],
3954 ) -> axum::Router {
3955 let limiter = build_extra_route_rate_limiter(per_minute, burst);
3956 let exempt: Arc<std::collections::HashSet<String>> =
3957 Arc::new(exempt_paths.iter().map(|s| (*s).to_owned()).collect());
3958 axum::Router::new()
3959 .route("/limited", axum::routing::get(|| async { "ok" }))
3960 .route("/exempt", axum::routing::get(|| async { "ok" }))
3961 .layer(axum::middleware::from_fn(move |req, next| {
3962 let l = Arc::clone(&limiter);
3963 let e = Arc::clone(&exempt);
3964 extra_route_rate_limit_middleware(l, e, req, next)
3965 }))
3966 }
3967
3968 fn limited_req(ip: &str) -> Request<Body> {
3969 limited_req_to(ip, "/limited")
3970 }
3971
3972 fn limited_req_to(ip: &str, path: &str) -> Request<Body> {
3973 let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
3974 Request::builder()
3975 .uri(path)
3976 .extension(ConnectInfo(addr))
3977 .body(Body::empty())
3978 .unwrap()
3979 }
3980
3981 #[tokio::test]
3982 async fn extra_route_limiter_denies_over_quota() {
3983 let app = limited_router(2);
3984 for i in 0..2 {
3985 let resp = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
3986 assert_eq!(resp.status(), StatusCode::OK, "request {i} should pass");
3987 }
3988 let resp = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
3989 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
3990 let body = body_string(resp).await;
3991 assert!(
3992 body.contains("too many requests to application routes"),
3993 "deny body should match the limiter message, got: {body}"
3994 );
3995 }
3996
3997 #[tokio::test]
3998 async fn extra_route_limiter_isolates_keys() {
3999 let app = limited_router(2);
4000 for _ in 0..2 {
4001 let resp = app.clone().oneshot(limited_req("10.2.2.2")).await.unwrap();
4002 assert_eq!(resp.status(), StatusCode::OK);
4003 }
4004 let exhausted = app.clone().oneshot(limited_req("10.2.2.2")).await.unwrap();
4005 assert_eq!(exhausted.status(), StatusCode::TOO_MANY_REQUESTS);
4006 let other = app.clone().oneshot(limited_req("10.3.3.3")).await.unwrap();
4008 assert_eq!(other.status(), StatusCode::OK);
4009 }
4010
4011 #[tokio::test]
4012 async fn extra_route_limiter_fails_open_without_peer() {
4013 let app = limited_router(1);
4014 for i in 0..3 {
4015 let req = Request::builder()
4016 .uri("/limited")
4017 .body(Body::empty())
4018 .unwrap();
4019 let resp = app.clone().oneshot(req).await.unwrap();
4020 assert_eq!(
4021 resp.status(),
4022 StatusCode::OK,
4023 "request {i} should fail open"
4024 );
4025 }
4026 }
4027
4028 #[tokio::test]
4029 async fn extra_route_limiter_extracts_tls_conn_info() {
4030 let app = limited_router(2);
4031 let mk = || {
4032 let addr: SocketAddr = "192.168.9.9:55555".parse().unwrap();
4033 Request::builder()
4034 .uri("/limited")
4035 .extension(ConnectInfo(TlsConnInfo::new(addr, None)))
4036 .body(Body::empty())
4037 .unwrap()
4038 };
4039 for _ in 0..2 {
4040 assert_eq!(
4041 app.clone().oneshot(mk()).await.unwrap().status(),
4042 StatusCode::OK
4043 );
4044 }
4045 let resp = app.clone().oneshot(mk()).await.unwrap();
4046 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
4047 }
4048
4049 #[tokio::test]
4050 async fn extra_route_limiter_exempt_path_bypasses_quota() {
4051 let app = limited_router_full(1, None, &["/exempt"]);
4054 for i in 0..5 {
4055 let resp = app
4056 .clone()
4057 .oneshot(limited_req_to("10.6.6.6", "/exempt"))
4058 .await
4059 .unwrap();
4060 assert_eq!(resp.status(), StatusCode::OK, "exempt request {i}");
4061 }
4062 let resp = app.clone().oneshot(limited_req("10.6.6.6")).await.unwrap();
4064 assert_eq!(resp.status(), StatusCode::OK);
4065 let resp = app.clone().oneshot(limited_req("10.6.6.6")).await.unwrap();
4067 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
4068 }
4069
4070 #[tokio::test]
4071 async fn extra_route_limiter_exemption_is_raw_exact_match() {
4072 let app = limited_router_full(1, None, &["/exempt"]);
4075 let ok = app
4076 .clone()
4077 .oneshot(limited_req_to("10.7.7.7", "/exempt/"))
4078 .await
4079 .unwrap();
4080 assert_eq!(
4081 ok.status(),
4082 StatusCode::NOT_FOUND,
4083 "variant path routes 404"
4084 );
4085 let denied = app
4087 .clone()
4088 .oneshot(limited_req_to("10.7.7.7", "/limited"))
4089 .await
4090 .unwrap();
4091 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
4092 }
4093
4094 #[cfg(feature = "metrics")]
4095 #[tokio::test]
4096 async fn extra_route_limiter_deny_increments_counter_exempt_does_not() {
4097 let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
4098 let app = limited_router_full(1, None, &["/exempt"]);
4099 let mk = |path: &str| {
4100 let addr: SocketAddr = "10.8.8.8:40000".parse().unwrap();
4101 Request::builder()
4102 .uri(path)
4103 .extension(ConnectInfo(addr))
4104 .extension(Arc::clone(&metrics))
4105 .body(Body::empty())
4106 .unwrap()
4107 };
4108 let counter = || {
4109 metrics
4110 .rate_limited_total
4111 .with_label_values(&["extra_route"])
4112 .get()
4113 };
4114 for _ in 0..3 {
4116 assert_eq!(
4117 app.clone().oneshot(mk("/exempt")).await.unwrap().status(),
4118 StatusCode::OK
4119 );
4120 }
4121 assert_eq!(counter(), 0, "exempt requests must not count as denies");
4122 assert_eq!(
4124 app.clone().oneshot(mk("/limited")).await.unwrap().status(),
4125 StatusCode::OK
4126 );
4127 assert_eq!(counter(), 0);
4128 assert_eq!(
4129 app.clone().oneshot(mk("/limited")).await.unwrap().status(),
4130 StatusCode::TOO_MANY_REQUESTS
4131 );
4132 assert_eq!(counter(), 1, "deny must increment the extra_route label");
4133 }
4134
4135 #[test]
4136 fn validate_rejects_exempt_paths_without_base_knob() {
4137 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4138 .with_extra_route_rate_limit_exempt_paths(["/ok"]);
4139 let err = cfg.validate().expect_err("exempt paths without rate limit");
4140 assert!(err.to_string().contains("requires extra_route_rate_limit"));
4141 }
4142
4143 #[test]
4144 fn validate_rejects_malformed_exempt_paths() {
4145 for bad in ["", "no-slash"] {
4146 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4147 .with_extra_route_rate_limit(10)
4148 .with_extra_route_rate_limit_exempt_paths([bad]);
4149 let err = cfg.validate().expect_err("malformed exempt path");
4150 assert!(
4151 err.to_string()
4152 .contains("must be non-empty and start with '/'"),
4153 "entry {bad:?}: {err}"
4154 );
4155 }
4156 }
4157
4158 #[test]
4159 fn validate_accepts_wellformed_exempt_paths() {
4160 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4161 .with_extra_route_rate_limit(10)
4162 .with_extra_route_rate_limit_exempt_paths(["/.well-known/oauth-authorization-server"]);
4163 assert!(cfg.validate().is_ok());
4164 }
4165
4166 #[test]
4167 fn validate_rejects_zero_extra_route_rate_limit() {
4168 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4169 .with_extra_route_rate_limit(0);
4170 let err = cfg.validate().expect_err("zero extra route rate limit");
4171 assert!(err.to_string().contains("extra_route_rate_limit"));
4172 }
4173
4174 #[tokio::test]
4175 async fn extra_route_limiter_burst_allows_initial_spike() {
4176 let app = limited_router_with_burst(1, Some(3));
4177 for i in 0..3 {
4178 let resp = app.clone().oneshot(limited_req("10.4.4.4")).await.unwrap();
4179 assert_eq!(resp.status(), StatusCode::OK, "burst request {i}");
4180 }
4181 let resp = app.clone().oneshot(limited_req("10.4.4.4")).await.unwrap();
4182 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
4183 }
4184
4185 #[tokio::test]
4186 async fn extra_route_limiter_deny_sets_retry_after() {
4187 let app = limited_router(1);
4188 let ok = app.clone().oneshot(limited_req("10.5.5.5")).await.unwrap();
4189 assert_eq!(ok.status(), StatusCode::OK);
4190 let denied = app.clone().oneshot(limited_req("10.5.5.5")).await.unwrap();
4191 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
4192 let retry_after = denied
4193 .headers()
4194 .get(header::RETRY_AFTER)
4195 .expect("Retry-After present")
4196 .to_str()
4197 .unwrap()
4198 .parse::<u64>()
4199 .unwrap();
4200 assert!(retry_after >= 1, "delta-seconds must be >= 1");
4201 }
4202
4203 #[test]
4204 fn validate_rejects_zero_burst_knobs() {
4205 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4206 .with_tool_rate_limit(10)
4207 .with_tool_rate_limit_burst(0)
4208 .validate()
4209 .expect_err("zero tool burst");
4210 assert!(err.to_string().contains("tool_rate_limit_burst"));
4211
4212 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4213 .with_extra_route_rate_limit(10)
4214 .with_extra_route_rate_limit_burst(0)
4215 .validate()
4216 .expect_err("zero extra route burst");
4217 assert!(err.to_string().contains("extra_route_rate_limit_burst"));
4218 }
4219
4220 #[test]
4221 fn validate_rejects_orphan_burst_knobs() {
4222 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4223 .with_tool_rate_limit_burst(5)
4224 .validate()
4225 .expect_err("orphan tool burst");
4226 assert!(err.to_string().contains("requires tool_rate_limit"));
4227
4228 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4229 .with_extra_route_rate_limit_burst(5)
4230 .validate()
4231 .expect_err("orphan extra route burst");
4232 assert!(err.to_string().contains("requires extra_route_rate_limit"));
4233 }
4234
4235 #[test]
4236 fn validate_rejects_zero_auth_bursts() {
4237 let auth = AuthConfig::with_keys(vec![])
4238 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_burst(0));
4239 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4240 .with_auth(auth)
4241 .validate()
4242 .expect_err("zero auth burst");
4243 assert!(err.to_string().contains("rate_limit.burst"));
4244
4245 let auth = AuthConfig::with_keys(vec![])
4246 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(0));
4247 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4248 .with_auth(auth)
4249 .validate()
4250 .expect_err("zero pre-auth burst");
4251 assert!(err.to_string().contains("pre_auth_burst"));
4252 }
4253
4254 #[test]
4257 fn validate_accepts_pre_auth_burst_without_explicit_pre_auth_rate() {
4258 let auth = AuthConfig::with_keys(vec![])
4259 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(50));
4260 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
4261 assert!(cfg.validate().is_ok(), "pre_auth_burst has no orphan rule");
4262 }
4263
4264 fn forward_resolver(trusted: &[&str], mode: ForwardedHeaderMode) -> Arc<ForwardResolver> {
4267 Arc::new(ForwardResolver {
4268 trusted: trusted.iter().map(|s| s.parse().unwrap()).collect(),
4269 mode,
4270 })
4271 }
4272
4273 fn forwarded_probe_router(resolver: Option<Arc<ForwardResolver>>) -> axum::Router {
4275 async fn probe(req: Request<Body>) -> String {
4276 let pa = req
4277 .extensions()
4278 .get::<PeerAddr>()
4279 .map(|p| p.addr.ip().to_string())
4280 .unwrap_or_default();
4281 let ci = req
4282 .extensions()
4283 .get::<ClientIp>()
4284 .map(|c| c.ip.to_string())
4285 .unwrap_or_default();
4286 format!("{pa}|{ci}")
4287 }
4288 axum::Router::new()
4289 .route("/probe", axum::routing::get(probe))
4290 .layer(axum::middleware::from_fn(move |req, next| {
4291 let r = resolver.clone();
4292 normalize_peer_addr_middleware(r, req, next)
4293 }))
4294 }
4295
4296 fn probe_req(peer: &str, header: Option<(&str, &str)>) -> Request<Body> {
4297 let addr: SocketAddr = peer.parse().unwrap();
4298 let mut builder = Request::builder()
4299 .uri("/probe")
4300 .extension(ConnectInfo(addr));
4301 if let Some((name, value)) = header {
4302 builder = builder.header(name, value);
4303 }
4304 builder.body(Body::empty()).unwrap()
4305 }
4306
4307 #[tokio::test]
4308 async fn client_ip_equals_direct_without_resolver() {
4309 let app = forwarded_probe_router(None);
4310 let resp = app
4311 .oneshot(probe_req(
4312 "10.1.2.3:4444",
4313 Some(("x-forwarded-for", "203.0.113.7")),
4314 ))
4315 .await
4316 .unwrap();
4317 assert_eq!(
4318 body_string(resp).await,
4319 "10.1.2.3|10.1.2.3",
4320 "feature off: header ignored, ClientIp == direct"
4321 );
4322 }
4323
4324 #[tokio::test]
4325 async fn client_ip_resolved_for_trusted_peer() {
4326 let app = forwarded_probe_router(Some(forward_resolver(
4327 &["10.0.0.0/8"],
4328 ForwardedHeaderMode::XForwardedFor,
4329 )));
4330 let resp = app
4331 .oneshot(probe_req(
4332 "10.0.0.1:9999",
4333 Some(("x-forwarded-for", "203.0.113.7")),
4334 ))
4335 .await
4336 .unwrap();
4337 assert_eq!(
4338 body_string(resp).await,
4339 "10.0.0.1|203.0.113.7",
4340 "PeerAddr stays direct while ClientIp resolves"
4341 );
4342 }
4343
4344 #[tokio::test]
4345 async fn client_ip_falls_back_to_direct_on_malformed_header() {
4346 let app = forwarded_probe_router(Some(forward_resolver(
4347 &["10.0.0.0/8"],
4348 ForwardedHeaderMode::XForwardedFor,
4349 )));
4350 let resp = app
4351 .oneshot(probe_req(
4352 "10.0.0.1:9999",
4353 Some(("x-forwarded-for", "not-an-ip")),
4354 ))
4355 .await
4356 .unwrap();
4357 assert_eq!(
4358 body_string(resp).await,
4359 "10.0.0.1|10.0.0.1",
4360 "malformed chain falls back to the direct peer"
4361 );
4362 }
4363
4364 #[test]
4365 fn forwarded_header_mode_deserializes_kebab_case() {
4366 #[derive(serde::Deserialize)]
4367 struct Wrapper {
4368 mode: ForwardedHeaderMode,
4369 }
4370 let w: Wrapper = toml::from_str(r#"mode = "x-forwarded-for""#).unwrap();
4371 assert_eq!(w.mode, ForwardedHeaderMode::XForwardedFor);
4372 let w: Wrapper = toml::from_str(r#"mode = "forwarded""#).unwrap();
4373 assert_eq!(w.mode, ForwardedHeaderMode::Forwarded);
4374 assert!(
4375 toml::from_str::<Wrapper>(r#"mode = "XForwardedFor""#).is_err(),
4376 "PascalCase wire value must be rejected"
4377 );
4378 }
4379
4380 #[test]
4381 fn validate_rejects_bad_trusted_proxy_entry() {
4382 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4383 .with_trusted_proxies(["not-a-cidr"]);
4384 let err = cfg.validate().expect_err("bad CIDR");
4385 assert!(err.to_string().contains("trusted_proxies"));
4386 }
4387
4388 #[test]
4389 fn validate_rejects_zero_prefix_trusted_proxy() {
4390 for entry in ["0.0.0.0/0", "::/0"] {
4391 let cfg =
4392 McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_trusted_proxies([entry]);
4393 let err = cfg.validate().expect_err("zero-prefix CIDR");
4394 assert!(
4395 err.to_string().contains("prefix length 0"),
4396 "entry {entry}: {err}"
4397 );
4398 }
4399 }
4400
4401 #[test]
4402 fn validate_accepts_cidr_and_bare_ip_proxy_entries() {
4403 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_trusted_proxies([
4404 "10.0.0.0/8",
4405 "192.0.2.1",
4406 "2001:db8::1",
4407 ]);
4408 assert!(cfg.validate().is_ok(), "CIDRs and bare IPs are accepted");
4409 }
4410
4411 #[test]
4412 fn validate_rejects_forwarded_header_without_proxies() {
4413 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4414 .with_forwarded_header(ForwardedHeaderMode::Forwarded);
4415 let err = cfg.validate().expect_err("mode without proxies");
4416 assert!(err.to_string().contains("requires trusted_proxies"));
4417 }
4418
4419 fn origin_router(origins: Vec<String>, log_request_headers: bool) -> axum::Router {
4423 let allowed: Arc<[String]> = Arc::from(origins);
4424 axum::Router::new()
4425 .route("/test", axum::routing::get(|| async { "ok" }))
4426 .layer(axum::middleware::from_fn(move |req, next| {
4427 let a = Arc::clone(&allowed);
4428 origin_check_middleware(a, log_request_headers, req, next)
4429 }))
4430 }
4431
4432 #[tokio::test]
4433 async fn origin_allowed_passes() {
4434 let app = origin_router(vec!["http://localhost:3000".into()], false);
4435 let req = Request::builder()
4436 .uri("/test")
4437 .header(header::ORIGIN, "http://localhost:3000")
4438 .body(Body::empty())
4439 .unwrap();
4440 let resp = app.oneshot(req).await.unwrap();
4441 assert_eq!(resp.status(), StatusCode::OK);
4442 }
4443
4444 #[tokio::test]
4445 async fn origin_rejected_returns_403() {
4446 let app = origin_router(vec!["http://localhost:3000".into()], false);
4447 let req = Request::builder()
4448 .uri("/test")
4449 .header(header::ORIGIN, "http://evil.com")
4450 .body(Body::empty())
4451 .unwrap();
4452 let resp = app.oneshot(req).await.unwrap();
4453 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
4454 }
4455
4456 #[tokio::test]
4457 async fn no_origin_header_passes() {
4458 let app = origin_router(vec!["http://localhost:3000".into()], false);
4459 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4460 let resp = app.oneshot(req).await.unwrap();
4461 assert_eq!(resp.status(), StatusCode::OK);
4462 }
4463
4464 #[tokio::test]
4465 async fn empty_allowlist_rejects_any_origin() {
4466 let app = origin_router(vec![], false);
4467 let req = Request::builder()
4468 .uri("/test")
4469 .header(header::ORIGIN, "http://anything.com")
4470 .body(Body::empty())
4471 .unwrap();
4472 let resp = app.oneshot(req).await.unwrap();
4473 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
4474 }
4475
4476 #[tokio::test]
4477 async fn empty_allowlist_passes_without_origin() {
4478 let app = origin_router(vec![], false);
4479 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4480 let resp = app.oneshot(req).await.unwrap();
4481 assert_eq!(resp.status(), StatusCode::OK);
4482 }
4483
4484 #[test]
4485 fn format_request_headers_redacts_sensitive_values() {
4486 let mut headers = axum::http::HeaderMap::new();
4487 headers.insert("authorization", "Bearer secret-token".parse().unwrap());
4488 headers.insert("cookie", "sid=abc".parse().unwrap());
4489 headers.insert("x-request-id", "req-123".parse().unwrap());
4490
4491 let out = format_request_headers_for_log(&headers);
4492 assert!(out.contains("authorization: [REDACTED]"));
4493 assert!(out.contains("cookie: [REDACTED]"));
4494 assert!(out.contains("x-request-id: req-123"));
4495 assert!(!out.contains("secret-token"));
4496 }
4497
4498 fn security_router(is_tls: bool) -> axum::Router {
4501 security_router_with(is_tls, SecurityHeadersConfig::default())
4502 }
4503
4504 fn security_router_with(is_tls: bool, cfg: SecurityHeadersConfig) -> axum::Router {
4505 let cfg = Arc::new(cfg);
4506 axum::Router::new()
4507 .route("/test", axum::routing::get(|| async { "ok" }))
4508 .layer(axum::middleware::from_fn(move |req, next| {
4509 let c = Arc::clone(&cfg);
4510 security_headers_middleware(is_tls, c, req, next)
4511 }))
4512 }
4513
4514 #[tokio::test]
4515 async fn security_headers_set_on_response() {
4516 let app = security_router(false);
4517 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4518 let resp = app.oneshot(req).await.unwrap();
4519 assert_eq!(resp.status(), StatusCode::OK);
4520
4521 let h = resp.headers();
4522 assert_eq!(h.get("x-content-type-options").unwrap(), "nosniff");
4523 assert_eq!(h.get("x-frame-options").unwrap(), "deny");
4524 assert_eq!(h.get("cache-control").unwrap(), "no-store, max-age=0");
4525 assert_eq!(h.get("referrer-policy").unwrap(), "no-referrer");
4526 assert_eq!(h.get("cross-origin-opener-policy").unwrap(), "same-origin");
4527 assert_eq!(
4528 h.get("cross-origin-resource-policy").unwrap(),
4529 "same-origin"
4530 );
4531 assert_eq!(
4532 h.get("cross-origin-embedder-policy").unwrap(),
4533 "require-corp"
4534 );
4535 assert_eq!(h.get("x-permitted-cross-domain-policies").unwrap(), "none");
4536 assert!(
4537 h.get("permissions-policy")
4538 .unwrap()
4539 .to_str()
4540 .unwrap()
4541 .contains("camera=()"),
4542 "permissions-policy must restrict browser features"
4543 );
4544 assert_eq!(
4545 h.get("content-security-policy").unwrap(),
4546 "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
4547 );
4548 assert_eq!(h.get("x-dns-prefetch-control").unwrap(), "off");
4549 assert!(h.get("strict-transport-security").is_none());
4551 }
4552
4553 #[tokio::test]
4554 async fn hsts_set_when_tls_enabled() {
4555 let app = security_router(true);
4556 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4557 let resp = app.oneshot(req).await.unwrap();
4558
4559 let hsts = resp.headers().get("strict-transport-security").unwrap();
4560 assert!(
4561 hsts.to_str().unwrap().contains("max-age=63072000"),
4562 "HSTS must set 2-year max-age"
4563 );
4564 }
4565
4566 #[tokio::test]
4567 async fn default_csp_matches_guideline() {
4568 let app = security_router(false);
4569 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4570 let resp = app.oneshot(req).await.unwrap();
4571 assert_eq!(
4572 resp.headers().get("content-security-policy").unwrap(),
4573 "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
4574 );
4575 }
4576
4577 #[tokio::test]
4578 async fn operator_csp_override_still_wins() {
4579 let cfg = SecurityHeadersConfig {
4580 content_security_policy: Some("default-src 'self'".into()),
4581 ..SecurityHeadersConfig::default()
4582 };
4583 let app = security_router_with(false, cfg);
4584 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4585 let resp = app.oneshot(req).await.unwrap();
4586 assert_eq!(
4587 resp.headers().get("content-security-policy").unwrap(),
4588 "default-src 'self'"
4589 );
4590 }
4591
4592 fn check_with_security_headers(headers: SecurityHeadersConfig) -> Result<(), McpxError> {
4598 let cfg =
4599 McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0").with_security_headers(headers);
4600 cfg.check()
4601 }
4602
4603 #[test]
4604 fn security_headers_config_default_validates() {
4605 check_with_security_headers(SecurityHeadersConfig::default())
4606 .expect("default SecurityHeadersConfig must validate");
4607 }
4608
4609 #[test]
4610 fn security_headers_config_validate_accepts_empty_string() {
4611 let h = SecurityHeadersConfig {
4613 x_content_type_options: Some(String::new()),
4614 x_frame_options: Some(String::new()),
4615 cache_control: Some(String::new()),
4616 referrer_policy: Some(String::new()),
4617 cross_origin_opener_policy: Some(String::new()),
4618 cross_origin_resource_policy: Some(String::new()),
4619 cross_origin_embedder_policy: Some(String::new()),
4620 permissions_policy: Some(String::new()),
4621 x_permitted_cross_domain_policies: Some(String::new()),
4622 content_security_policy: Some(String::new()),
4623 x_dns_prefetch_control: Some(String::new()),
4624 strict_transport_security: Some(String::new()),
4625 };
4626 check_with_security_headers(h).expect("Some(\"\") on every field must validate (omit-all)");
4627 }
4628
4629 #[test]
4630 fn security_headers_config_validate_rejects_bad_value() {
4631 let h = SecurityHeadersConfig {
4633 referrer_policy: Some("\u{0007}".into()),
4634 ..SecurityHeadersConfig::default()
4635 };
4636 let err = check_with_security_headers(h)
4637 .expect_err("control char in referrer_policy must reject");
4638 let msg = err.to_string();
4639 assert!(
4640 msg.contains("referrer_policy"),
4641 "error must name the offending field, got: {msg}"
4642 );
4643 }
4644
4645 #[test]
4646 fn security_headers_config_validate_rejects_hsts_preload() {
4647 let h = SecurityHeadersConfig {
4648 strict_transport_security: Some("max-age=63072000; includeSubDomains; preload".into()),
4649 ..SecurityHeadersConfig::default()
4650 };
4651 let err = check_with_security_headers(h).expect_err("HSTS with preload must reject");
4652 let msg = err.to_string();
4653 assert!(
4654 msg.contains("strict_transport_security"),
4655 "error must name the field, got: {msg}"
4656 );
4657 assert!(
4658 msg.to_lowercase().contains("preload"),
4659 "error must mention `preload`, got: {msg}"
4660 );
4661 }
4662
4663 #[test]
4664 fn security_headers_config_validate_rejects_hsts_preload_uppercase() {
4665 let h = SecurityHeadersConfig {
4667 strict_transport_security: Some("max-age=600; PRELOAD".into()),
4668 ..SecurityHeadersConfig::default()
4669 };
4670 check_with_security_headers(h).expect_err("HSTS preload check must be case-insensitive");
4671 }
4672
4673 #[tokio::test]
4674 async fn security_headers_override_honored() {
4675 let h = SecurityHeadersConfig {
4677 x_frame_options: Some("SAMEORIGIN".into()),
4678 ..SecurityHeadersConfig::default()
4679 };
4680 let app = security_router_with(false, h);
4681 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4682 let resp = app.oneshot(req).await.unwrap();
4683 assert_eq!(resp.status(), StatusCode::OK);
4684
4685 let xfo = resp.headers().get("x-frame-options").unwrap();
4686 assert_eq!(xfo, "SAMEORIGIN");
4687 }
4688
4689 #[tokio::test]
4690 async fn security_headers_empty_string_omits() {
4691 let h = SecurityHeadersConfig {
4693 referrer_policy: Some(String::new()),
4694 ..SecurityHeadersConfig::default()
4695 };
4696 let app = security_router_with(false, h);
4697 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4698 let resp = app.oneshot(req).await.unwrap();
4699 assert_eq!(resp.status(), StatusCode::OK);
4700
4701 assert!(
4702 resp.headers().get("referrer-policy").is_none(),
4703 "Some(\"\") must omit the header"
4704 );
4705 assert_eq!(
4707 resp.headers().get("x-content-type-options").unwrap(),
4708 "nosniff"
4709 );
4710 }
4711
4712 #[tokio::test]
4713 async fn security_headers_hsts_only_when_tls() {
4714 let h = SecurityHeadersConfig {
4716 strict_transport_security: Some("max-age=600".into()),
4717 ..SecurityHeadersConfig::default()
4718 };
4719 let app = security_router_with(false, h);
4720 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4721 let resp = app.oneshot(req).await.unwrap();
4722 assert!(
4723 resp.headers().get("strict-transport-security").is_none(),
4724 "HSTS must remain absent on plaintext deployments even with override"
4725 );
4726 }
4727
4728 #[cfg(feature = "oauth")]
4731 #[tokio::test]
4732 async fn oauth_token_cache_headers_set_pragma_and_vary() {
4733 let app = axum::Router::new()
4734 .route("/token", axum::routing::post(|| async { "{}" }))
4735 .layer(axum::middleware::from_fn(
4736 oauth_token_cache_headers_middleware,
4737 ));
4738 let req = Request::builder()
4739 .method("POST")
4740 .uri("/token")
4741 .body(Body::from("{}"))
4742 .unwrap();
4743 let resp = app.oneshot(req).await.unwrap();
4744 assert_eq!(resp.status(), StatusCode::OK);
4745
4746 let h = resp.headers();
4747 assert_eq!(
4748 h.get("pragma").unwrap(),
4749 "no-cache",
4750 "RFC 6749 §5.1: token responses must set Pragma: no-cache"
4751 );
4752 let vary_values: Vec<String> = h
4753 .get_all("vary")
4754 .iter()
4755 .filter_map(|v| v.to_str().ok().map(str::to_owned))
4756 .collect();
4757 assert!(
4758 vary_values
4759 .iter()
4760 .any(|v| v.eq_ignore_ascii_case("Authorization")),
4761 "RFC 6750 §5.4: Vary must include Authorization, got {vary_values:?}"
4762 );
4763 }
4764
4765 #[cfg(feature = "oauth")]
4766 #[tokio::test]
4767 async fn oauth_token_cache_headers_preserve_existing_vary() {
4768 let app = axum::Router::new()
4771 .route(
4772 "/token",
4773 axum::routing::post(|| async {
4774 axum::response::Response::builder()
4775 .header("vary", "Accept-Encoding")
4776 .body(axum::body::Body::from("{}"))
4777 .unwrap()
4778 }),
4779 )
4780 .layer(axum::middleware::from_fn(
4781 oauth_token_cache_headers_middleware,
4782 ));
4783 let req = Request::builder()
4784 .method("POST")
4785 .uri("/token")
4786 .body(Body::empty())
4787 .unwrap();
4788 let resp = app.oneshot(req).await.unwrap();
4789
4790 let vary: Vec<String> = resp
4791 .headers()
4792 .get_all("vary")
4793 .iter()
4794 .filter_map(|v| v.to_str().ok().map(str::to_owned))
4795 .collect();
4796 assert!(
4797 vary.iter().any(|v| v.contains("Accept-Encoding")),
4798 "must preserve pre-existing Vary value, got {vary:?}"
4799 );
4800 assert!(
4801 vary.iter().any(|v| v.contains("Authorization")),
4802 "must append Authorization to Vary, got {vary:?}"
4803 );
4804 }
4805
4806 #[test]
4809 fn version_omits_build_fingerprint_by_default() {
4810 let v = version_payload("my-server", "1.2.3", false);
4811 assert_eq!(v["name"], "my-server");
4812 assert_eq!(v["version"], "1.2.3");
4813 assert!(v["mcpx_version"].is_string());
4814 assert!(
4815 v.get("build_git_sha").is_none(),
4816 "build sha must be hidden by default"
4817 );
4818 assert!(v.get("build_timestamp").is_none());
4819 assert!(v.get("rust_version").is_none());
4820 }
4821
4822 #[test]
4823 fn version_exposes_all_when_enabled() {
4824 let v = version_payload("my-server", "1.2.3", true);
4825 assert!(v["build_git_sha"].is_string());
4826 assert!(v["build_timestamp"].is_string());
4827 assert!(v["rust_version"].is_string());
4828 assert!(v["mcpx_version"].is_string());
4829 }
4830
4831 #[tokio::test]
4834 async fn concurrency_limit_layer_composes_and_serves() {
4835 let app = axum::Router::new()
4839 .route("/ok", axum::routing::get(|| async { "ok" }))
4840 .layer(
4841 tower::ServiceBuilder::new()
4842 .layer(axum::error_handling::HandleErrorLayer::new(
4843 |_err: tower::BoxError| async { StatusCode::SERVICE_UNAVAILABLE },
4844 ))
4845 .layer(tower::load_shed::LoadShedLayer::new())
4846 .layer(tower::limit::ConcurrencyLimitLayer::new(4)),
4847 );
4848 let resp = app
4849 .oneshot(Request::builder().uri("/ok").body(Body::empty()).unwrap())
4850 .await
4851 .unwrap();
4852 assert_eq!(resp.status(), StatusCode::OK);
4853 }
4854
4855 #[tokio::test]
4858 async fn compression_layer_gzip_encodes_response() {
4859 use tower_http::compression::Predicate as _;
4860
4861 let big_body = "a".repeat(4096);
4862 let app = axum::Router::new()
4863 .route(
4864 "/big",
4865 axum::routing::get(move || {
4866 let body = big_body.clone();
4867 async move { body }
4868 }),
4869 )
4870 .layer(
4871 tower_http::compression::CompressionLayer::new()
4872 .gzip(true)
4873 .br(true)
4874 .compress_when(
4875 tower_http::compression::DefaultPredicate::new()
4876 .and(tower_http::compression::predicate::SizeAbove::new(1024)),
4877 ),
4878 );
4879
4880 let req = Request::builder()
4881 .uri("/big")
4882 .header(header::ACCEPT_ENCODING, "gzip")
4883 .body(Body::empty())
4884 .unwrap();
4885 let resp = app.oneshot(req).await.unwrap();
4886 assert_eq!(resp.status(), StatusCode::OK);
4887 assert_eq!(
4888 resp.headers().get(header::CONTENT_ENCODING).unwrap(),
4889 "gzip"
4890 );
4891 }
4892
4893 #[tokio::test]
4896 async fn tls_handshake_timeout_reaps_idle_connections() {
4897 use tokio::io::AsyncReadExt as _;
4898
4899 let _ = rustls::crypto::ring::default_provider().install_default();
4900
4901 let key = rcgen::KeyPair::generate().expect("generate key");
4903 let cert = rcgen::CertificateParams::new(vec!["localhost".to_owned()])
4904 .expect("cert params")
4905 .self_signed(&key)
4906 .expect("self-signed cert");
4907 let dir = std::env::temp_dir().join(format!(
4908 "rmcp-server-kit-hs-timeout-{}",
4909 std::time::SystemTime::now()
4910 .duration_since(std::time::UNIX_EPOCH)
4911 .expect("clock after epoch")
4912 .as_nanos()
4913 ));
4914 tokio::fs::create_dir_all(&dir).await.expect("temp dir");
4915 let cert_path = dir.join("server.crt");
4916 let key_path = dir.join("server.key");
4917 tokio::fs::write(&cert_path, cert.pem())
4918 .await
4919 .expect("write cert");
4920 tokio::fs::write(&key_path, key.serialize_pem())
4921 .await
4922 .expect("write key");
4923
4924 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
4925 let tls = TlsListener::new(
4926 listener,
4927 &cert_path,
4928 &key_path,
4929 None,
4930 None,
4931 Duration::from_millis(200),
4932 8, )
4934 .expect("tls listener");
4935 let addr = axum::serve::Listener::local_addr(&tls).expect("local addr");
4936
4937 let mut idle = tokio::net::TcpStream::connect(addr).await.expect("connect");
4941 let mut buf = [0_u8; 16];
4942 let read = tokio::time::timeout(Duration::from_secs(2), idle.read(&mut buf))
4943 .await
4944 .expect("server must reap the idle handshake within its timeout");
4945 match read {
4946 Ok(0) | Err(_) => {} Ok(n) => panic!("unexpected {n} bytes from server during reaped handshake"),
4948 }
4949
4950 drop(tls);
4951 }
4952
4953 fn assert_owasp_headers(resp: &axum::response::Response, ctx: &str) {
4956 let h = resp.headers();
4957 assert!(
4958 h.contains_key("x-content-type-options"),
4959 "{ctx}: missing X-Content-Type-Options"
4960 );
4961 assert!(
4962 h.contains_key("x-frame-options"),
4963 "{ctx}: missing X-Frame-Options"
4964 );
4965 assert!(
4966 h.contains_key("strict-transport-security"),
4967 "{ctx}: missing Strict-Transport-Security"
4968 );
4969 assert!(
4970 h.contains_key(header::CONTENT_SECURITY_POLICY),
4971 "{ctx}: missing Content-Security-Policy"
4972 );
4973 }
4974
4975 fn m5_router(configure: impl FnOnce(&mut McpServerConfig)) -> axum::Router {
4976 #[derive(Clone)]
4977 struct H;
4978 impl ServerHandler for H {}
4979 let mut config = McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0")
4983 .with_allowed_origins(["http://good.example"])
4984 .with_tls("unused.crt", "unused.key");
4985 configure(&mut config);
4986 let (router, _params) = build_app_router(config, || H).expect("build_app_router");
4987 router
4988 }
4989
4990 #[tokio::test]
4991 async fn headers_on_rejected_origin_403() {
4992 let app = m5_router(|_| {});
4993 let req = Request::builder()
4994 .uri("/healthz")
4995 .header(header::ORIGIN, "http://evil.example")
4996 .body(Body::empty())
4997 .unwrap();
4998 let resp = app.oneshot(req).await.unwrap();
4999 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
5000 assert_owasp_headers(&resp, "origin-403");
5001 }
5002
5003 #[tokio::test]
5004 async fn headers_on_cors_preflight() {
5005 let app = m5_router(|_| {});
5006 let req = Request::builder()
5007 .method(axum::http::Method::OPTIONS)
5008 .uri("/mcp")
5009 .header(header::ORIGIN, "http://good.example")
5010 .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
5011 .body(Body::empty())
5012 .unwrap();
5013 let resp = app.oneshot(req).await.unwrap();
5014 assert_owasp_headers(&resp, "cors-preflight");
5015 }
5016
5017 #[tokio::test]
5018 async fn headers_on_404_fallback() {
5019 let app = m5_router(|_| {});
5020 let req = Request::builder()
5021 .uri("/no-such-route")
5022 .body(Body::empty())
5023 .unwrap();
5024 let resp = app.oneshot(req).await.unwrap();
5025 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5026 assert_owasp_headers(&resp, "404-fallback");
5027 }
5028
5029 #[tokio::test]
5030 async fn headers_on_overload_503() {
5031 let app = m5_router(|c| c.max_concurrent_requests = Some(0));
5034 let req = Request::builder()
5035 .uri("/healthz")
5036 .body(Body::empty())
5037 .unwrap();
5038 let resp = app.oneshot(req).await.unwrap();
5039 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
5040 assert_owasp_headers(&resp, "overload-503");
5041 }
5042
5043 #[cfg(feature = "oauth")]
5046 fn m6_auth_state() -> (Arc<AuthState>, String, String) {
5047 let (admin_token, admin_hash) = crate::auth::generate_api_key().unwrap();
5048 let (viewer_token, viewer_hash) = crate::auth::generate_api_key().unwrap();
5049 let state = Arc::new(AuthState {
5050 api_keys: ArcSwap::from_pointee(vec![
5051 crate::auth::ApiKeyEntry::new("admin-key", admin_hash, "admin"),
5052 crate::auth::ApiKeyEntry::new("viewer-key", viewer_hash, "viewer"),
5053 ]),
5054 rate_limiter: None,
5055 pre_auth_limiter: None,
5056 jwks_cache: None,
5057 seen_identities: crate::auth::SeenIdentitySet::new(),
5058 counters: crate::auth::AuthCounters::default(),
5059 });
5060 (state, admin_token, viewer_token)
5061 }
5062
5063 #[cfg(feature = "oauth")]
5064 fn m6_admin_router(state: &Arc<AuthState>) -> axum::Router {
5065 let proxy = crate::oauth::OAuthProxyConfig::builder(
5066 "https://idp.example/authorize",
5067 "https://idp.example/token",
5068 "client",
5069 )
5070 .introspection_url("http://127.0.0.1:1/introspect")
5071 .revocation_url("http://127.0.0.1:1/revoke")
5072 .expose_admin_endpoints(true)
5073 .require_auth_on_admin_endpoints(true)
5074 .build();
5075 let http = crate::oauth::OauthHttpClient::new().expect("oauth http client");
5076 build_oauth_admin_router(&proxy, http, Some(state), "admin").expect("admin router")
5077 }
5078
5079 #[cfg(feature = "oauth")]
5080 fn m6_req(path: &str, token: &str) -> Request<Body> {
5081 Request::builder()
5082 .method(axum::http::Method::POST)
5083 .uri(path)
5084 .header(header::AUTHORIZATION, format!("Bearer {token}"))
5085 .body(Body::from("token=abc"))
5086 .unwrap()
5087 }
5088
5089 #[cfg(feature = "oauth")]
5090 #[tokio::test]
5091 async fn oauth_proxy_admin_requires_admin_role() {
5092 let (state, _admin, viewer) = m6_auth_state();
5093 for path in ["/introspect", "/revoke"] {
5094 let app = m6_admin_router(&state);
5095 let resp = app.oneshot(m6_req(path, &viewer)).await.unwrap();
5096 assert_eq!(
5097 resp.status(),
5098 StatusCode::FORBIDDEN,
5099 "an authenticated viewer must be rejected with 403 on {path}"
5100 );
5101 }
5102 }
5103
5104 #[cfg(feature = "oauth")]
5105 #[tokio::test]
5106 async fn oauth_proxy_admin_allows_admin_role() {
5107 let (state, admin, _viewer) = m6_auth_state();
5108 for path in ["/introspect", "/revoke"] {
5109 let app = m6_admin_router(&state);
5110 let resp = app.oneshot(m6_req(path, &admin)).await.unwrap();
5111 assert_ne!(
5115 resp.status(),
5116 StatusCode::FORBIDDEN,
5117 "an authenticated admin must pass the role gate on {path}"
5118 );
5119 assert_ne!(
5120 resp.status(),
5121 StatusCode::UNAUTHORIZED,
5122 "an authenticated admin must pass the auth gate on {path}"
5123 );
5124 }
5125 }
5126}