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 async fn from_request_parts(
144 parts: &mut axum::http::request::Parts,
145 _state: &S,
146 ) -> Result<Self, Self::Rejection> {
147 parts.extensions.get::<Self>().copied().ok_or((
148 axum::http::StatusCode::INTERNAL_SERVER_ERROR,
149 "peer address unavailable: not running under rmcp-server-kit serve()",
150 ))
151 }
152}
153
154#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
177#[non_exhaustive]
178pub struct ClientIp {
179 pub ip: IpAddr,
181}
182
183impl ClientIp {
184 #[must_use]
187 pub(crate) const fn new(ip: IpAddr) -> Self {
188 Self { ip }
189 }
190}
191
192#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
197#[serde(rename_all = "kebab-case")]
198#[non_exhaustive]
199pub enum ForwardedHeaderMode {
200 XForwardedFor,
202 Forwarded,
204}
205
206struct ForwardResolver {
209 trusted: Vec<ipnet::IpNet>,
210 mode: ForwardedHeaderMode,
211}
212
213#[derive(Debug, Clone, Default)]
234#[non_exhaustive]
235pub struct SecurityHeadersConfig {
236 pub x_content_type_options: Option<String>,
238 pub x_frame_options: Option<String>,
240 pub cache_control: Option<String>,
242 pub referrer_policy: Option<String>,
244 pub cross_origin_opener_policy: Option<String>,
246 pub cross_origin_resource_policy: Option<String>,
248 pub cross_origin_embedder_policy: Option<String>,
250 pub permissions_policy: Option<String>,
253 pub x_permitted_cross_domain_policies: Option<String>,
255 pub content_security_policy: Option<String>,
258 pub x_dns_prefetch_control: Option<String>,
260 pub strict_transport_security: Option<String>,
265}
266
267#[allow(
269 missing_debug_implementations,
270 reason = "contains callback/trait objects that don't impl Debug"
271)]
272#[allow(
273 clippy::struct_excessive_bools,
274 reason = "server configuration naturally has many boolean feature flags"
275)]
276#[non_exhaustive]
277pub struct McpServerConfig {
278 #[deprecated(
280 since = "0.13.0",
281 note = "use McpServerConfig::new() / with_bind_addr(); direct field access will become pub(crate) in a future major release"
282 )]
283 pub bind_addr: String,
284 #[deprecated(
286 since = "0.13.0",
287 note = "set via McpServerConfig::new(); direct field access will become pub(crate) in a future major release"
288 )]
289 pub name: String,
290 #[deprecated(
292 since = "0.13.0",
293 note = "set via McpServerConfig::new(); direct field access will become pub(crate) in a future major release"
294 )]
295 pub version: String,
296 #[deprecated(
298 since = "0.13.0",
299 note = "use McpServerConfig::with_tls(); direct field access will become pub(crate) in a future major release"
300 )]
301 pub tls_cert_path: Option<PathBuf>,
302 #[deprecated(
304 since = "0.13.0",
305 note = "use McpServerConfig::with_tls(); direct field access will become pub(crate) in a future major release"
306 )]
307 pub tls_key_path: Option<PathBuf>,
308 #[deprecated(
311 since = "0.13.0",
312 note = "use McpServerConfig::with_auth(); direct field access will become pub(crate) in a future major release"
313 )]
314 pub auth: Option<AuthConfig>,
315 #[deprecated(
318 since = "0.13.0",
319 note = "use McpServerConfig::with_rbac(); direct field access will become pub(crate) in a future major release"
320 )]
321 pub rbac: Option<Arc<RbacPolicy>>,
322 #[deprecated(
328 since = "0.13.0",
329 note = "use McpServerConfig::with_allowed_origins(); direct field access will become pub(crate) in a future major release"
330 )]
331 pub allowed_origins: Vec<String>,
332 #[deprecated(
335 since = "0.13.0",
336 note = "use McpServerConfig::with_tool_rate_limit(); direct field access will become pub(crate) in a future major release"
337 )]
338 pub tool_rate_limit: Option<u32>,
339 #[deprecated(
345 since = "1.12.0",
346 note = "use McpServerConfig::with_tool_rate_limit_burst(); direct field access will become pub(crate) in a future major release"
347 )]
348 pub tool_rate_limit_burst: Option<u32>,
349 #[deprecated(
362 since = "1.11.0",
363 note = "use McpServerConfig::with_extra_route_rate_limit(); direct field access will become pub(crate) in a future major release"
364 )]
365 pub extra_route_rate_limit: Option<u32>,
366 #[deprecated(
373 since = "1.12.0",
374 note = "use McpServerConfig::with_extra_route_rate_limit_burst(); direct field access will become pub(crate) in a future major release"
375 )]
376 pub extra_route_rate_limit_burst: Option<u32>,
377 #[deprecated(
390 since = "1.14.0",
391 note = "use McpServerConfig::with_extra_route_rate_limit_exempt_paths(); direct field access will become pub(crate) in a future major release"
392 )]
393 pub extra_route_rate_limit_exempt_paths: Vec<String>,
394 #[deprecated(
402 since = "1.13.0",
403 note = "use McpServerConfig::with_trusted_proxies(); direct field access will become pub(crate) in a future major release"
404 )]
405 pub trusted_proxies: Vec<String>,
406 #[deprecated(
411 since = "1.13.0",
412 note = "use McpServerConfig::with_forwarded_header(); direct field access will become pub(crate) in a future major release"
413 )]
414 pub forwarded_header: Option<ForwardedHeaderMode>,
415 #[deprecated(
418 since = "0.13.0",
419 note = "use McpServerConfig::with_readiness_check(); direct field access will become pub(crate) in a future major release"
420 )]
421 pub readiness_check: Option<ReadinessCheck>,
422 #[deprecated(
425 since = "0.13.0",
426 note = "use McpServerConfig::with_max_request_body(); direct field access will become pub(crate) in a future major release"
427 )]
428 pub max_request_body: usize,
429 #[deprecated(
432 since = "0.13.0",
433 note = "use McpServerConfig::with_request_timeout(); direct field access will become pub(crate) in a future major release"
434 )]
435 pub request_timeout: Duration,
436 #[deprecated(
439 since = "0.13.0",
440 note = "use McpServerConfig::with_shutdown_timeout(); direct field access will become pub(crate) in a future major release"
441 )]
442 pub shutdown_timeout: Duration,
443 #[deprecated(
446 since = "0.13.0",
447 note = "use McpServerConfig::with_session_idle_timeout(); direct field access will become pub(crate) in a future major release"
448 )]
449 pub session_idle_timeout: Duration,
450 #[deprecated(
453 since = "0.13.0",
454 note = "use McpServerConfig::with_sse_keep_alive(); direct field access will become pub(crate) in a future major release"
455 )]
456 pub sse_keep_alive: Duration,
457 #[deprecated(
461 since = "0.13.0",
462 note = "use McpServerConfig::with_reload_callback(); direct field access will become pub(crate) in a future major release"
463 )]
464 pub on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
465 #[deprecated(
472 since = "0.13.0",
473 note = "use McpServerConfig::with_extra_router(); direct field access will become pub(crate) in a future major release"
474 )]
475 pub extra_router: Option<axum::Router>,
476 #[deprecated(
481 since = "0.13.0",
482 note = "use McpServerConfig::with_public_url(); direct field access will become pub(crate) in a future major release"
483 )]
484 pub public_url: Option<String>,
485 #[deprecated(
488 since = "0.13.0",
489 note = "use McpServerConfig::enable_request_header_logging(); direct field access will become pub(crate) in a future major release"
490 )]
491 pub log_request_headers: bool,
492 pub expose_build_metadata: bool,
499 #[deprecated(
502 since = "0.13.0",
503 note = "use McpServerConfig::enable_compression(); direct field access will become pub(crate) in a future major release"
504 )]
505 pub compression_enabled: bool,
506 #[deprecated(
509 since = "0.13.0",
510 note = "use McpServerConfig::enable_compression(); direct field access will become pub(crate) in a future major release"
511 )]
512 pub compression_min_size: u16,
513 #[deprecated(
517 since = "0.13.0",
518 note = "use McpServerConfig::with_max_concurrent_requests(); direct field access will become pub(crate) in a future major release"
519 )]
520 pub max_concurrent_requests: Option<usize>,
521 #[deprecated(
524 since = "0.13.0",
525 note = "use McpServerConfig::enable_admin(); direct field access will become pub(crate) in a future major release"
526 )]
527 pub admin_enabled: bool,
528 #[deprecated(
530 since = "0.13.0",
531 note = "use McpServerConfig::enable_admin(); direct field access will become pub(crate) in a future major release"
532 )]
533 pub admin_role: String,
534 #[cfg(feature = "metrics")]
537 #[deprecated(
538 since = "0.13.0",
539 note = "use McpServerConfig::with_metrics(); direct field access will become pub(crate) in a future major release"
540 )]
541 pub metrics_enabled: bool,
542 #[cfg(feature = "metrics")]
544 #[deprecated(
545 since = "0.13.0",
546 note = "use McpServerConfig::with_metrics(); direct field access will become pub(crate) in a future major release"
547 )]
548 pub metrics_bind: String,
549 #[deprecated(
553 since = "1.5.0",
554 note = "use McpServerConfig::with_security_headers(); direct field access will become pub(crate) in a future major release"
555 )]
556 pub security_headers: SecurityHeadersConfig,
557 #[deprecated(
563 since = "1.9.0",
564 note = "use McpServerConfig::with_tls_handshake_timeout(); direct field access will become pub(crate) in a future major release"
565 )]
566 pub tls_handshake_timeout: Duration,
567 #[deprecated(
574 since = "1.9.0",
575 note = "use McpServerConfig::with_max_concurrent_tls_handshakes(); direct field access will become pub(crate) in a future major release"
576 )]
577 pub max_concurrent_tls_handshakes: usize,
578}
579
580#[allow(
638 missing_debug_implementations,
639 reason = "wraps T which may not implement Debug; manual impl below avoids leaking inner contents into logs"
640)]
641pub struct Validated<T>(T);
642
643impl<T> std::fmt::Debug for Validated<T> {
644 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
645 f.debug_struct("Validated").finish_non_exhaustive()
646 }
647}
648
649impl<T> Validated<T> {
650 #[must_use]
652 pub fn as_inner(&self) -> &T {
653 &self.0
654 }
655
656 #[must_use]
661 pub fn into_inner(self) -> T {
662 self.0
663 }
664}
665
666#[allow(
667 deprecated,
668 reason = "internal builders/validators legitimately read/write the deprecated `pub` fields they were designed to manage"
669)]
670impl McpServerConfig {
671 #[must_use]
679 pub fn new(
680 bind_addr: impl Into<String>,
681 name: impl Into<String>,
682 version: impl Into<String>,
683 ) -> Self {
684 Self {
685 bind_addr: bind_addr.into(),
686 name: name.into(),
687 version: version.into(),
688 tls_cert_path: None,
689 tls_key_path: None,
690 auth: None,
691 rbac: None,
692 allowed_origins: Vec::new(),
693 tool_rate_limit: None,
694 readiness_check: None,
695 max_request_body: 1024 * 1024,
696 request_timeout: Duration::from_mins(2),
697 shutdown_timeout: Duration::from_secs(30),
698 session_idle_timeout: Duration::from_mins(20),
699 sse_keep_alive: Duration::from_secs(15),
700 on_reload_ready: None,
701 extra_router: None,
702 public_url: None,
703 log_request_headers: false,
704 expose_build_metadata: false,
705 compression_enabled: false,
706 compression_min_size: 1024,
707 max_concurrent_requests: None,
708 admin_enabled: false,
709 admin_role: "admin".to_owned(),
710 #[cfg(feature = "metrics")]
711 metrics_enabled: false,
712 #[cfg(feature = "metrics")]
713 metrics_bind: "127.0.0.1:9090".into(),
714 security_headers: SecurityHeadersConfig::default(),
715 tls_handshake_timeout: DEFAULT_TLS_HANDSHAKE_TIMEOUT,
716 max_concurrent_tls_handshakes: DEFAULT_MAX_CONCURRENT_TLS_HANDSHAKES,
717 extra_route_rate_limit: None,
718 tool_rate_limit_burst: None,
719 extra_route_rate_limit_burst: None,
720 extra_route_rate_limit_exempt_paths: Vec::new(),
721 trusted_proxies: Vec::new(),
722 forwarded_header: None,
723 }
724 }
725
726 #[must_use]
736 pub fn with_auth(mut self, auth: AuthConfig) -> Self {
737 self.auth = Some(auth);
738 self
739 }
740
741 #[must_use]
746 pub fn with_security_headers(mut self, headers: SecurityHeadersConfig) -> Self {
747 self.security_headers = headers;
748 self
749 }
750
751 #[must_use]
755 pub fn with_bind_addr(mut self, addr: impl Into<String>) -> Self {
756 self.bind_addr = addr.into();
757 self
758 }
759
760 #[must_use]
763 pub fn with_rbac(mut self, rbac: Arc<RbacPolicy>) -> Self {
764 self.rbac = Some(rbac);
765 self
766 }
767
768 #[must_use]
772 pub fn with_tls(mut self, cert_path: impl Into<PathBuf>, key_path: impl Into<PathBuf>) -> Self {
773 self.tls_cert_path = Some(cert_path.into());
774 self.tls_key_path = Some(key_path.into());
775 self
776 }
777
778 #[must_use]
782 pub fn with_public_url(mut self, url: impl Into<String>) -> Self {
783 self.public_url = Some(url.into());
784 self
785 }
786
787 #[must_use]
791 pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Self
792 where
793 I: IntoIterator<Item = S>,
794 S: Into<String>,
795 {
796 self.allowed_origins = origins.into_iter().map(Into::into).collect();
797 self
798 }
799
800 #[must_use]
813 pub fn with_extra_router(mut self, router: axum::Router) -> Self {
814 self.extra_router = Some(router);
815 self
816 }
817
818 #[must_use]
821 pub fn with_readiness_check(mut self, check: ReadinessCheck) -> Self {
822 self.readiness_check = Some(check);
823 self
824 }
825
826 #[must_use]
829 pub fn with_max_request_body(mut self, bytes: usize) -> Self {
830 self.max_request_body = bytes;
831 self
832 }
833
834 #[must_use]
836 pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
837 self.request_timeout = timeout;
838 self
839 }
840
841 #[must_use]
843 pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
844 self.shutdown_timeout = timeout;
845 self
846 }
847
848 #[must_use]
850 pub fn with_session_idle_timeout(mut self, timeout: Duration) -> Self {
851 self.session_idle_timeout = timeout;
852 self
853 }
854
855 #[must_use]
857 pub fn with_sse_keep_alive(mut self, interval: Duration) -> Self {
858 self.sse_keep_alive = interval;
859 self
860 }
861
862 #[must_use]
866 pub fn with_max_concurrent_requests(mut self, limit: usize) -> Self {
867 self.max_concurrent_requests = Some(limit);
868 self
869 }
870
871 #[must_use]
879 pub fn with_tls_handshake_timeout(mut self, timeout: Duration) -> Self {
880 self.tls_handshake_timeout = timeout;
881 self
882 }
883
884 #[must_use]
893 pub fn with_max_concurrent_tls_handshakes(mut self, limit: usize) -> Self {
894 self.max_concurrent_tls_handshakes = limit;
895 self
896 }
897
898 #[must_use]
901 pub fn with_tool_rate_limit(mut self, per_minute: u32) -> Self {
902 self.tool_rate_limit = Some(per_minute);
903 self
904 }
905
906 #[must_use]
917 pub fn with_extra_route_rate_limit(mut self, per_minute: u32) -> Self {
918 self.extra_route_rate_limit = Some(per_minute);
919 self
920 }
921
922 #[must_use]
927 pub fn with_tool_rate_limit_burst(mut self, burst: u32) -> Self {
928 self.tool_rate_limit_burst = Some(burst);
929 self
930 }
931
932 #[must_use]
938 pub fn with_extra_route_rate_limit_burst(mut self, burst: u32) -> Self {
939 self.extra_route_rate_limit_burst = Some(burst);
940 self
941 }
942
943 #[must_use]
963 pub fn with_extra_route_rate_limit_exempt_paths<I, S>(mut self, paths: I) -> Self
964 where
965 I: IntoIterator<Item = S>,
966 S: Into<String>,
967 {
968 self.extra_route_rate_limit_exempt_paths = paths.into_iter().map(Into::into).collect();
969 self
970 }
971
972 #[must_use]
984 pub fn with_trusted_proxies<I, S>(mut self, proxies: I) -> Self
985 where
986 I: IntoIterator<Item = S>,
987 S: Into<String>,
988 {
989 self.trusted_proxies = proxies.into_iter().map(Into::into).collect();
990 self
991 }
992
993 #[must_use]
998 pub fn with_forwarded_header(mut self, mode: ForwardedHeaderMode) -> Self {
999 self.forwarded_header = Some(mode);
1000 self
1001 }
1002
1003 #[must_use]
1007 pub fn with_reload_callback<F>(mut self, callback: F) -> Self
1008 where
1009 F: FnOnce(ReloadHandle) + Send + 'static,
1010 {
1011 self.on_reload_ready = Some(Box::new(callback));
1012 self
1013 }
1014
1015 #[must_use]
1019 pub fn enable_compression(mut self, min_size: u16) -> Self {
1020 self.compression_enabled = true;
1021 self.compression_min_size = min_size;
1022 self
1023 }
1024
1025 #[must_use]
1030 pub fn enable_admin(mut self, role: impl Into<String>) -> Self {
1031 self.admin_enabled = true;
1032 self.admin_role = role.into();
1033 self
1034 }
1035
1036 #[must_use]
1039 pub fn enable_request_header_logging(mut self) -> Self {
1040 self.log_request_headers = true;
1041 self
1042 }
1043
1044 #[must_use]
1049 pub fn expose_build_metadata(mut self) -> Self {
1050 self.expose_build_metadata = true;
1051 self
1052 }
1053
1054 #[cfg(feature = "metrics")]
1057 #[must_use]
1058 pub fn with_metrics(mut self, bind: impl Into<String>) -> Self {
1059 self.metrics_enabled = true;
1060 self.metrics_bind = bind.into();
1061 self
1062 }
1063
1064 pub fn validate(self) -> Result<Validated<Self>, McpxError> {
1097 self.check()?;
1098 Ok(Validated(self))
1099 }
1100
1101 fn check_burst_knobs(&self) -> Result<(), McpxError> {
1108 if self.tool_rate_limit_burst == Some(0) {
1109 return Err(McpxError::Config(
1110 "tool_rate_limit_burst must be greater than zero".into(),
1111 ));
1112 }
1113 if self.extra_route_rate_limit_burst == Some(0) {
1114 return Err(McpxError::Config(
1115 "extra_route_rate_limit_burst must be greater than zero".into(),
1116 ));
1117 }
1118 if self.tool_rate_limit_burst.is_some() && self.tool_rate_limit.is_none() {
1119 return Err(McpxError::Config(
1120 "tool_rate_limit_burst requires tool_rate_limit to be set".into(),
1121 ));
1122 }
1123 if self.extra_route_rate_limit_burst.is_some() && self.extra_route_rate_limit.is_none() {
1124 return Err(McpxError::Config(
1125 "extra_route_rate_limit_burst requires extra_route_rate_limit to be set".into(),
1126 ));
1127 }
1128 if !self.extra_route_rate_limit_exempt_paths.is_empty()
1129 && self.extra_route_rate_limit.is_none()
1130 {
1131 return Err(McpxError::Config(
1132 "extra_route_rate_limit_exempt_paths requires extra_route_rate_limit to be set"
1133 .into(),
1134 ));
1135 }
1136 for path in &self.extra_route_rate_limit_exempt_paths {
1137 if path.is_empty() || !path.starts_with('/') {
1138 return Err(McpxError::Config(format!(
1139 "extra_route_rate_limit_exempt_paths entries must be non-empty and start with '/': {path:?}"
1140 )));
1141 }
1142 }
1143 if let Some(rl) = self.auth.as_ref().and_then(|a| a.rate_limit.as_ref()) {
1144 if rl.burst == Some(0) {
1145 return Err(McpxError::Config(
1146 "auth rate_limit.burst must be greater than zero".into(),
1147 ));
1148 }
1149 if rl.pre_auth_burst == Some(0) {
1150 return Err(McpxError::Config(
1151 "auth rate_limit.pre_auth_burst must be greater than zero".into(),
1152 ));
1153 }
1154 }
1155 Ok(())
1156 }
1157
1158 fn check_trusted_forwarder(&self) -> Result<(), McpxError> {
1163 for entry in &self.trusted_proxies {
1164 validate_trusted_proxy_entry(entry).map_err(McpxError::Config)?;
1165 }
1166 if self.forwarded_header.is_some() && self.trusted_proxies.is_empty() {
1167 return Err(McpxError::Config(
1168 "forwarded_header requires trusted_proxies to be nonempty".into(),
1169 ));
1170 }
1171 Ok(())
1172 }
1173
1174 fn check(&self) -> Result<(), McpxError> {
1178 if self.admin_enabled {
1182 let auth_enabled = self.auth.as_ref().is_some_and(|a| a.enabled);
1183 if !auth_enabled {
1184 return Err(McpxError::Config(
1185 "admin_enabled=true requires auth to be configured and enabled".into(),
1186 ));
1187 }
1188 }
1189
1190 match (&self.tls_cert_path, &self.tls_key_path) {
1192 (Some(_), None) => {
1193 return Err(McpxError::Config(
1194 "tls_cert_path is set but tls_key_path is missing".into(),
1195 ));
1196 }
1197 (None, Some(_)) => {
1198 return Err(McpxError::Config(
1199 "tls_key_path is set but tls_cert_path is missing".into(),
1200 ));
1201 }
1202 _ => {}
1203 }
1204
1205 if self.bind_addr.parse::<SocketAddr>().is_err() {
1207 return Err(McpxError::Config(format!(
1208 "bind_addr {:?} is not a valid socket address (expected e.g. 127.0.0.1:8080)",
1209 self.bind_addr
1210 )));
1211 }
1212
1213 if let Some(ref url) = self.public_url
1215 && !(url.starts_with("http://") || url.starts_with("https://"))
1216 {
1217 return Err(McpxError::Config(format!(
1218 "public_url {url:?} must start with http:// or https://"
1219 )));
1220 }
1221
1222 for origin in &self.allowed_origins {
1224 if !(origin.starts_with("http://") || origin.starts_with("https://")) {
1225 return Err(McpxError::Config(format!(
1226 "allowed_origins entry {origin:?} must start with http:// or https://"
1227 )));
1228 }
1229 }
1230
1231 if self.max_request_body == 0 {
1233 return Err(McpxError::Config(
1234 "max_request_body must be greater than zero".into(),
1235 ));
1236 }
1237
1238 if self.extra_route_rate_limit == Some(0) {
1242 return Err(McpxError::Config(
1243 "extra_route_rate_limit must be greater than zero".into(),
1244 ));
1245 }
1246
1247 self.check_burst_knobs()?;
1249
1250 self.check_trusted_forwarder()?;
1252
1253 #[cfg(feature = "oauth")]
1255 if let Some(auth_cfg) = &self.auth
1256 && let Some(oauth_cfg) = &auth_cfg.oauth
1257 {
1258 oauth_cfg.validate()?;
1259 }
1260
1261 validate_security_headers(&self.security_headers)?;
1264
1265 if self.max_concurrent_requests == Some(0) {
1269 return Err(McpxError::Config(
1270 "max_concurrent_requests must be greater than zero when set".into(),
1271 ));
1272 }
1273
1274 if let Some(auth_cfg) = &self.auth
1278 && let Some(rl) = &auth_cfg.rate_limit
1279 && rl.max_tracked_keys == 0
1280 {
1281 return Err(McpxError::Config(
1282 "auth.rate_limit.max_tracked_keys must be greater than zero".into(),
1283 ));
1284 }
1285
1286 if self.tls_handshake_timeout == Duration::ZERO {
1291 return Err(McpxError::Config(
1292 "tls_handshake_timeout must be greater than zero".into(),
1293 ));
1294 }
1295
1296 if self.max_concurrent_tls_handshakes == 0 {
1301 return Err(McpxError::Config(
1302 "max_concurrent_tls_handshakes must be greater than zero".into(),
1303 ));
1304 }
1305
1306 Ok(())
1307 }
1308}
1309
1310#[allow(
1316 missing_debug_implementations,
1317 reason = "contains Arc<AuthState> with non-Debug fields"
1318)]
1319pub struct ReloadHandle {
1320 auth: Option<Arc<AuthState>>,
1321 rbac: Option<Arc<ArcSwap<RbacPolicy>>>,
1322 crl_set: Option<Arc<CrlSet>>,
1323}
1324
1325impl ReloadHandle {
1326 pub fn reload_auth_keys(&self, keys: Vec<crate::auth::ApiKeyEntry>) {
1328 if let Some(ref auth) = self.auth {
1329 auth.reload_keys(keys);
1330 }
1331 }
1332
1333 pub fn reload_rbac(&self, policy: RbacPolicy) {
1335 if let Some(ref rbac) = self.rbac {
1336 rbac.store(Arc::new(policy));
1337 tracing::info!("RBAC policy reloaded");
1338 }
1339 }
1340
1341 pub async fn refresh_crls(&self) -> Result<(), McpxError> {
1347 let Some(ref crl_set) = self.crl_set else {
1348 return Err(McpxError::Config(
1349 "CRL refresh requested but mTLS CRL support is not configured".into(),
1350 ));
1351 };
1352
1353 crl_set.force_refresh().await
1354 }
1355}
1356
1357#[allow(
1374 clippy::too_many_lines,
1375 clippy::cognitive_complexity,
1376 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"
1377)]
1378struct AppRunParams {
1382 tls_paths: Option<(PathBuf, PathBuf)>,
1384 tls_handshake_timeout: Duration,
1386 max_concurrent_tls_handshakes: usize,
1388 mtls_config: Option<MtlsConfig>,
1390 shutdown_timeout: Duration,
1392 auth_state: Option<Arc<AuthState>>,
1394 rbac_swap: Arc<ArcSwap<RbacPolicy>>,
1396 on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
1398 ct: CancellationToken,
1402 scheme: &'static str,
1404 name: String,
1406}
1407
1408#[allow(
1418 clippy::cognitive_complexity,
1419 reason = "router assembly is intrinsically sequential; splitting harms readability"
1420)]
1421#[allow(
1422 deprecated,
1423 reason = "internal router assembly reads deprecated `pub` config fields by design until 1.0 makes them pub(crate)"
1424)]
1425fn build_app_router<H, F>(
1426 mut config: McpServerConfig,
1427 handler_factory: F,
1428) -> anyhow::Result<(axum::Router, AppRunParams)>
1429where
1430 H: ServerHandler + 'static,
1431 F: Fn() -> H + Send + Sync + Clone + 'static,
1432{
1433 let ct = CancellationToken::new();
1434
1435 let allowed_hosts = derive_allowed_hosts(&config.bind_addr, config.public_url.as_deref());
1436 tracing::info!(allowed_hosts = %allowed_hosts.join(", "), "configured Streamable HTTP allowed hosts");
1437
1438 let mcp_service = StreamableHttpService::new(
1439 move || Ok(handler_factory()),
1440 {
1441 let mut mgr = LocalSessionManager::default();
1442 mgr.session_config.keep_alive = Some(config.session_idle_timeout);
1443 mgr.into()
1444 },
1445 StreamableHttpServerConfig::default()
1446 .with_allowed_hosts(allowed_hosts)
1447 .with_sse_keep_alive(Some(config.sse_keep_alive))
1448 .with_cancellation_token(ct.child_token()),
1449 );
1450
1451 let mut mcp_router = axum::Router::new().nest_service("/mcp", mcp_service);
1453
1454 let auth_state: Option<Arc<AuthState>> = match config.auth {
1458 Some(ref auth_config) if auth_config.enabled => {
1459 let rate_limiter = auth_config.rate_limit.as_ref().map(build_rate_limiter);
1460 let pre_auth_limiter = auth_config
1461 .rate_limit
1462 .as_ref()
1463 .map(crate::auth::build_pre_auth_limiter);
1464
1465 #[cfg(feature = "oauth")]
1466 let jwks_cache = auth_config
1467 .oauth
1468 .as_ref()
1469 .map(|c| crate::oauth::JwksCache::new(c).map(Arc::new))
1470 .transpose()
1471 .map_err(|e| std::io::Error::other(format!("JWKS HTTP client: {e}")))?;
1472
1473 Some(Arc::new(AuthState {
1474 api_keys: ArcSwap::new(Arc::new(auth_config.api_keys.clone())),
1475 rate_limiter,
1476 pre_auth_limiter,
1477 #[cfg(feature = "oauth")]
1478 jwks_cache,
1479 seen_identities: crate::auth::SeenIdentitySet::new(),
1480 counters: crate::auth::AuthCounters::default(),
1481 }))
1482 }
1483 _ => None,
1484 };
1485
1486 let rbac_swap = Arc::new(ArcSwap::new(
1489 config
1490 .rbac
1491 .clone()
1492 .unwrap_or_else(|| Arc::new(RbacPolicy::disabled())),
1493 ));
1494
1495 if config.admin_enabled {
1498 let Some(ref auth_state_ref) = auth_state else {
1499 return Err(anyhow::anyhow!(
1500 "admin_enabled=true requires auth to be configured and enabled"
1501 ));
1502 };
1503 let admin_state = crate::admin::AdminState {
1504 started_at: std::time::Instant::now(),
1505 name: config.name.clone(),
1506 version: config.version.clone(),
1507 auth: Some(Arc::clone(auth_state_ref)),
1508 rbac: Arc::clone(&rbac_swap),
1509 };
1510 let admin_cfg = crate::admin::AdminConfig {
1511 role: config.admin_role.clone(),
1512 };
1513 mcp_router = mcp_router.merge(crate::admin::admin_router(admin_state, &admin_cfg));
1514 tracing::info!(role = %config.admin_role, "/admin/* endpoints enabled");
1515 }
1516
1517 {
1550 let tool_limiter: Option<Arc<ToolRateLimiter>> = config
1551 .tool_rate_limit
1552 .map(|per_minute| build_tool_rate_limiter(per_minute, config.tool_rate_limit_burst));
1553
1554 if rbac_swap.load().is_enabled() {
1555 tracing::info!("RBAC enforcement enabled on /mcp");
1556 }
1557 if let Some(limit) = config.tool_rate_limit {
1558 tracing::info!(limit, "tool rate limiting enabled (calls/min per IP)");
1559 }
1560
1561 let rbac_for_mw = Arc::clone(&rbac_swap);
1562 mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
1563 let p = rbac_for_mw.load_full();
1564 let tl = tool_limiter.clone();
1565 rbac_middleware(p, tl, req, next)
1566 }));
1567 }
1568
1569 if let Some(ref auth_config) = config.auth
1571 && auth_config.enabled
1572 {
1573 let Some(ref state) = auth_state else {
1574 return Err(anyhow::anyhow!("auth state missing despite enabled config"));
1575 };
1576
1577 let methods: Vec<&str> = [
1578 auth_config.mtls.is_some().then_some("mTLS"),
1579 (!auth_config.api_keys.is_empty()).then_some("bearer"),
1580 #[cfg(feature = "oauth")]
1581 auth_config.oauth.is_some().then_some("oauth-jwt"),
1582 ]
1583 .into_iter()
1584 .flatten()
1585 .collect();
1586
1587 tracing::info!(
1588 methods = %methods.join(", "),
1589 api_keys = auth_config.api_keys.len(),
1590 "auth enabled on /mcp"
1591 );
1592
1593 let state_for_mw = Arc::clone(state);
1594 mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
1595 let s = Arc::clone(&state_for_mw);
1596 auth_middleware(s, req, next)
1597 }));
1598 }
1599
1600 mcp_router = mcp_router.layer(tower_http::timeout::TimeoutLayer::with_status_code(
1603 axum::http::StatusCode::REQUEST_TIMEOUT,
1604 config.request_timeout,
1605 ));
1606
1607 mcp_router = mcp_router.layer(tower_http::limit::RequestBodyLimitLayer::new(
1611 config.max_request_body,
1612 ));
1613
1614 let mut effective_origins = config.allowed_origins.clone();
1621 if effective_origins.is_empty()
1622 && let Some(ref url) = config.public_url
1623 {
1624 if let Some(scheme_end) = url.find("://") {
1629 let scheme_with_sep = url.get(..scheme_end + 3).unwrap_or_default();
1630 let after_scheme = url.get(scheme_end + 3..).unwrap_or_default();
1631 let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
1632 let host = after_scheme.get(..host_end).unwrap_or_default();
1633 let origin = format!("{scheme_with_sep}{host}");
1634 tracing::info!(
1635 %origin,
1636 "auto-derived allowed origin from public_url"
1637 );
1638 effective_origins.push(origin);
1639 }
1640 }
1641 let allowed_origins: Arc<[String]> = Arc::from(effective_origins);
1642 let cors_origins = Arc::clone(&allowed_origins);
1643 let log_request_headers = config.log_request_headers;
1644
1645 let readyz_route = if let Some(check) = config.readiness_check.take() {
1646 axum::routing::get(move || readyz(Arc::clone(&check)))
1647 } else {
1648 axum::routing::get(healthz)
1649 };
1650
1651 #[allow(unused_mut)] let mut router = axum::Router::new()
1653 .route("/healthz", axum::routing::get(healthz))
1654 .route("/readyz", readyz_route)
1655 .route(
1656 "/version",
1657 axum::routing::get({
1658 let payload_bytes: Arc<[u8]> = serialize_version_payload(
1663 &config.name,
1664 &config.version,
1665 config.expose_build_metadata,
1666 );
1667 move || {
1668 let p = Arc::clone(&payload_bytes);
1669 async move {
1670 (
1671 [(axum::http::header::CONTENT_TYPE, "application/json")],
1672 p.to_vec(),
1673 )
1674 }
1675 }
1676 }),
1677 )
1678 .merge(mcp_router);
1679
1680 if let Some(extra) = config.extra_router.take() {
1687 let extra = match config.extra_route_rate_limit {
1688 Some(per_minute) => {
1689 let limiter =
1690 build_extra_route_rate_limiter(per_minute, config.extra_route_rate_limit_burst);
1691 let exempt: Arc<std::collections::HashSet<String>> = Arc::new(
1692 config
1693 .extra_route_rate_limit_exempt_paths
1694 .iter()
1695 .cloned()
1696 .collect(),
1697 );
1698 tracing::info!(
1699 per_minute,
1700 exempt_paths = exempt.len(),
1701 "extra-route per-IP rate limit enabled"
1702 );
1703 extra.layer(axum::middleware::from_fn(move |req, next| {
1704 let l = Arc::clone(&limiter);
1705 let e = Arc::clone(&exempt);
1706 extra_route_rate_limit_middleware(l, e, req, next)
1707 }))
1708 }
1709 None => extra,
1710 };
1711 router = router.merge(extra);
1712 }
1713
1714 let server_url = if let Some(ref url) = config.public_url {
1721 url.trim_end_matches('/').to_owned()
1722 } else {
1723 let prm_scheme = if config.tls_cert_path.is_some() {
1724 "https"
1725 } else {
1726 "http"
1727 };
1728 format!("{prm_scheme}://{}", config.bind_addr)
1729 };
1730 let resource_url = format!("{server_url}/mcp");
1731
1732 #[cfg(feature = "oauth")]
1733 let prm_metadata = if let Some(ref auth_config) = config.auth
1734 && let Some(ref oauth_config) = auth_config.oauth
1735 {
1736 crate::oauth::protected_resource_metadata(&resource_url, &server_url, oauth_config)
1737 } else {
1738 serde_json::json!({ "resource": resource_url })
1739 };
1740 #[cfg(not(feature = "oauth"))]
1741 let prm_metadata = serde_json::json!({ "resource": resource_url });
1742
1743 router = router.route(
1744 "/.well-known/oauth-protected-resource",
1745 axum::routing::get(move || {
1746 let m = prm_metadata.clone();
1747 async move { axum::Json(m) }
1748 }),
1749 );
1750
1751 #[cfg(feature = "oauth")]
1756 if let Some(ref auth_config) = config.auth
1757 && let Some(ref oauth_config) = auth_config.oauth
1758 && oauth_config.proxy.is_some()
1759 {
1760 router = install_oauth_proxy_routes(
1761 router,
1762 &server_url,
1763 oauth_config,
1764 auth_state.as_ref(),
1765 config.max_request_body,
1766 &config.admin_role,
1767 )?;
1768 }
1769
1770 if !cors_origins.is_empty() {
1779 let cors = tower_http::cors::CorsLayer::new()
1780 .allow_origin(
1781 cors_origins
1782 .iter()
1783 .filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
1784 .collect::<Vec<_>>(),
1785 )
1786 .allow_methods([
1787 axum::http::Method::GET,
1788 axum::http::Method::POST,
1789 axum::http::Method::OPTIONS,
1790 ])
1791 .allow_headers([
1792 axum::http::header::CONTENT_TYPE,
1793 axum::http::header::AUTHORIZATION,
1794 ]);
1795 router = router.layer(cors);
1796 }
1797
1798 if config.compression_enabled {
1802 use tower_http::compression::Predicate as _;
1803 let predicate = tower_http::compression::DefaultPredicate::new().and(
1804 tower_http::compression::predicate::SizeAbove::new(u64::from(
1805 config.compression_min_size,
1806 )),
1807 );
1808 router = router.layer(
1809 tower_http::compression::CompressionLayer::new()
1810 .gzip(true)
1811 .br(true)
1812 .compress_when(predicate),
1813 );
1814 tracing::info!(
1815 min_size = config.compression_min_size,
1816 "response compression enabled (gzip, br)"
1817 );
1818 }
1819
1820 if let Some(max) = config.max_concurrent_requests {
1823 let overload_handler = tower::ServiceBuilder::new()
1824 .layer(axum::error_handling::HandleErrorLayer::new(
1825 |_err: tower::BoxError| async {
1826 (
1827 axum::http::StatusCode::SERVICE_UNAVAILABLE,
1828 axum::Json(serde_json::json!({
1829 "error": "overloaded",
1830 "error_description": "server is at capacity, retry later"
1831 })),
1832 )
1833 },
1834 ))
1835 .layer(tower::load_shed::LoadShedLayer::new())
1836 .layer(tower::limit::ConcurrencyLimitLayer::new(max));
1837 router = router.layer(overload_handler);
1838 tracing::info!(max, "global concurrency limit enabled");
1839 }
1840
1841 router = router.fallback(|| async {
1845 (
1846 axum::http::StatusCode::NOT_FOUND,
1847 axum::Json(serde_json::json!({
1848 "error": "not_found",
1849 "error_description": "The requested endpoint does not exist"
1850 })),
1851 )
1852 });
1853
1854 #[cfg(feature = "metrics")]
1856 if config.metrics_enabled {
1857 let metrics = Arc::new(
1858 crate::metrics::McpMetrics::new().map_err(|e| anyhow::anyhow!("metrics init: {e}"))?,
1859 );
1860 let m = Arc::clone(&metrics);
1861 router = router.layer(axum::middleware::from_fn(
1862 move |req: Request<Body>, next: Next| {
1863 let m = Arc::clone(&m);
1864 metrics_middleware(m, req, next)
1865 },
1866 ));
1867 let metrics_bind = config.metrics_bind.clone();
1868 let metrics_shutdown = ct.clone();
1869 tokio::spawn(async move {
1870 if let Err(e) =
1871 crate::metrics::serve_metrics(metrics_bind, metrics, metrics_shutdown).await
1872 {
1873 tracing::error!("metrics listener failed: {e}");
1874 }
1875 });
1876 }
1877
1878 let forward_resolver: Option<Arc<ForwardResolver>> = if config.trusted_proxies.is_empty() {
1886 None
1887 } else {
1888 Some(Arc::new(ForwardResolver {
1891 trusted: config
1892 .trusted_proxies
1893 .iter()
1894 .filter_map(|entry| parse_proxy_net(entry))
1895 .collect(),
1896 mode: config
1897 .forwarded_header
1898 .unwrap_or(ForwardedHeaderMode::XForwardedFor),
1899 }))
1900 };
1901 if forward_resolver.is_some() {
1902 tracing::info!(
1903 proxies = config.trusted_proxies.len(),
1904 "trusted-forwarder mode enabled: limiters key by resolved client IP"
1905 );
1906 }
1907 router = router.layer(axum::middleware::from_fn(move |req, next| {
1908 let r = forward_resolver.clone();
1909 normalize_peer_addr_middleware(r, req, next)
1910 }));
1911
1912 router = router.layer(axum::middleware::from_fn(move |req, next| {
1924 let origins = Arc::clone(&allowed_origins);
1925 origin_check_middleware(origins, log_request_headers, req, next)
1926 }));
1927
1928 let is_tls = config.tls_cert_path.is_some();
1937 let security_headers_cfg = Arc::new(config.security_headers.clone());
1938 router = router.layer(axum::middleware::from_fn(move |req, next| {
1939 let cfg = Arc::clone(&security_headers_cfg);
1940 security_headers_middleware(is_tls, cfg, req, next)
1941 }));
1942
1943 let scheme = if config.tls_cert_path.is_some() {
1944 "https"
1945 } else {
1946 "http"
1947 };
1948
1949 let tls_paths = match (&config.tls_cert_path, &config.tls_key_path) {
1950 (Some(cert), Some(key)) => Some((cert.clone(), key.clone())),
1951 _ => None,
1952 };
1953 let tls_handshake_timeout = config.tls_handshake_timeout;
1954 let max_concurrent_tls_handshakes = config.max_concurrent_tls_handshakes;
1955 let mtls_config = config.auth.as_ref().and_then(|a| a.mtls.as_ref()).cloned();
1956
1957 Ok((
1958 router,
1959 AppRunParams {
1960 tls_paths,
1961 tls_handshake_timeout,
1962 max_concurrent_tls_handshakes,
1963 mtls_config,
1964 shutdown_timeout: config.shutdown_timeout,
1965 auth_state,
1966 rbac_swap,
1967 on_reload_ready: config.on_reload_ready.take(),
1968 ct,
1969 scheme,
1970 name: config.name.clone(),
1971 },
1972 ))
1973}
1974
1975pub async fn serve<H, F>(
1992 config: Validated<McpServerConfig>,
1993 handler_factory: F,
1994) -> Result<(), McpxError>
1995where
1996 H: ServerHandler + 'static,
1997 F: Fn() -> H + Send + Sync + Clone + 'static,
1998{
1999 let config = config.into_inner();
2000 #[allow(
2001 deprecated,
2002 reason = "internal serve() reads `bind_addr` to construct the listener; field becomes pub(crate) in 1.0"
2003 )]
2004 let bind_addr = config.bind_addr.clone();
2005 let (router, params) = build_app_router(config, handler_factory).map_err(anyhow_to_startup)?;
2006
2007 let listener = TcpListener::bind(&bind_addr)
2008 .await
2009 .map_err(|e| io_to_startup(&format!("bind {bind_addr}"), e))?;
2010 log_listening(¶ms.name, params.scheme, &bind_addr);
2011
2012 run_server(
2013 router,
2014 listener,
2015 params.tls_paths,
2016 params.tls_handshake_timeout,
2017 params.max_concurrent_tls_handshakes,
2018 params.mtls_config,
2019 params.shutdown_timeout,
2020 params.auth_state,
2021 params.rbac_swap,
2022 params.on_reload_ready,
2023 params.ct,
2024 )
2025 .await
2026 .map_err(anyhow_to_startup)
2027}
2028
2029pub async fn serve_with_listener<H, F>(
2059 listener: TcpListener,
2060 config: Validated<McpServerConfig>,
2061 handler_factory: F,
2062 ready_tx: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
2063 shutdown: Option<CancellationToken>,
2064) -> Result<(), McpxError>
2065where
2066 H: ServerHandler + 'static,
2067 F: Fn() -> H + Send + Sync + Clone + 'static,
2068{
2069 let config = config.into_inner();
2070 let local_addr = listener
2071 .local_addr()
2072 .map_err(|e| io_to_startup("listener.local_addr", e))?;
2073 let (router, params) = build_app_router(config, handler_factory).map_err(anyhow_to_startup)?;
2074
2075 log_listening(¶ms.name, params.scheme, &local_addr.to_string());
2076
2077 if let Some(external) = shutdown {
2081 let internal = params.ct.clone();
2082 tokio::spawn(async move {
2083 external.cancelled().await;
2084 internal.cancel();
2085 });
2086 }
2087
2088 if let Some(tx) = ready_tx {
2092 let _ = tx.send(local_addr);
2094 }
2095
2096 run_server(
2097 router,
2098 listener,
2099 params.tls_paths,
2100 params.tls_handshake_timeout,
2101 params.max_concurrent_tls_handshakes,
2102 params.mtls_config,
2103 params.shutdown_timeout,
2104 params.auth_state,
2105 params.rbac_swap,
2106 params.on_reload_ready,
2107 params.ct,
2108 )
2109 .await
2110 .map_err(anyhow_to_startup)
2111}
2112
2113#[allow(
2116 clippy::cognitive_complexity,
2117 reason = "tracing::info! macro expansions inflate the score; logic is trivial"
2118)]
2119fn log_listening(name: &str, scheme: &str, addr: &str) {
2120 tracing::info!("{name} listening on {addr}");
2121 tracing::info!(" MCP endpoint: {scheme}://{addr}/mcp");
2122 tracing::info!(" Health check: {scheme}://{addr}/healthz");
2123 tracing::info!(" Readiness: {scheme}://{addr}/readyz");
2124}
2125
2126#[allow(
2149 clippy::too_many_arguments,
2150 clippy::cognitive_complexity,
2151 reason = "server start-up threads TLS, reload state, and graceful shutdown through one flow"
2152)]
2153async fn run_server(
2154 router: axum::Router,
2155 listener: TcpListener,
2156 tls_paths: Option<(PathBuf, PathBuf)>,
2157 tls_handshake_timeout: Duration,
2158 max_concurrent_tls_handshakes: usize,
2159 mtls_config: Option<MtlsConfig>,
2160 shutdown_timeout: Duration,
2161 auth_state: Option<Arc<AuthState>>,
2162 rbac_swap: Arc<ArcSwap<RbacPolicy>>,
2163 mut on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
2164 ct: CancellationToken,
2165) -> anyhow::Result<()> {
2166 let shutdown_trigger = CancellationToken::new();
2170 {
2171 let trigger = shutdown_trigger.clone();
2172 let parent = ct.clone();
2173 tokio::spawn(async move {
2174 tokio::select! {
2177 () = shutdown_signal() => {}
2178 () = parent.cancelled() => {}
2179 }
2180 trigger.cancel();
2181 });
2182 }
2183
2184 let graceful = {
2185 let trigger = shutdown_trigger.clone();
2186 let ct = ct.clone();
2187 async move {
2188 trigger.cancelled().await;
2189 tracing::info!("shutting down (grace period: {shutdown_timeout:?})");
2190 ct.cancel();
2191 }
2192 };
2193
2194 let force_exit_timer = {
2195 let trigger = shutdown_trigger.clone();
2196 async move {
2197 trigger.cancelled().await;
2198 tokio::time::sleep(shutdown_timeout).await;
2199 }
2200 };
2201
2202 if let Some((cert_path, key_path)) = tls_paths {
2203 let crl_set = if let Some(mtls) = mtls_config.as_ref()
2204 && mtls.crl_enabled
2205 {
2206 let (ca_certs, roots) = load_client_auth_roots(&mtls.ca_cert_path)?;
2207 let (crl_set, discover_rx) =
2208 mtls_revocation::bootstrap_fetch(roots, &ca_certs, mtls.clone())
2209 .await
2210 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
2211 tokio::spawn(mtls_revocation::run_crl_refresher(
2212 Arc::clone(&crl_set),
2213 discover_rx,
2214 ct.clone(),
2215 ));
2216 Some(crl_set)
2217 } else {
2218 None
2219 };
2220
2221 if let Some(cb) = on_reload_ready.take() {
2222 cb(ReloadHandle {
2223 auth: auth_state.clone(),
2224 rbac: Some(Arc::clone(&rbac_swap)),
2225 crl_set: crl_set.clone(),
2226 });
2227 }
2228
2229 let tls_listener = TlsListener::new(
2230 listener,
2231 &cert_path,
2232 &key_path,
2233 mtls_config.as_ref(),
2234 crl_set,
2235 tls_handshake_timeout,
2236 max_concurrent_tls_handshakes,
2237 )?;
2238 let make_svc = router.into_make_service_with_connect_info::<TlsConnInfo>();
2239 tokio::select! {
2242 result = axum::serve(tls_listener, make_svc)
2243 .with_graceful_shutdown(graceful) => { result?; }
2244 () = force_exit_timer => {
2245 tracing::warn!("shutdown timeout exceeded, forcing exit");
2246 }
2247 }
2248 } else {
2249 if let Some(cb) = on_reload_ready.take() {
2250 cb(ReloadHandle {
2251 auth: auth_state,
2252 rbac: Some(rbac_swap),
2253 crl_set: None,
2254 });
2255 }
2256
2257 let make_svc = router.into_make_service_with_connect_info::<SocketAddr>();
2258 tokio::select! {
2261 result = axum::serve(listener, make_svc)
2262 .with_graceful_shutdown(graceful) => { result?; }
2263 () = force_exit_timer => {
2264 tracing::warn!("shutdown timeout exceeded, forcing exit");
2265 }
2266 }
2267 }
2268
2269 Ok(())
2270}
2271
2272#[cfg(feature = "oauth")]
2281fn install_oauth_proxy_routes(
2282 router: axum::Router,
2283 server_url: &str,
2284 oauth_config: &crate::oauth::OAuthConfig,
2285 auth_state: Option<&Arc<AuthState>>,
2286 max_request_body: usize,
2287 admin_role: &str,
2288) -> Result<axum::Router, McpxError> {
2289 let Some(ref proxy) = oauth_config.proxy else {
2290 return Ok(router);
2291 };
2292
2293 let http = crate::oauth::OauthHttpClient::with_config(oauth_config)?;
2296
2297 let proxy_router = axum::Router::new();
2303
2304 let asm = crate::oauth::authorization_server_metadata(server_url, oauth_config);
2305 let proxy_router = proxy_router.route(
2306 "/.well-known/oauth-authorization-server",
2307 axum::routing::get(move || {
2308 let m = asm.clone();
2309 async move { axum::Json(m) }
2310 }),
2311 );
2312
2313 let proxy_authorize = proxy.clone();
2314 let proxy_router = proxy_router.route(
2315 "/authorize",
2316 axum::routing::get(
2317 move |axum::extract::RawQuery(query): axum::extract::RawQuery| {
2318 let p = proxy_authorize.clone();
2319 async move { crate::oauth::handle_authorize(&p, &query.unwrap_or_default()) }
2320 },
2321 ),
2322 );
2323
2324 let proxy_token = proxy.clone();
2325 let token_http = http.clone();
2326 let proxy_router = proxy_router.route(
2327 "/token",
2328 axum::routing::post(move |body: String| {
2329 let p = proxy_token.clone();
2330 let h = token_http.clone();
2331 async move { crate::oauth::handle_token(&h, &p, &body).await }
2332 })
2333 .layer(axum::middleware::from_fn(
2334 oauth_token_cache_headers_middleware,
2335 )),
2336 );
2337
2338 let proxy_register = proxy.clone();
2339 let proxy_router = proxy_router.route(
2340 "/register",
2341 axum::routing::post(move |axum::Json(body): axum::Json<serde_json::Value>| {
2342 let p = proxy_register;
2343 async move { axum::Json(crate::oauth::handle_register(&p, &body)) }
2344 })
2345 .layer(axum::middleware::from_fn(
2346 oauth_token_cache_headers_middleware,
2347 )),
2348 );
2349
2350 let admin_routes_enabled = proxy.expose_admin_endpoints
2351 && (proxy.introspection_url.is_some() || proxy.revocation_url.is_some());
2352 if proxy.expose_admin_endpoints
2353 && !proxy.require_auth_on_admin_endpoints
2354 && proxy.allow_unauthenticated_admin_endpoints
2355 {
2356 tracing::warn!(
2360 "OAuth introspect/revoke endpoints are unauthenticated by explicit \
2361 allow_unauthenticated_admin_endpoints opt-out; ensure an \
2362 authenticated reverse proxy fronts these routes"
2363 );
2364 }
2365
2366 let admin_router = if admin_routes_enabled {
2367 build_oauth_admin_router(proxy, http, auth_state, admin_role)?
2368 } else {
2369 axum::Router::new()
2370 };
2371
2372 let proxy_router =
2376 proxy_router
2377 .merge(admin_router)
2378 .layer(tower_http::limit::RequestBodyLimitLayer::new(
2379 max_request_body,
2380 ));
2381
2382 let router = router.merge(proxy_router);
2383
2384 tracing::info!(
2385 introspect = proxy.expose_admin_endpoints && proxy.introspection_url.is_some(),
2386 revoke = proxy.expose_admin_endpoints && proxy.revocation_url.is_some(),
2387 max_request_body,
2388 "OAuth 2.1 proxy endpoints enabled (/authorize, /token, /register)"
2389 );
2390 Ok(router)
2391}
2392
2393#[cfg(feature = "oauth")]
2399fn build_oauth_admin_router(
2400 proxy: &crate::oauth::OAuthProxyConfig,
2401 http: crate::oauth::OauthHttpClient,
2402 auth_state: Option<&Arc<AuthState>>,
2403 admin_role: &str,
2404) -> Result<axum::Router, McpxError> {
2405 let mut admin_router = axum::Router::new();
2406 if proxy.introspection_url.is_some() {
2407 let proxy_introspect = proxy.clone();
2408 let introspect_http = http.clone();
2409 admin_router = admin_router.route(
2410 "/introspect",
2411 axum::routing::post(move |body: String| {
2412 let p = proxy_introspect.clone();
2413 let h = introspect_http.clone();
2414 async move { crate::oauth::handle_introspect(&h, &p, &body).await }
2415 }),
2416 );
2417 }
2418 if proxy.revocation_url.is_some() {
2419 let proxy_revoke = proxy.clone();
2420 let revoke_http = http;
2421 admin_router = admin_router.route(
2422 "/revoke",
2423 axum::routing::post(move |body: String| {
2424 let p = proxy_revoke.clone();
2425 let h = revoke_http.clone();
2426 async move { crate::oauth::handle_revoke(&h, &p, &body).await }
2427 }),
2428 );
2429 }
2430
2431 let admin_router = admin_router.layer(axum::middleware::from_fn(
2432 oauth_token_cache_headers_middleware,
2433 ));
2434
2435 if proxy.require_auth_on_admin_endpoints {
2436 let Some(state) = auth_state else {
2437 return Err(McpxError::Startup(
2438 "oauth proxy admin endpoints require auth state".into(),
2439 ));
2440 };
2441 let state_for_mw = Arc::clone(state);
2442 let required_role: Arc<str> = Arc::from(admin_role);
2443 Ok(admin_router
2449 .layer(axum::middleware::from_fn(move |req, next| {
2450 let r = Arc::clone(&required_role);
2451 crate::admin::require_admin_role(r, req, next)
2452 }))
2453 .layer(axum::middleware::from_fn(move |req, next| {
2454 let s = Arc::clone(&state_for_mw);
2455 auth_middleware(s, req, next)
2456 })))
2457 } else {
2458 Ok(admin_router)
2459 }
2460}
2461
2462fn derive_allowed_hosts(bind_addr: &str, public_url: Option<&str>) -> Vec<String> {
2467 let mut hosts = vec![
2468 "localhost".to_owned(),
2469 "127.0.0.1".to_owned(),
2470 "::1".to_owned(),
2471 ];
2472
2473 if let Some(url) = public_url
2474 && let Ok(uri) = url.parse::<axum::http::Uri>()
2475 && let Some(authority) = uri.authority()
2476 {
2477 let host = authority.host().to_owned();
2478 if !hosts.iter().any(|h| h == &host) {
2479 hosts.push(host);
2480 }
2481
2482 let authority = authority.as_str().to_owned();
2483 if !hosts.iter().any(|h| h == &authority) {
2484 hosts.push(authority);
2485 }
2486 }
2487
2488 if let Ok(uri) = format!("http://{bind_addr}").parse::<axum::http::Uri>()
2489 && let Some(authority) = uri.authority()
2490 {
2491 let host = authority.host().to_owned();
2492 if !hosts.iter().any(|h| h == &host) {
2493 hosts.push(host);
2494 }
2495
2496 let authority = authority.as_str().to_owned();
2497 if !hosts.iter().any(|h| h == &authority) {
2498 hosts.push(authority);
2499 }
2500 }
2501
2502 hosts
2503}
2504
2505impl axum::extract::connect_info::Connected<axum::serve::IncomingStream<'_, TlsListener>>
2518 for TlsConnInfo
2519{
2520 fn connect_info(target: axum::serve::IncomingStream<'_, TlsListener>) -> Self {
2521 let addr = *target.remote_addr();
2522 let identity = target.io().identity().cloned();
2523 Self::new(addr, identity)
2524 }
2525}
2526
2527const DEFAULT_TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
2534
2535const DEFAULT_MAX_CONCURRENT_TLS_HANDSHAKES: usize = 256;
2543
2544const TLS_ACCEPT_CHANNEL_CAPACITY: usize = 32;
2549
2550struct TlsListener {
2566 local_addr: SocketAddr,
2569 rx: mpsc::Receiver<(AuthenticatedTlsStream, SocketAddr)>,
2571 acceptor_task: tokio::task::JoinHandle<()>,
2574}
2575
2576impl TlsListener {
2577 fn new(
2578 inner: TcpListener,
2579 cert_path: &Path,
2580 key_path: &Path,
2581 mtls_config: Option<&MtlsConfig>,
2582 crl_set: Option<Arc<CrlSet>>,
2583 handshake_timeout: Duration,
2584 max_concurrent_handshakes: usize,
2585 ) -> anyhow::Result<Self> {
2586 rustls::crypto::ring::default_provider()
2588 .install_default()
2589 .ok();
2590
2591 let certs = load_certs(cert_path)?;
2592 let key = load_key(key_path)?;
2593
2594 let mtls_default_role;
2595
2596 let tls_config = if let Some(mtls) = mtls_config {
2597 mtls_default_role = mtls.default_role.clone();
2598 let verifier: Arc<dyn rustls::server::danger::ClientCertVerifier> = if mtls.crl_enabled
2599 {
2600 let Some(crl_set) = crl_set else {
2601 return Err(anyhow::anyhow!(
2602 "mTLS CRL verifier requested but CRL state was not initialized"
2603 ));
2604 };
2605 Arc::new(DynamicClientCertVerifier::new(crl_set))
2606 } else {
2607 let (_, root_store) = load_client_auth_roots(&mtls.ca_cert_path)?;
2608 if mtls.required {
2609 rustls::server::WebPkiClientVerifier::builder(root_store)
2610 .build()
2611 .map_err(|e| anyhow::anyhow!("mTLS verifier error: {e}"))?
2612 } else {
2613 rustls::server::WebPkiClientVerifier::builder(root_store)
2614 .allow_unauthenticated()
2615 .build()
2616 .map_err(|e| anyhow::anyhow!("mTLS verifier error: {e}"))?
2617 }
2618 };
2619
2620 tracing::info!(
2621 ca = %mtls.ca_cert_path.display(),
2622 required = mtls.required,
2623 crl_enabled = mtls.crl_enabled,
2624 "mTLS client auth configured"
2625 );
2626
2627 rustls::ServerConfig::builder_with_protocol_versions(&[
2628 &rustls::version::TLS12,
2629 &rustls::version::TLS13,
2630 ])
2631 .with_client_cert_verifier(verifier)
2632 .with_single_cert(certs, key)?
2633 } else {
2634 mtls_default_role = "viewer".to_owned();
2635 rustls::ServerConfig::builder_with_protocol_versions(&[
2636 &rustls::version::TLS12,
2637 &rustls::version::TLS13,
2638 ])
2639 .with_no_client_auth()
2640 .with_single_cert(certs, key)?
2641 };
2642
2643 let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
2644 tracing::info!(
2645 "TLS enabled (cert: {}, key: {})",
2646 cert_path.display(),
2647 key_path.display()
2648 );
2649 let local_addr = inner.local_addr()?;
2650 let (tx, rx) = mpsc::channel(TLS_ACCEPT_CHANNEL_CAPACITY);
2651 let acceptor_task = tokio::spawn(run_tls_acceptor(
2652 inner,
2653 acceptor,
2654 mtls_default_role,
2655 tx,
2656 handshake_timeout,
2657 max_concurrent_handshakes,
2658 ));
2659 Ok(Self {
2660 local_addr,
2661 rx,
2662 acceptor_task,
2663 })
2664 }
2665
2666 fn extract_handshake_identity(
2670 tls_stream: &tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
2671 default_role: &str,
2672 addr: SocketAddr,
2673 ) -> Option<AuthIdentity> {
2674 let (_, server_conn) = tls_stream.get_ref();
2675 let cert_der = server_conn.peer_certificates()?.first()?;
2676 let id = extract_mtls_identity(cert_der.as_ref(), default_role)?;
2677 tracing::debug!(name = %id.name, peer = %addr, "mTLS client cert accepted");
2678 Some(id)
2679 }
2680}
2681
2682async fn run_tls_acceptor(
2690 listener: TcpListener,
2691 acceptor: tokio_rustls::TlsAcceptor,
2692 default_role: String,
2693 tx: mpsc::Sender<(AuthenticatedTlsStream, SocketAddr)>,
2694 handshake_timeout: Duration,
2695 max_concurrent_handshakes: usize,
2696) {
2697 let inflight = Arc::new(Semaphore::new(max_concurrent_handshakes));
2698 loop {
2699 let Ok(permit) = Arc::clone(&inflight).acquire_owned().await else {
2703 return;
2705 };
2706 let (stream, addr) = match listener.accept().await {
2707 Ok(pair) => pair,
2708 Err(e) => {
2709 tracing::debug!("TCP accept error: {e}");
2710 continue;
2711 }
2712 };
2713 if tx.is_closed() {
2714 return;
2716 }
2717 let acceptor = acceptor.clone();
2718 let default_role = default_role.clone();
2719 let tx = tx.clone();
2720 tokio::spawn(async move {
2721 let _permit = permit;
2722 match tokio::time::timeout(handshake_timeout, acceptor.accept(stream)).await {
2723 Ok(Ok(tls_stream)) => {
2724 let identity =
2725 TlsListener::extract_handshake_identity(&tls_stream, &default_role, addr);
2726 let wrapped = AuthenticatedTlsStream {
2727 inner: tls_stream,
2728 identity,
2729 };
2730 let _ = tx.send((wrapped, addr)).await;
2733 }
2734 Ok(Err(e)) => {
2735 tracing::debug!("TLS handshake failed from {addr}: {e}");
2736 }
2737 Err(_elapsed) => {
2738 tracing::debug!(
2739 "TLS handshake timed out from {addr} after {handshake_timeout:?}"
2740 );
2741 }
2742 }
2743 });
2744 }
2745}
2746
2747pub(crate) struct AuthenticatedTlsStream {
2759 inner: tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
2760 identity: Option<AuthIdentity>,
2761}
2762
2763impl AuthenticatedTlsStream {
2764 #[must_use]
2766 pub(crate) const fn identity(&self) -> Option<&AuthIdentity> {
2767 self.identity.as_ref()
2768 }
2769}
2770
2771impl std::fmt::Debug for AuthenticatedTlsStream {
2772 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2773 f.debug_struct("AuthenticatedTlsStream")
2774 .field("identity", &self.identity.as_ref().map(|id| &id.name))
2775 .finish_non_exhaustive()
2776 }
2777}
2778
2779impl tokio::io::AsyncRead for AuthenticatedTlsStream {
2780 fn poll_read(
2781 mut self: Pin<&mut Self>,
2782 cx: &mut std::task::Context<'_>,
2783 buf: &mut tokio::io::ReadBuf<'_>,
2784 ) -> std::task::Poll<std::io::Result<()>> {
2785 Pin::new(&mut self.inner).poll_read(cx, buf)
2786 }
2787}
2788
2789impl tokio::io::AsyncWrite for AuthenticatedTlsStream {
2790 fn poll_write(
2791 mut self: Pin<&mut Self>,
2792 cx: &mut std::task::Context<'_>,
2793 buf: &[u8],
2794 ) -> std::task::Poll<std::io::Result<usize>> {
2795 Pin::new(&mut self.inner).poll_write(cx, buf)
2796 }
2797
2798 fn poll_flush(
2799 mut self: Pin<&mut Self>,
2800 cx: &mut std::task::Context<'_>,
2801 ) -> std::task::Poll<std::io::Result<()>> {
2802 Pin::new(&mut self.inner).poll_flush(cx)
2803 }
2804
2805 fn poll_shutdown(
2806 mut self: Pin<&mut Self>,
2807 cx: &mut std::task::Context<'_>,
2808 ) -> std::task::Poll<std::io::Result<()>> {
2809 Pin::new(&mut self.inner).poll_shutdown(cx)
2810 }
2811
2812 fn poll_write_vectored(
2813 mut self: Pin<&mut Self>,
2814 cx: &mut std::task::Context<'_>,
2815 bufs: &[std::io::IoSlice<'_>],
2816 ) -> std::task::Poll<std::io::Result<usize>> {
2817 Pin::new(&mut self.inner).poll_write_vectored(cx, bufs)
2818 }
2819
2820 fn is_write_vectored(&self) -> bool {
2821 self.inner.is_write_vectored()
2822 }
2823}
2824
2825impl axum::serve::Listener for TlsListener {
2826 type Io = AuthenticatedTlsStream;
2827 type Addr = SocketAddr;
2828
2829 async fn accept(&mut self) -> (Self::Io, Self::Addr) {
2835 if let Some(pair) = self.rx.recv().await {
2836 return pair;
2837 }
2838 tracing::error!("TLS acceptor task terminated; no further connections will be accepted");
2844 std::future::pending().await
2845 }
2846
2847 fn local_addr(&self) -> std::io::Result<Self::Addr> {
2848 Ok(self.local_addr)
2849 }
2850}
2851
2852impl Drop for TlsListener {
2853 fn drop(&mut self) {
2854 self.acceptor_task.abort();
2857 }
2858}
2859
2860fn load_certs(path: &Path) -> anyhow::Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
2861 use rustls::pki_types::pem::PemObject;
2862 let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_file_iter(path)
2863 .map_err(|e| anyhow::anyhow!("failed to read certs from {}: {e}", path.display()))?
2864 .collect::<Result<_, _>>()
2865 .map_err(|e| anyhow::anyhow!("invalid cert in {}: {e}", path.display()))?;
2866 anyhow::ensure!(
2867 !certs.is_empty(),
2868 "no certificates found in {}",
2869 path.display()
2870 );
2871 Ok(certs)
2872}
2873
2874fn load_client_auth_roots(
2875 path: &Path,
2876) -> anyhow::Result<(
2877 Vec<rustls::pki_types::CertificateDer<'static>>,
2878 Arc<RootCertStore>,
2879)> {
2880 let ca_certs = load_certs(path)?;
2881 let mut root_store = RootCertStore::empty();
2882 for cert in &ca_certs {
2883 root_store
2884 .add(cert.clone())
2885 .map_err(|error| anyhow::anyhow!("invalid CA cert: {error}"))?;
2886 }
2887
2888 Ok((ca_certs, Arc::new(root_store)))
2889}
2890
2891fn load_key(path: &Path) -> anyhow::Result<rustls::pki_types::PrivateKeyDer<'static>> {
2892 use rustls::pki_types::pem::PemObject;
2893 rustls::pki_types::PrivateKeyDer::from_pem_file(path)
2894 .map_err(|e| anyhow::anyhow!("failed to read key from {}: {e}", path.display()))
2895}
2896
2897#[allow(
2898 clippy::unused_async,
2899 reason = "axum route handler signature requires `async fn` even when the body is synchronous"
2900)]
2901async fn healthz() -> impl IntoResponse {
2902 axum::Json(serde_json::json!({
2903 "status": "ok",
2904 }))
2905}
2906
2907fn version_payload(name: &str, version: &str, expose_build_metadata: bool) -> serde_json::Value {
2917 let mut map = serde_json::Map::new();
2918 map.insert("name".into(), name.into());
2919 map.insert("version".into(), version.into());
2920 map.insert("mcpx_version".into(), env!("CARGO_PKG_VERSION").into());
2921 if expose_build_metadata {
2922 map.insert(
2923 "build_git_sha".into(),
2924 option_env!("RMCP_SERVER_KIT_BUILD_SHA")
2925 .unwrap_or("unknown")
2926 .into(),
2927 );
2928 map.insert(
2929 "build_timestamp".into(),
2930 option_env!("RMCP_SERVER_KIT_BUILD_TIME")
2931 .unwrap_or("unknown")
2932 .into(),
2933 );
2934 map.insert(
2935 "rust_version".into(),
2936 option_env!("RMCP_SERVER_KIT_RUSTC_VERSION")
2937 .unwrap_or("unknown")
2938 .into(),
2939 );
2940 }
2941 serde_json::Value::Object(map)
2942}
2943
2944fn serialize_version_payload(name: &str, version: &str, expose_build_metadata: bool) -> Arc<[u8]> {
2954 let value = version_payload(name, version, expose_build_metadata);
2955 serde_json::to_vec(&value).map_or_else(|_| Arc::from(&b"{}"[..]), Arc::from)
2956}
2957
2958async fn readyz(check: ReadinessCheck) -> impl IntoResponse {
2959 let status = check().await;
2960 let ready = status
2961 .get("ready")
2962 .and_then(serde_json::Value::as_bool)
2963 .unwrap_or(false);
2964 let code = if ready {
2965 axum::http::StatusCode::OK
2966 } else {
2967 axum::http::StatusCode::SERVICE_UNAVAILABLE
2968 };
2969 (code, axum::Json(status))
2970}
2971
2972async fn shutdown_signal() {
2976 let ctrl_c = tokio::signal::ctrl_c();
2977
2978 #[cfg(unix)]
2979 {
2980 match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
2981 Ok(mut term) => {
2982 tokio::select! {
2985 _ = ctrl_c => {}
2986 _ = term.recv() => {}
2987 }
2988 }
2989 Err(e) => {
2990 tracing::warn!(error = %e, "failed to register SIGTERM handler, using SIGINT only");
2991 ctrl_c.await.ok();
2992 }
2993 }
2994 }
2995
2996 #[cfg(not(unix))]
2997 {
2998 ctrl_c.await.ok();
2999 }
3000}
3001
3002#[cfg(feature = "metrics")]
3013async fn metrics_middleware(
3014 metrics: Arc<crate::metrics::McpMetrics>,
3015 mut req: Request<Body>,
3016 next: Next,
3017) -> axum::response::Response {
3018 let method = req.method().to_string();
3019 let path = req.uri().path().to_owned();
3020 let start = std::time::Instant::now();
3021
3022 req.extensions_mut().insert(Arc::clone(&metrics));
3023 let response = next.run(req).await;
3024
3025 let status = response.status().as_u16().to_string();
3026 let duration = start.elapsed().as_secs_f64();
3027
3028 metrics
3029 .http_requests_total
3030 .with_label_values(&[&method, &path, &status])
3031 .inc();
3032 metrics
3033 .http_request_duration_seconds
3034 .with_label_values(&[&method, &path])
3035 .observe(duration);
3036
3037 response
3038}
3039
3040async fn security_headers_middleware(
3052 is_tls: bool,
3053 cfg: Arc<SecurityHeadersConfig>,
3054 req: Request<Body>,
3055 next: Next,
3056) -> axum::response::Response {
3057 use axum::http::{HeaderName, header};
3058
3059 let mut resp = next.run(req).await;
3060 let headers = resp.headers_mut();
3061
3062 headers.remove(header::SERVER);
3064 headers.remove(HeaderName::from_static("x-powered-by"));
3065
3066 apply_security_header(
3067 headers,
3068 header::X_CONTENT_TYPE_OPTIONS,
3069 cfg.x_content_type_options.as_deref(),
3070 "nosniff",
3071 );
3072 apply_security_header(
3073 headers,
3074 header::X_FRAME_OPTIONS,
3075 cfg.x_frame_options.as_deref(),
3076 "deny",
3077 );
3078 apply_security_header(
3079 headers,
3080 header::CACHE_CONTROL,
3081 cfg.cache_control.as_deref(),
3082 "no-store, max-age=0",
3083 );
3084 apply_security_header(
3085 headers,
3086 header::REFERRER_POLICY,
3087 cfg.referrer_policy.as_deref(),
3088 "no-referrer",
3089 );
3090 apply_security_header(
3091 headers,
3092 HeaderName::from_static("cross-origin-opener-policy"),
3093 cfg.cross_origin_opener_policy.as_deref(),
3094 "same-origin",
3095 );
3096 apply_security_header(
3097 headers,
3098 HeaderName::from_static("cross-origin-resource-policy"),
3099 cfg.cross_origin_resource_policy.as_deref(),
3100 "same-origin",
3101 );
3102 apply_security_header(
3103 headers,
3104 HeaderName::from_static("cross-origin-embedder-policy"),
3105 cfg.cross_origin_embedder_policy.as_deref(),
3106 "require-corp",
3107 );
3108 apply_security_header(
3109 headers,
3110 HeaderName::from_static("permissions-policy"),
3111 cfg.permissions_policy.as_deref(),
3112 "accelerometer=(), camera=(), geolocation=(), microphone=()",
3113 );
3114 apply_security_header(
3115 headers,
3116 HeaderName::from_static("x-permitted-cross-domain-policies"),
3117 cfg.x_permitted_cross_domain_policies.as_deref(),
3118 "none",
3119 );
3120 apply_security_header(
3121 headers,
3122 HeaderName::from_static("content-security-policy"),
3123 cfg.content_security_policy.as_deref(),
3124 "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests",
3125 );
3126 apply_security_header(
3127 headers,
3128 HeaderName::from_static("x-dns-prefetch-control"),
3129 cfg.x_dns_prefetch_control.as_deref(),
3130 "off",
3131 );
3132
3133 if is_tls {
3134 apply_security_header(
3135 headers,
3136 header::STRICT_TRANSPORT_SECURITY,
3137 cfg.strict_transport_security.as_deref(),
3138 "max-age=63072000; includeSubDomains",
3139 );
3140 }
3141
3142 resp
3143}
3144
3145fn apply_security_header(
3156 headers: &mut axum::http::HeaderMap,
3157 name: axum::http::HeaderName,
3158 override_value: Option<&str>,
3159 default: &'static str,
3160) {
3161 use axum::http::HeaderValue;
3162
3163 match override_value {
3164 None => {
3165 headers.insert(name, HeaderValue::from_static(default));
3166 }
3167 Some("") => {
3168 }
3170 Some(v) => match HeaderValue::from_str(v) {
3171 Ok(hv) => {
3172 headers.insert(name, hv);
3173 }
3174 Err(err) => {
3175 tracing::error!(
3176 header = %name,
3177 error = %err,
3178 "invalid security header override reached middleware; using default"
3179 );
3180 headers.insert(name, HeaderValue::from_static(default));
3181 }
3182 },
3183 }
3184}
3185
3186fn validate_security_headers(cfg: &SecurityHeadersConfig) -> Result<(), McpxError> {
3197 use axum::http::HeaderValue;
3198
3199 let fields: &[(&str, Option<&str>)] = &[
3200 (
3201 "x_content_type_options",
3202 cfg.x_content_type_options.as_deref(),
3203 ),
3204 ("x_frame_options", cfg.x_frame_options.as_deref()),
3205 ("cache_control", cfg.cache_control.as_deref()),
3206 ("referrer_policy", cfg.referrer_policy.as_deref()),
3207 (
3208 "cross_origin_opener_policy",
3209 cfg.cross_origin_opener_policy.as_deref(),
3210 ),
3211 (
3212 "cross_origin_resource_policy",
3213 cfg.cross_origin_resource_policy.as_deref(),
3214 ),
3215 (
3216 "cross_origin_embedder_policy",
3217 cfg.cross_origin_embedder_policy.as_deref(),
3218 ),
3219 ("permissions_policy", cfg.permissions_policy.as_deref()),
3220 (
3221 "x_permitted_cross_domain_policies",
3222 cfg.x_permitted_cross_domain_policies.as_deref(),
3223 ),
3224 (
3225 "content_security_policy",
3226 cfg.content_security_policy.as_deref(),
3227 ),
3228 (
3229 "x_dns_prefetch_control",
3230 cfg.x_dns_prefetch_control.as_deref(),
3231 ),
3232 (
3233 "strict_transport_security",
3234 cfg.strict_transport_security.as_deref(),
3235 ),
3236 ];
3237
3238 for (field, value) in fields {
3239 let Some(v) = value else { continue };
3240 if v.is_empty() {
3241 continue;
3242 }
3243 if let Err(err) = HeaderValue::from_str(v) {
3244 return Err(McpxError::Config(format!(
3245 "invalid security_headers.{field}: {err}"
3246 )));
3247 }
3248 }
3249
3250 if let Some(v) = cfg.strict_transport_security.as_deref()
3251 && !v.is_empty()
3252 && v.to_ascii_lowercase().contains("preload")
3253 {
3254 return Err(McpxError::Config(format!(
3255 "invalid security_headers.strict_transport_security: {v:?} contains the `preload` directive; \
3256 HSTS preload must be opted into explicitly via a dedicated builder, not via this knob"
3257 )));
3258 }
3259
3260 Ok(())
3261}
3262
3263#[cfg(feature = "oauth")]
3278async fn oauth_token_cache_headers_middleware(
3279 req: Request<Body>,
3280 next: Next,
3281) -> axum::response::Response {
3282 use axum::http::{HeaderValue, header};
3283
3284 let mut resp = next.run(req).await;
3285 let headers = resp.headers_mut();
3286 headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
3287 headers.append(header::VARY, HeaderValue::from_static("Authorization"));
3288 resp
3289}
3290
3291async fn normalize_peer_addr_middleware(
3320 resolver: Option<Arc<ForwardResolver>>,
3321 mut req: Request<Body>,
3322 next: Next,
3323) -> axum::response::Response {
3324 let direct = req
3325 .extensions()
3326 .get::<ConnectInfo<SocketAddr>>()
3327 .map(|ci| ci.0);
3328 let from_tls = req
3329 .extensions()
3330 .get::<ConnectInfo<TlsConnInfo>>()
3331 .map(|ci| ci.0.addr);
3332 if let Some(addr) = direct.or(from_tls) {
3333 if direct.is_none() {
3334 req.extensions_mut().insert(ConnectInfo(addr));
3335 }
3336 req.extensions_mut().insert(PeerAddr::new(addr));
3337 let client_ip = match &resolver {
3338 Some(r) => {
3339 crate::forwarded::resolve_client_ip(addr.ip(), req.headers(), &r.trusted, r.mode)
3340 .unwrap_or_else(|reason| {
3341 tracing::debug!(
3342 reason = ?reason,
3343 "forwarded-header resolution fell back to direct peer"
3344 );
3345 addr.ip()
3346 })
3347 }
3348 None => addr.ip(),
3349 };
3350 req.extensions_mut().insert(ClientIp::new(client_ip));
3351 }
3352 next.run(req).await
3353}
3354
3355fn parse_proxy_net(entry: &str) -> Option<ipnet::IpNet> {
3358 if let Ok(net) = entry.parse::<ipnet::IpNet>() {
3359 return Some(net);
3360 }
3361 entry.parse::<IpAddr>().ok().map(ipnet::IpNet::from)
3362}
3363
3364pub(crate) fn validate_trusted_proxy_entry(entry: &str) -> Result<(), String> {
3374 match parse_proxy_net(entry) {
3375 None => Err(format!(
3376 "trusted_proxies entry {entry:?} is neither a CIDR nor an IP address"
3377 )),
3378 Some(net) if net.prefix_len() == 0 => Err(format!(
3379 "trusted_proxies entry {entry:?}: prefix length 0 is forbidden (marks every peer trusted, enabling client-IP spoofing)"
3380 )),
3381 Some(_) => Ok(()),
3382 }
3383}
3384
3385pub(crate) fn limiter_client_ip(extensions: &axum::http::Extensions) -> Option<IpAddr> {
3389 if let Some(client) = extensions.get::<ClientIp>() {
3390 return Some(client.ip);
3391 }
3392 extensions
3393 .get::<ConnectInfo<SocketAddr>>()
3394 .map(|ci| ci.0.ip())
3395 .or_else(|| {
3396 extensions
3397 .get::<ConnectInfo<TlsConnInfo>>()
3398 .map(|ci| ci.0.addr.ip())
3399 })
3400}
3401
3402pub(crate) type ExtraRouteRateLimiter = BoundedKeyedLimiter<IpAddr>;
3406
3407const EXTRA_ROUTE_MAX_TRACKED_KEYS: usize = 10_000;
3413
3414const EXTRA_ROUTE_IDLE_EVICTION: Duration = Duration::from_mins(15);
3417
3418fn build_extra_route_rate_limiter(
3425 per_minute: u32,
3426 burst: Option<u32>,
3427) -> Arc<ExtraRouteRateLimiter> {
3428 let rate = std::num::NonZeroU32::new(per_minute.max(1)).unwrap_or(std::num::NonZeroU32::MIN);
3429 let mut quota = governor::Quota::per_minute(rate);
3430 if let Some(b) = burst.and_then(std::num::NonZeroU32::new) {
3431 quota = quota.allow_burst(b);
3432 }
3433 Arc::new(BoundedKeyedLimiter::new(
3434 quota,
3435 EXTRA_ROUTE_MAX_TRACKED_KEYS,
3436 EXTRA_ROUTE_IDLE_EVICTION,
3437 ))
3438}
3439
3440async fn extra_route_rate_limit_middleware(
3462 limiter: Arc<ExtraRouteRateLimiter>,
3463 exempt: Arc<std::collections::HashSet<String>>,
3464 req: Request<Body>,
3465 next: Next,
3466) -> axum::response::Response {
3467 if exempt.contains(req.uri().path()) {
3468 return next.run(req).await;
3469 }
3470 let peer_ip: Option<IpAddr> = limiter_client_ip(req.extensions());
3471 if let Some(ip) = peer_ip
3472 && let Err(wait) = limiter.check_key_wait(&ip)
3473 {
3474 #[cfg(feature = "metrics")]
3475 crate::metrics::record_rate_limit_deny(req.extensions(), "extra_route");
3476 tracing::warn!(%ip, "extra route request rate limited");
3477 return McpxError::RateLimitedFor {
3478 message: "too many requests to application routes from this source".into(),
3479 retry_after: wait,
3480 }
3481 .into_response();
3482 }
3483 next.run(req).await
3484}
3485
3486async fn origin_check_middleware(
3490 allowed: Arc<[String]>,
3491 log_request_headers: bool,
3492 req: Request<Body>,
3493 next: Next,
3494) -> axum::response::Response {
3495 let method = req.method().clone();
3496 let path = req.uri().path().to_owned();
3497
3498 log_incoming_request(&method, &path, req.headers(), log_request_headers);
3499
3500 if let Some(origin) = req.headers().get(axum::http::header::ORIGIN) {
3501 let origin_str = origin.to_str().unwrap_or("");
3502 if !allowed.iter().any(|a| a == origin_str) {
3503 tracing::warn!(
3504 origin = origin_str,
3505 %method,
3506 %path,
3507 allowed = ?&*allowed,
3508 "rejected request: Origin not allowed"
3509 );
3510 return (
3511 axum::http::StatusCode::FORBIDDEN,
3512 "Forbidden: Origin not allowed",
3513 )
3514 .into_response();
3515 }
3516 }
3517 next.run(req).await
3518}
3519
3520fn log_incoming_request(
3523 method: &axum::http::Method,
3524 path: &str,
3525 headers: &axum::http::HeaderMap,
3526 log_request_headers: bool,
3527) {
3528 if log_request_headers {
3529 tracing::debug!(
3530 %method,
3531 %path,
3532 headers = %format_request_headers_for_log(headers),
3533 "incoming request"
3534 );
3535 } else {
3536 tracing::debug!(%method, %path, "incoming request");
3537 }
3538}
3539
3540fn format_request_headers_for_log(headers: &axum::http::HeaderMap) -> String {
3541 headers
3542 .iter()
3543 .map(|(k, v)| {
3544 let name = k.as_str();
3545 if name == "authorization" || name == "cookie" || name == "proxy-authorization" {
3546 format!("{name}: [REDACTED]")
3547 } else {
3548 format!("{name}: {}", v.to_str().unwrap_or("<non-utf8>"))
3549 }
3550 })
3551 .collect::<Vec<_>>()
3552 .join(", ")
3553}
3554
3555#[allow(
3579 clippy::cognitive_complexity,
3580 reason = "complexity is purely tracing macro expansion (info/warn + match arms); 18 lines of straight-line code, nothing meaningful to extract"
3581)]
3582pub async fn serve_stdio<H>(handler: H) -> Result<(), McpxError>
3583where
3584 H: ServerHandler + 'static,
3585{
3586 use rmcp::ServiceExt as _;
3587
3588 tracing::info!("stdio transport: serving on stdin/stdout");
3589 tracing::warn!("stdio mode: auth, RBAC, TLS, and Origin checks are DISABLED");
3590
3591 let transport = rmcp::transport::io::stdio();
3592
3593 let service = handler
3594 .serve(transport)
3595 .await
3596 .map_err(|e| McpxError::Startup(format!("stdio initialize failed: {e}")))?;
3597
3598 if let Err(e) = service.waiting().await {
3599 tracing::warn!(error = %e, "stdio session ended with error");
3600 }
3601 tracing::info!("stdio session ended");
3602 Ok(())
3603}
3604
3605#[cfg(test)]
3606mod tests {
3607 #![allow(
3608 clippy::unwrap_used,
3609 clippy::expect_used,
3610 clippy::panic,
3611 clippy::indexing_slicing,
3612 clippy::unwrap_in_result,
3613 clippy::print_stdout,
3614 clippy::print_stderr,
3615 deprecated,
3616 reason = "internal unit tests legitimately read/write the deprecated `pub` fields they were designed to verify"
3617 )]
3618 use std::{sync::Arc, time::Duration};
3619
3620 use axum::{
3621 body::Body,
3622 http::{Request, StatusCode, header},
3623 response::IntoResponse,
3624 };
3625 use http_body_util::BodyExt;
3626 use tower::ServiceExt as _;
3627
3628 use super::*;
3629
3630 #[test]
3633 fn server_config_new_defaults() {
3634 let cfg = McpServerConfig::new("0.0.0.0:8443", "test-server", "1.0.0");
3635 assert_eq!(cfg.bind_addr, "0.0.0.0:8443");
3636 assert_eq!(cfg.name, "test-server");
3637 assert_eq!(cfg.version, "1.0.0");
3638 assert!(cfg.tls_cert_path.is_none());
3639 assert!(cfg.tls_key_path.is_none());
3640 assert!(cfg.auth.is_none());
3641 assert!(cfg.rbac.is_none());
3642 assert!(cfg.allowed_origins.is_empty());
3643 assert!(cfg.tool_rate_limit.is_none());
3644 assert!(cfg.readiness_check.is_none());
3645 assert_eq!(cfg.max_request_body, 1024 * 1024);
3646 assert_eq!(cfg.request_timeout, Duration::from_mins(2));
3647 assert_eq!(cfg.shutdown_timeout, Duration::from_secs(30));
3648 assert!(!cfg.log_request_headers);
3649 assert_eq!(cfg.tls_handshake_timeout, Duration::from_secs(10));
3650 assert_eq!(cfg.max_concurrent_tls_handshakes, 256);
3651 }
3652
3653 #[test]
3654 fn tls_handshake_builders_set_fields() {
3655 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
3656 .with_tls_handshake_timeout(Duration::from_secs(3))
3657 .with_max_concurrent_tls_handshakes(64);
3658 assert_eq!(cfg.tls_handshake_timeout, Duration::from_secs(3));
3659 assert_eq!(cfg.max_concurrent_tls_handshakes, 64);
3660 }
3661
3662 #[test]
3663 fn validate_rejects_zero_tls_handshake_timeout() {
3664 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
3665 .with_tls_handshake_timeout(Duration::ZERO);
3666 let err = cfg.validate().expect_err("zero handshake timeout");
3667 assert!(err.to_string().contains("tls_handshake_timeout"));
3668 }
3669
3670 #[test]
3671 fn validate_rejects_zero_max_concurrent_tls_handshakes() {
3672 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
3673 .with_max_concurrent_tls_handshakes(0);
3674 let err = cfg.validate().expect_err("zero handshake concurrency");
3675 assert!(err.to_string().contains("max_concurrent_tls_handshakes"));
3676 }
3677
3678 #[test]
3679 fn validate_consumes_and_proves() {
3680 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
3682 let validated = cfg.validate().expect("valid config");
3683 assert_eq!(validated.as_inner().name, "test-server");
3685 let raw = validated.into_inner();
3687 assert_eq!(raw.name, "test-server");
3688
3689 let mut bad = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
3691 bad.max_request_body = 0;
3692 assert!(bad.validate().is_err(), "zero body cap must fail validate");
3693 }
3694
3695 #[test]
3696 fn validate_rejects_zero_max_concurrent_requests() {
3697 let cfg =
3698 McpServerConfig::new("127.0.0.1:8080", "test", "1.0.0").with_max_concurrent_requests(0);
3699 let err = cfg.validate().expect_err("zero concurrency cap must fail");
3700 assert!(
3701 format!("{err}").contains("max_concurrent_requests"),
3702 "error should mention max_concurrent_requests, got: {err}"
3703 );
3704 }
3705
3706 #[test]
3707 fn validate_rejects_zero_max_tracked_keys() {
3708 let rl = crate::auth::RateLimitConfig {
3711 max_attempts_per_minute: 30,
3712 pre_auth_max_per_minute: None,
3713 max_tracked_keys: 0,
3714 idle_eviction: Duration::from_secs(15 * 60),
3715 burst: None,
3716 pre_auth_burst: None,
3717 };
3718 let auth_cfg = AuthConfig {
3719 enabled: true,
3720 api_keys: Vec::new(),
3721 mtls: None,
3722 rate_limit: Some(rl),
3723 #[cfg(feature = "oauth")]
3724 oauth: None,
3725 };
3726 let cfg = McpServerConfig::new("127.0.0.1:8080", "test", "1.0.0").with_auth(auth_cfg);
3727 let err = cfg.validate().expect_err("zero max_tracked_keys must fail");
3728 assert!(
3729 format!("{err}").contains("max_tracked_keys"),
3730 "error should mention max_tracked_keys, got: {err}"
3731 );
3732 }
3733
3734 #[test]
3735 fn derive_allowed_hosts_includes_public_host() {
3736 let hosts = derive_allowed_hosts("0.0.0.0:8080", Some("https://mcp.example.com/mcp"));
3737 assert!(
3738 hosts.iter().any(|h| h == "mcp.example.com"),
3739 "public_url host must be allowed"
3740 );
3741 }
3742
3743 #[test]
3744 fn derive_allowed_hosts_includes_bind_authority() {
3745 let hosts = derive_allowed_hosts("127.0.0.1:8080", None);
3746 assert!(
3747 hosts.iter().any(|h| h == "127.0.0.1"),
3748 "bind host must be allowed"
3749 );
3750 assert!(
3751 hosts.iter().any(|h| h == "127.0.0.1:8080"),
3752 "bind authority must be allowed"
3753 );
3754 }
3755
3756 #[tokio::test]
3759 async fn healthz_returns_ok_json() {
3760 let resp = healthz().await.into_response();
3761 assert_eq!(resp.status(), StatusCode::OK);
3762 let body = resp.into_body().collect().await.unwrap().to_bytes();
3763 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3764 assert_eq!(json["status"], "ok");
3765 assert!(
3766 json.get("name").is_none(),
3767 "healthz must not expose server name"
3768 );
3769 assert!(
3770 json.get("version").is_none(),
3771 "healthz must not expose version"
3772 );
3773 }
3774
3775 #[tokio::test]
3778 async fn readyz_returns_ok_when_ready() {
3779 let check: ReadinessCheck =
3780 Arc::new(|| Box::pin(async { serde_json::json!({"ready": true, "db": "connected"}) }));
3781 let resp = readyz(check).await.into_response();
3782 assert_eq!(resp.status(), StatusCode::OK);
3783 let body = resp.into_body().collect().await.unwrap().to_bytes();
3784 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
3785 assert_eq!(json["ready"], true);
3786 assert!(
3787 json.get("name").is_none(),
3788 "readyz must not expose server name"
3789 );
3790 assert!(
3791 json.get("version").is_none(),
3792 "readyz must not expose version"
3793 );
3794 assert_eq!(json["db"], "connected");
3795 }
3796
3797 #[tokio::test]
3798 async fn readyz_returns_503_when_not_ready() {
3799 let check: ReadinessCheck =
3800 Arc::new(|| Box::pin(async { serde_json::json!({"ready": false}) }));
3801 let resp = readyz(check).await.into_response();
3802 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
3803 }
3804
3805 #[tokio::test]
3806 async fn readyz_returns_503_when_ready_missing() {
3807 let check: ReadinessCheck =
3808 Arc::new(|| Box::pin(async { serde_json::json!({"status": "starting"}) }));
3809 let resp = readyz(check).await.into_response();
3810 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
3812 }
3813
3814 fn peer_probe_router() -> axum::Router {
3819 async fn probe(req: Request<Body>) -> String {
3820 let ci = req
3821 .extensions()
3822 .get::<ConnectInfo<SocketAddr>>()
3823 .map(|c| c.0.to_string())
3824 .unwrap_or_default();
3825 let pa = req
3826 .extensions()
3827 .get::<PeerAddr>()
3828 .map(|p| p.addr.to_string())
3829 .unwrap_or_default();
3830 format!("{ci}|{pa}")
3831 }
3832 axum::Router::new()
3833 .route("/probe", axum::routing::get(probe))
3834 .layer(axum::middleware::from_fn(|req, next| {
3835 normalize_peer_addr_middleware(None, req, next)
3836 }))
3837 }
3838
3839 async fn body_string(resp: axum::response::Response) -> String {
3840 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
3841 String::from_utf8(bytes.to_vec()).unwrap()
3842 }
3843
3844 #[tokio::test]
3845 async fn normalize_preserves_existing_connect_info_and_mirrors_peer_addr() {
3846 let plain: SocketAddr = "10.0.0.1:1111".parse().unwrap();
3849 let tls: SocketAddr = "10.0.0.2:2222".parse().unwrap();
3850 let req = Request::builder()
3851 .uri("/probe")
3852 .extension(ConnectInfo(plain))
3853 .extension(ConnectInfo(TlsConnInfo::new(tls, None)))
3854 .body(Body::empty())
3855 .unwrap();
3856 let resp = peer_probe_router().oneshot(req).await.unwrap();
3857 assert_eq!(resp.status(), StatusCode::OK);
3858 assert_eq!(body_string(resp).await, format!("{plain}|{plain}"));
3859 }
3860
3861 #[tokio::test]
3862 async fn normalize_inserts_connect_info_and_peer_addr_from_tls() {
3863 let tls: SocketAddr = "192.168.1.7:50443".parse().unwrap();
3864 let req = Request::builder()
3865 .uri("/probe")
3866 .extension(ConnectInfo(TlsConnInfo::new(tls, None)))
3867 .body(Body::empty())
3868 .unwrap();
3869 let resp = peer_probe_router().oneshot(req).await.unwrap();
3870 assert_eq!(resp.status(), StatusCode::OK);
3871 assert_eq!(body_string(resp).await, format!("{tls}|{tls}"));
3872 }
3873
3874 #[tokio::test]
3875 async fn normalize_no_op_without_any_connect_info() {
3876 let req = Request::builder()
3877 .uri("/probe")
3878 .body(Body::empty())
3879 .unwrap();
3880 let resp = peer_probe_router().oneshot(req).await.unwrap();
3881 assert_eq!(resp.status(), StatusCode::OK);
3882 assert_eq!(body_string(resp).await, "|");
3883 }
3884
3885 #[tokio::test]
3886 async fn peer_addr_extractor_rejects_when_absent() {
3887 async fn h(peer: PeerAddr) -> String {
3888 peer.addr.to_string()
3889 }
3890 let app = axum::Router::new().route("/p", axum::routing::get(h));
3891 let req = Request::builder().uri("/p").body(Body::empty()).unwrap();
3892 let resp = app.oneshot(req).await.unwrap();
3893 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
3894 }
3895
3896 #[tokio::test]
3897 async fn peer_addr_extractor_returns_value_when_present() {
3898 async fn h(peer: PeerAddr) -> String {
3899 peer.addr.to_string()
3900 }
3901 let addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
3902 let app = axum::Router::new().route("/p", axum::routing::get(h));
3903 let req = Request::builder()
3904 .uri("/p")
3905 .extension(PeerAddr::new(addr))
3906 .body(Body::empty())
3907 .unwrap();
3908 let resp = app.oneshot(req).await.unwrap();
3909 assert_eq!(resp.status(), StatusCode::OK);
3910 assert_eq!(body_string(resp).await, addr.to_string());
3911 }
3912
3913 #[tokio::test]
3914 async fn peer_addr_via_extension_extractor() {
3915 async fn h(axum::Extension(peer): axum::Extension<PeerAddr>) -> String {
3916 peer.addr.to_string()
3917 }
3918 let addr: SocketAddr = "127.0.0.1:4242".parse().unwrap();
3919 let app = axum::Router::new().route("/p", axum::routing::get(h));
3920 let req = Request::builder()
3921 .uri("/p")
3922 .extension(PeerAddr::new(addr))
3923 .body(Body::empty())
3924 .unwrap();
3925 let resp = app.oneshot(req).await.unwrap();
3926 assert_eq!(resp.status(), StatusCode::OK);
3927 assert_eq!(body_string(resp).await, addr.to_string());
3928 }
3929
3930 fn limited_router(per_minute: u32) -> axum::Router {
3935 limited_router_with_burst(per_minute, None)
3936 }
3937
3938 fn limited_router_with_burst(per_minute: u32, burst: Option<u32>) -> axum::Router {
3940 limited_router_full(per_minute, burst, &[])
3941 }
3942
3943 fn limited_router_full(
3947 per_minute: u32,
3948 burst: Option<u32>,
3949 exempt_paths: &[&str],
3950 ) -> axum::Router {
3951 let limiter = build_extra_route_rate_limiter(per_minute, burst);
3952 let exempt: Arc<std::collections::HashSet<String>> =
3953 Arc::new(exempt_paths.iter().map(|s| (*s).to_owned()).collect());
3954 axum::Router::new()
3955 .route("/limited", axum::routing::get(|| async { "ok" }))
3956 .route("/exempt", axum::routing::get(|| async { "ok" }))
3957 .layer(axum::middleware::from_fn(move |req, next| {
3958 let l = Arc::clone(&limiter);
3959 let e = Arc::clone(&exempt);
3960 extra_route_rate_limit_middleware(l, e, req, next)
3961 }))
3962 }
3963
3964 fn limited_req(ip: &str) -> Request<Body> {
3965 limited_req_to(ip, "/limited")
3966 }
3967
3968 fn limited_req_to(ip: &str, path: &str) -> Request<Body> {
3969 let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
3970 Request::builder()
3971 .uri(path)
3972 .extension(ConnectInfo(addr))
3973 .body(Body::empty())
3974 .unwrap()
3975 }
3976
3977 #[tokio::test]
3978 async fn extra_route_limiter_denies_over_quota() {
3979 let app = limited_router(2);
3980 for i in 0..2 {
3981 let resp = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
3982 assert_eq!(resp.status(), StatusCode::OK, "request {i} should pass");
3983 }
3984 let resp = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
3985 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
3986 let body = body_string(resp).await;
3987 assert!(
3988 body.contains("too many requests to application routes"),
3989 "deny body should match the limiter message, got: {body}"
3990 );
3991 }
3992
3993 #[tokio::test]
3994 async fn extra_route_limiter_isolates_keys() {
3995 let app = limited_router(2);
3996 for _ in 0..2 {
3997 let resp = app.clone().oneshot(limited_req("10.2.2.2")).await.unwrap();
3998 assert_eq!(resp.status(), StatusCode::OK);
3999 }
4000 let exhausted = app.clone().oneshot(limited_req("10.2.2.2")).await.unwrap();
4001 assert_eq!(exhausted.status(), StatusCode::TOO_MANY_REQUESTS);
4002 let other = app.clone().oneshot(limited_req("10.3.3.3")).await.unwrap();
4004 assert_eq!(other.status(), StatusCode::OK);
4005 }
4006
4007 #[tokio::test]
4008 async fn extra_route_limiter_fails_open_without_peer() {
4009 let app = limited_router(1);
4010 for i in 0..3 {
4011 let req = Request::builder()
4012 .uri("/limited")
4013 .body(Body::empty())
4014 .unwrap();
4015 let resp = app.clone().oneshot(req).await.unwrap();
4016 assert_eq!(
4017 resp.status(),
4018 StatusCode::OK,
4019 "request {i} should fail open"
4020 );
4021 }
4022 }
4023
4024 #[tokio::test]
4025 async fn extra_route_limiter_extracts_tls_conn_info() {
4026 let app = limited_router(2);
4027 let mk = || {
4028 let addr: SocketAddr = "192.168.9.9:55555".parse().unwrap();
4029 Request::builder()
4030 .uri("/limited")
4031 .extension(ConnectInfo(TlsConnInfo::new(addr, None)))
4032 .body(Body::empty())
4033 .unwrap()
4034 };
4035 for _ in 0..2 {
4036 assert_eq!(
4037 app.clone().oneshot(mk()).await.unwrap().status(),
4038 StatusCode::OK
4039 );
4040 }
4041 let resp = app.clone().oneshot(mk()).await.unwrap();
4042 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
4043 }
4044
4045 #[tokio::test]
4046 async fn extra_route_limiter_exempt_path_bypasses_quota() {
4047 let app = limited_router_full(1, None, &["/exempt"]);
4050 for i in 0..5 {
4051 let resp = app
4052 .clone()
4053 .oneshot(limited_req_to("10.6.6.6", "/exempt"))
4054 .await
4055 .unwrap();
4056 assert_eq!(resp.status(), StatusCode::OK, "exempt request {i}");
4057 }
4058 let resp = app.clone().oneshot(limited_req("10.6.6.6")).await.unwrap();
4060 assert_eq!(resp.status(), StatusCode::OK);
4061 let resp = app.clone().oneshot(limited_req("10.6.6.6")).await.unwrap();
4063 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
4064 }
4065
4066 #[tokio::test]
4067 async fn extra_route_limiter_exemption_is_raw_exact_match() {
4068 let app = limited_router_full(1, None, &["/exempt"]);
4071 let ok = app
4072 .clone()
4073 .oneshot(limited_req_to("10.7.7.7", "/exempt/"))
4074 .await
4075 .unwrap();
4076 assert_eq!(
4077 ok.status(),
4078 StatusCode::NOT_FOUND,
4079 "variant path routes 404"
4080 );
4081 let denied = app
4083 .clone()
4084 .oneshot(limited_req_to("10.7.7.7", "/limited"))
4085 .await
4086 .unwrap();
4087 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
4088 }
4089
4090 #[cfg(feature = "metrics")]
4091 #[tokio::test]
4092 async fn extra_route_limiter_deny_increments_counter_exempt_does_not() {
4093 let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
4094 let app = limited_router_full(1, None, &["/exempt"]);
4095 let mk = |path: &str| {
4096 let addr: SocketAddr = "10.8.8.8:40000".parse().unwrap();
4097 Request::builder()
4098 .uri(path)
4099 .extension(ConnectInfo(addr))
4100 .extension(Arc::clone(&metrics))
4101 .body(Body::empty())
4102 .unwrap()
4103 };
4104 let counter = || {
4105 metrics
4106 .rate_limited_total
4107 .with_label_values(&["extra_route"])
4108 .get()
4109 };
4110 for _ in 0..3 {
4112 assert_eq!(
4113 app.clone().oneshot(mk("/exempt")).await.unwrap().status(),
4114 StatusCode::OK
4115 );
4116 }
4117 assert_eq!(counter(), 0, "exempt requests must not count as denies");
4118 assert_eq!(
4120 app.clone().oneshot(mk("/limited")).await.unwrap().status(),
4121 StatusCode::OK
4122 );
4123 assert_eq!(counter(), 0);
4124 assert_eq!(
4125 app.clone().oneshot(mk("/limited")).await.unwrap().status(),
4126 StatusCode::TOO_MANY_REQUESTS
4127 );
4128 assert_eq!(counter(), 1, "deny must increment the extra_route label");
4129 }
4130
4131 #[test]
4132 fn validate_rejects_exempt_paths_without_base_knob() {
4133 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4134 .with_extra_route_rate_limit_exempt_paths(["/ok"]);
4135 let err = cfg.validate().expect_err("exempt paths without rate limit");
4136 assert!(err.to_string().contains("requires extra_route_rate_limit"));
4137 }
4138
4139 #[test]
4140 fn validate_rejects_malformed_exempt_paths() {
4141 for bad in ["", "no-slash"] {
4142 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4143 .with_extra_route_rate_limit(10)
4144 .with_extra_route_rate_limit_exempt_paths([bad]);
4145 let err = cfg.validate().expect_err("malformed exempt path");
4146 assert!(
4147 err.to_string()
4148 .contains("must be non-empty and start with '/'"),
4149 "entry {bad:?}: {err}"
4150 );
4151 }
4152 }
4153
4154 #[test]
4155 fn validate_accepts_wellformed_exempt_paths() {
4156 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4157 .with_extra_route_rate_limit(10)
4158 .with_extra_route_rate_limit_exempt_paths(["/.well-known/oauth-authorization-server"]);
4159 assert!(cfg.validate().is_ok());
4160 }
4161
4162 #[test]
4163 fn validate_rejects_zero_extra_route_rate_limit() {
4164 let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4165 .with_extra_route_rate_limit(0);
4166 let err = cfg.validate().expect_err("zero extra route rate limit");
4167 assert!(err.to_string().contains("extra_route_rate_limit"));
4168 }
4169
4170 #[tokio::test]
4171 async fn extra_route_limiter_burst_allows_initial_spike() {
4172 let app = limited_router_with_burst(1, Some(3));
4173 for i in 0..3 {
4174 let resp = app.clone().oneshot(limited_req("10.4.4.4")).await.unwrap();
4175 assert_eq!(resp.status(), StatusCode::OK, "burst request {i}");
4176 }
4177 let resp = app.clone().oneshot(limited_req("10.4.4.4")).await.unwrap();
4178 assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
4179 }
4180
4181 #[tokio::test]
4182 async fn extra_route_limiter_deny_sets_retry_after() {
4183 let app = limited_router(1);
4184 let ok = app.clone().oneshot(limited_req("10.5.5.5")).await.unwrap();
4185 assert_eq!(ok.status(), StatusCode::OK);
4186 let denied = app.clone().oneshot(limited_req("10.5.5.5")).await.unwrap();
4187 assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
4188 let retry_after = denied
4189 .headers()
4190 .get(header::RETRY_AFTER)
4191 .expect("Retry-After present")
4192 .to_str()
4193 .unwrap()
4194 .parse::<u64>()
4195 .unwrap();
4196 assert!(retry_after >= 1, "delta-seconds must be >= 1");
4197 }
4198
4199 #[test]
4200 fn validate_rejects_zero_burst_knobs() {
4201 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4202 .with_tool_rate_limit(10)
4203 .with_tool_rate_limit_burst(0)
4204 .validate()
4205 .expect_err("zero tool burst");
4206 assert!(err.to_string().contains("tool_rate_limit_burst"));
4207
4208 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4209 .with_extra_route_rate_limit(10)
4210 .with_extra_route_rate_limit_burst(0)
4211 .validate()
4212 .expect_err("zero extra route burst");
4213 assert!(err.to_string().contains("extra_route_rate_limit_burst"));
4214 }
4215
4216 #[test]
4217 fn validate_rejects_orphan_burst_knobs() {
4218 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4219 .with_tool_rate_limit_burst(5)
4220 .validate()
4221 .expect_err("orphan tool burst");
4222 assert!(err.to_string().contains("requires tool_rate_limit"));
4223
4224 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4225 .with_extra_route_rate_limit_burst(5)
4226 .validate()
4227 .expect_err("orphan extra route burst");
4228 assert!(err.to_string().contains("requires extra_route_rate_limit"));
4229 }
4230
4231 #[test]
4232 fn validate_rejects_zero_auth_bursts() {
4233 let auth = AuthConfig::with_keys(vec![])
4234 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_burst(0));
4235 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4236 .with_auth(auth)
4237 .validate()
4238 .expect_err("zero auth burst");
4239 assert!(err.to_string().contains("rate_limit.burst"));
4240
4241 let auth = AuthConfig::with_keys(vec![])
4242 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(0));
4243 let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4244 .with_auth(auth)
4245 .validate()
4246 .expect_err("zero pre-auth burst");
4247 assert!(err.to_string().contains("pre_auth_burst"));
4248 }
4249
4250 #[test]
4253 fn validate_accepts_pre_auth_burst_without_explicit_pre_auth_rate() {
4254 let auth = AuthConfig::with_keys(vec![])
4255 .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(50));
4256 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
4257 assert!(cfg.validate().is_ok(), "pre_auth_burst has no orphan rule");
4258 }
4259
4260 fn forward_resolver(trusted: &[&str], mode: ForwardedHeaderMode) -> Arc<ForwardResolver> {
4263 Arc::new(ForwardResolver {
4264 trusted: trusted.iter().map(|s| s.parse().unwrap()).collect(),
4265 mode,
4266 })
4267 }
4268
4269 fn forwarded_probe_router(resolver: Option<Arc<ForwardResolver>>) -> axum::Router {
4271 async fn probe(req: Request<Body>) -> String {
4272 let pa = req
4273 .extensions()
4274 .get::<PeerAddr>()
4275 .map(|p| p.addr.ip().to_string())
4276 .unwrap_or_default();
4277 let ci = req
4278 .extensions()
4279 .get::<ClientIp>()
4280 .map(|c| c.ip.to_string())
4281 .unwrap_or_default();
4282 format!("{pa}|{ci}")
4283 }
4284 axum::Router::new()
4285 .route("/probe", axum::routing::get(probe))
4286 .layer(axum::middleware::from_fn(move |req, next| {
4287 let r = resolver.clone();
4288 normalize_peer_addr_middleware(r, req, next)
4289 }))
4290 }
4291
4292 fn probe_req(peer: &str, header: Option<(&str, &str)>) -> Request<Body> {
4293 let addr: SocketAddr = peer.parse().unwrap();
4294 let mut builder = Request::builder()
4295 .uri("/probe")
4296 .extension(ConnectInfo(addr));
4297 if let Some((name, value)) = header {
4298 builder = builder.header(name, value);
4299 }
4300 builder.body(Body::empty()).unwrap()
4301 }
4302
4303 #[tokio::test]
4304 async fn client_ip_equals_direct_without_resolver() {
4305 let app = forwarded_probe_router(None);
4306 let resp = app
4307 .oneshot(probe_req(
4308 "10.1.2.3:4444",
4309 Some(("x-forwarded-for", "203.0.113.7")),
4310 ))
4311 .await
4312 .unwrap();
4313 assert_eq!(
4314 body_string(resp).await,
4315 "10.1.2.3|10.1.2.3",
4316 "feature off: header ignored, ClientIp == direct"
4317 );
4318 }
4319
4320 #[tokio::test]
4321 async fn client_ip_resolved_for_trusted_peer() {
4322 let app = forwarded_probe_router(Some(forward_resolver(
4323 &["10.0.0.0/8"],
4324 ForwardedHeaderMode::XForwardedFor,
4325 )));
4326 let resp = app
4327 .oneshot(probe_req(
4328 "10.0.0.1:9999",
4329 Some(("x-forwarded-for", "203.0.113.7")),
4330 ))
4331 .await
4332 .unwrap();
4333 assert_eq!(
4334 body_string(resp).await,
4335 "10.0.0.1|203.0.113.7",
4336 "PeerAddr stays direct while ClientIp resolves"
4337 );
4338 }
4339
4340 #[tokio::test]
4341 async fn client_ip_falls_back_to_direct_on_malformed_header() {
4342 let app = forwarded_probe_router(Some(forward_resolver(
4343 &["10.0.0.0/8"],
4344 ForwardedHeaderMode::XForwardedFor,
4345 )));
4346 let resp = app
4347 .oneshot(probe_req(
4348 "10.0.0.1:9999",
4349 Some(("x-forwarded-for", "not-an-ip")),
4350 ))
4351 .await
4352 .unwrap();
4353 assert_eq!(
4354 body_string(resp).await,
4355 "10.0.0.1|10.0.0.1",
4356 "malformed chain falls back to the direct peer"
4357 );
4358 }
4359
4360 #[test]
4361 fn forwarded_header_mode_deserializes_kebab_case() {
4362 #[derive(serde::Deserialize)]
4363 struct Wrapper {
4364 mode: ForwardedHeaderMode,
4365 }
4366 let w: Wrapper = toml::from_str(r#"mode = "x-forwarded-for""#).unwrap();
4367 assert_eq!(w.mode, ForwardedHeaderMode::XForwardedFor);
4368 let w: Wrapper = toml::from_str(r#"mode = "forwarded""#).unwrap();
4369 assert_eq!(w.mode, ForwardedHeaderMode::Forwarded);
4370 assert!(
4371 toml::from_str::<Wrapper>(r#"mode = "XForwardedFor""#).is_err(),
4372 "PascalCase wire value must be rejected"
4373 );
4374 }
4375
4376 #[test]
4377 fn validate_rejects_bad_trusted_proxy_entry() {
4378 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4379 .with_trusted_proxies(["not-a-cidr"]);
4380 let err = cfg.validate().expect_err("bad CIDR");
4381 assert!(err.to_string().contains("trusted_proxies"));
4382 }
4383
4384 #[test]
4385 fn validate_rejects_zero_prefix_trusted_proxy() {
4386 for entry in ["0.0.0.0/0", "::/0"] {
4387 let cfg =
4388 McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_trusted_proxies([entry]);
4389 let err = cfg.validate().expect_err("zero-prefix CIDR");
4390 assert!(
4391 err.to_string().contains("prefix length 0"),
4392 "entry {entry}: {err}"
4393 );
4394 }
4395 }
4396
4397 #[test]
4398 fn validate_accepts_cidr_and_bare_ip_proxy_entries() {
4399 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_trusted_proxies([
4400 "10.0.0.0/8",
4401 "192.0.2.1",
4402 "2001:db8::1",
4403 ]);
4404 assert!(cfg.validate().is_ok(), "CIDRs and bare IPs are accepted");
4405 }
4406
4407 #[test]
4408 fn validate_rejects_forwarded_header_without_proxies() {
4409 let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
4410 .with_forwarded_header(ForwardedHeaderMode::Forwarded);
4411 let err = cfg.validate().expect_err("mode without proxies");
4412 assert!(err.to_string().contains("requires trusted_proxies"));
4413 }
4414
4415 fn origin_router(origins: Vec<String>, log_request_headers: bool) -> axum::Router {
4419 let allowed: Arc<[String]> = Arc::from(origins);
4420 axum::Router::new()
4421 .route("/test", axum::routing::get(|| async { "ok" }))
4422 .layer(axum::middleware::from_fn(move |req, next| {
4423 let a = Arc::clone(&allowed);
4424 origin_check_middleware(a, log_request_headers, req, next)
4425 }))
4426 }
4427
4428 #[tokio::test]
4429 async fn origin_allowed_passes() {
4430 let app = origin_router(vec!["http://localhost:3000".into()], false);
4431 let req = Request::builder()
4432 .uri("/test")
4433 .header(header::ORIGIN, "http://localhost:3000")
4434 .body(Body::empty())
4435 .unwrap();
4436 let resp = app.oneshot(req).await.unwrap();
4437 assert_eq!(resp.status(), StatusCode::OK);
4438 }
4439
4440 #[tokio::test]
4441 async fn origin_rejected_returns_403() {
4442 let app = origin_router(vec!["http://localhost:3000".into()], false);
4443 let req = Request::builder()
4444 .uri("/test")
4445 .header(header::ORIGIN, "http://evil.com")
4446 .body(Body::empty())
4447 .unwrap();
4448 let resp = app.oneshot(req).await.unwrap();
4449 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
4450 }
4451
4452 #[tokio::test]
4453 async fn no_origin_header_passes() {
4454 let app = origin_router(vec!["http://localhost:3000".into()], false);
4455 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4456 let resp = app.oneshot(req).await.unwrap();
4457 assert_eq!(resp.status(), StatusCode::OK);
4458 }
4459
4460 #[tokio::test]
4461 async fn empty_allowlist_rejects_any_origin() {
4462 let app = origin_router(vec![], false);
4463 let req = Request::builder()
4464 .uri("/test")
4465 .header(header::ORIGIN, "http://anything.com")
4466 .body(Body::empty())
4467 .unwrap();
4468 let resp = app.oneshot(req).await.unwrap();
4469 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
4470 }
4471
4472 #[tokio::test]
4473 async fn empty_allowlist_passes_without_origin() {
4474 let app = origin_router(vec![], false);
4475 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4476 let resp = app.oneshot(req).await.unwrap();
4477 assert_eq!(resp.status(), StatusCode::OK);
4478 }
4479
4480 #[test]
4481 fn format_request_headers_redacts_sensitive_values() {
4482 let mut headers = axum::http::HeaderMap::new();
4483 headers.insert("authorization", "Bearer secret-token".parse().unwrap());
4484 headers.insert("cookie", "sid=abc".parse().unwrap());
4485 headers.insert("x-request-id", "req-123".parse().unwrap());
4486
4487 let out = format_request_headers_for_log(&headers);
4488 assert!(out.contains("authorization: [REDACTED]"));
4489 assert!(out.contains("cookie: [REDACTED]"));
4490 assert!(out.contains("x-request-id: req-123"));
4491 assert!(!out.contains("secret-token"));
4492 }
4493
4494 fn security_router(is_tls: bool) -> axum::Router {
4497 security_router_with(is_tls, SecurityHeadersConfig::default())
4498 }
4499
4500 fn security_router_with(is_tls: bool, cfg: SecurityHeadersConfig) -> axum::Router {
4501 let cfg = Arc::new(cfg);
4502 axum::Router::new()
4503 .route("/test", axum::routing::get(|| async { "ok" }))
4504 .layer(axum::middleware::from_fn(move |req, next| {
4505 let c = Arc::clone(&cfg);
4506 security_headers_middleware(is_tls, c, req, next)
4507 }))
4508 }
4509
4510 #[tokio::test]
4511 async fn security_headers_set_on_response() {
4512 let app = security_router(false);
4513 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4514 let resp = app.oneshot(req).await.unwrap();
4515 assert_eq!(resp.status(), StatusCode::OK);
4516
4517 let h = resp.headers();
4518 assert_eq!(h.get("x-content-type-options").unwrap(), "nosniff");
4519 assert_eq!(h.get("x-frame-options").unwrap(), "deny");
4520 assert_eq!(h.get("cache-control").unwrap(), "no-store, max-age=0");
4521 assert_eq!(h.get("referrer-policy").unwrap(), "no-referrer");
4522 assert_eq!(h.get("cross-origin-opener-policy").unwrap(), "same-origin");
4523 assert_eq!(
4524 h.get("cross-origin-resource-policy").unwrap(),
4525 "same-origin"
4526 );
4527 assert_eq!(
4528 h.get("cross-origin-embedder-policy").unwrap(),
4529 "require-corp"
4530 );
4531 assert_eq!(h.get("x-permitted-cross-domain-policies").unwrap(), "none");
4532 assert!(
4533 h.get("permissions-policy")
4534 .unwrap()
4535 .to_str()
4536 .unwrap()
4537 .contains("camera=()"),
4538 "permissions-policy must restrict browser features"
4539 );
4540 assert_eq!(
4541 h.get("content-security-policy").unwrap(),
4542 "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
4543 );
4544 assert_eq!(h.get("x-dns-prefetch-control").unwrap(), "off");
4545 assert!(h.get("strict-transport-security").is_none());
4547 }
4548
4549 #[tokio::test]
4550 async fn hsts_set_when_tls_enabled() {
4551 let app = security_router(true);
4552 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4553 let resp = app.oneshot(req).await.unwrap();
4554
4555 let hsts = resp.headers().get("strict-transport-security").unwrap();
4556 assert!(
4557 hsts.to_str().unwrap().contains("max-age=63072000"),
4558 "HSTS must set 2-year max-age"
4559 );
4560 }
4561
4562 #[tokio::test]
4563 async fn default_csp_matches_guideline() {
4564 let app = security_router(false);
4565 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4566 let resp = app.oneshot(req).await.unwrap();
4567 assert_eq!(
4568 resp.headers().get("content-security-policy").unwrap(),
4569 "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
4570 );
4571 }
4572
4573 #[tokio::test]
4574 async fn operator_csp_override_still_wins() {
4575 let cfg = SecurityHeadersConfig {
4576 content_security_policy: Some("default-src 'self'".into()),
4577 ..SecurityHeadersConfig::default()
4578 };
4579 let app = security_router_with(false, cfg);
4580 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4581 let resp = app.oneshot(req).await.unwrap();
4582 assert_eq!(
4583 resp.headers().get("content-security-policy").unwrap(),
4584 "default-src 'self'"
4585 );
4586 }
4587
4588 fn check_with_security_headers(headers: SecurityHeadersConfig) -> Result<(), McpxError> {
4594 let cfg =
4595 McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0").with_security_headers(headers);
4596 cfg.check()
4597 }
4598
4599 #[test]
4600 fn security_headers_config_default_validates() {
4601 check_with_security_headers(SecurityHeadersConfig::default())
4602 .expect("default SecurityHeadersConfig must validate");
4603 }
4604
4605 #[test]
4606 fn security_headers_config_validate_accepts_empty_string() {
4607 let h = SecurityHeadersConfig {
4609 x_content_type_options: Some(String::new()),
4610 x_frame_options: Some(String::new()),
4611 cache_control: Some(String::new()),
4612 referrer_policy: Some(String::new()),
4613 cross_origin_opener_policy: Some(String::new()),
4614 cross_origin_resource_policy: Some(String::new()),
4615 cross_origin_embedder_policy: Some(String::new()),
4616 permissions_policy: Some(String::new()),
4617 x_permitted_cross_domain_policies: Some(String::new()),
4618 content_security_policy: Some(String::new()),
4619 x_dns_prefetch_control: Some(String::new()),
4620 strict_transport_security: Some(String::new()),
4621 };
4622 check_with_security_headers(h).expect("Some(\"\") on every field must validate (omit-all)");
4623 }
4624
4625 #[test]
4626 fn security_headers_config_validate_rejects_bad_value() {
4627 let h = SecurityHeadersConfig {
4629 referrer_policy: Some("\u{0007}".into()),
4630 ..SecurityHeadersConfig::default()
4631 };
4632 let err = check_with_security_headers(h)
4633 .expect_err("control char in referrer_policy must reject");
4634 let msg = err.to_string();
4635 assert!(
4636 msg.contains("referrer_policy"),
4637 "error must name the offending field, got: {msg}"
4638 );
4639 }
4640
4641 #[test]
4642 fn security_headers_config_validate_rejects_hsts_preload() {
4643 let h = SecurityHeadersConfig {
4644 strict_transport_security: Some("max-age=63072000; includeSubDomains; preload".into()),
4645 ..SecurityHeadersConfig::default()
4646 };
4647 let err = check_with_security_headers(h).expect_err("HSTS with preload must reject");
4648 let msg = err.to_string();
4649 assert!(
4650 msg.contains("strict_transport_security"),
4651 "error must name the field, got: {msg}"
4652 );
4653 assert!(
4654 msg.to_lowercase().contains("preload"),
4655 "error must mention `preload`, got: {msg}"
4656 );
4657 }
4658
4659 #[test]
4660 fn security_headers_config_validate_rejects_hsts_preload_uppercase() {
4661 let h = SecurityHeadersConfig {
4663 strict_transport_security: Some("max-age=600; PRELOAD".into()),
4664 ..SecurityHeadersConfig::default()
4665 };
4666 check_with_security_headers(h).expect_err("HSTS preload check must be case-insensitive");
4667 }
4668
4669 #[tokio::test]
4670 async fn security_headers_override_honored() {
4671 let h = SecurityHeadersConfig {
4673 x_frame_options: Some("SAMEORIGIN".into()),
4674 ..SecurityHeadersConfig::default()
4675 };
4676 let app = security_router_with(false, h);
4677 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4678 let resp = app.oneshot(req).await.unwrap();
4679 assert_eq!(resp.status(), StatusCode::OK);
4680
4681 let xfo = resp.headers().get("x-frame-options").unwrap();
4682 assert_eq!(xfo, "SAMEORIGIN");
4683 }
4684
4685 #[tokio::test]
4686 async fn security_headers_empty_string_omits() {
4687 let h = SecurityHeadersConfig {
4689 referrer_policy: Some(String::new()),
4690 ..SecurityHeadersConfig::default()
4691 };
4692 let app = security_router_with(false, h);
4693 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4694 let resp = app.oneshot(req).await.unwrap();
4695 assert_eq!(resp.status(), StatusCode::OK);
4696
4697 assert!(
4698 resp.headers().get("referrer-policy").is_none(),
4699 "Some(\"\") must omit the header"
4700 );
4701 assert_eq!(
4703 resp.headers().get("x-content-type-options").unwrap(),
4704 "nosniff"
4705 );
4706 }
4707
4708 #[tokio::test]
4709 async fn security_headers_hsts_only_when_tls() {
4710 let h = SecurityHeadersConfig {
4712 strict_transport_security: Some("max-age=600".into()),
4713 ..SecurityHeadersConfig::default()
4714 };
4715 let app = security_router_with(false, h);
4716 let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
4717 let resp = app.oneshot(req).await.unwrap();
4718 assert!(
4719 resp.headers().get("strict-transport-security").is_none(),
4720 "HSTS must remain absent on plaintext deployments even with override"
4721 );
4722 }
4723
4724 #[cfg(feature = "oauth")]
4727 #[tokio::test]
4728 async fn oauth_token_cache_headers_set_pragma_and_vary() {
4729 let app = axum::Router::new()
4730 .route("/token", axum::routing::post(|| async { "{}" }))
4731 .layer(axum::middleware::from_fn(
4732 oauth_token_cache_headers_middleware,
4733 ));
4734 let req = Request::builder()
4735 .method("POST")
4736 .uri("/token")
4737 .body(Body::from("{}"))
4738 .unwrap();
4739 let resp = app.oneshot(req).await.unwrap();
4740 assert_eq!(resp.status(), StatusCode::OK);
4741
4742 let h = resp.headers();
4743 assert_eq!(
4744 h.get("pragma").unwrap(),
4745 "no-cache",
4746 "RFC 6749 §5.1: token responses must set Pragma: no-cache"
4747 );
4748 let vary_values: Vec<String> = h
4749 .get_all("vary")
4750 .iter()
4751 .filter_map(|v| v.to_str().ok().map(str::to_owned))
4752 .collect();
4753 assert!(
4754 vary_values
4755 .iter()
4756 .any(|v| v.eq_ignore_ascii_case("Authorization")),
4757 "RFC 6750 §5.4: Vary must include Authorization, got {vary_values:?}"
4758 );
4759 }
4760
4761 #[cfg(feature = "oauth")]
4762 #[tokio::test]
4763 async fn oauth_token_cache_headers_preserve_existing_vary() {
4764 let app = axum::Router::new()
4767 .route(
4768 "/token",
4769 axum::routing::post(|| async {
4770 axum::response::Response::builder()
4771 .header("vary", "Accept-Encoding")
4772 .body(axum::body::Body::from("{}"))
4773 .unwrap()
4774 }),
4775 )
4776 .layer(axum::middleware::from_fn(
4777 oauth_token_cache_headers_middleware,
4778 ));
4779 let req = Request::builder()
4780 .method("POST")
4781 .uri("/token")
4782 .body(Body::empty())
4783 .unwrap();
4784 let resp = app.oneshot(req).await.unwrap();
4785
4786 let vary: Vec<String> = resp
4787 .headers()
4788 .get_all("vary")
4789 .iter()
4790 .filter_map(|v| v.to_str().ok().map(str::to_owned))
4791 .collect();
4792 assert!(
4793 vary.iter().any(|v| v.contains("Accept-Encoding")),
4794 "must preserve pre-existing Vary value, got {vary:?}"
4795 );
4796 assert!(
4797 vary.iter().any(|v| v.contains("Authorization")),
4798 "must append Authorization to Vary, got {vary:?}"
4799 );
4800 }
4801
4802 #[test]
4805 fn version_omits_build_fingerprint_by_default() {
4806 let v = version_payload("my-server", "1.2.3", false);
4807 assert_eq!(v["name"], "my-server");
4808 assert_eq!(v["version"], "1.2.3");
4809 assert!(v["mcpx_version"].is_string());
4810 assert!(
4811 v.get("build_git_sha").is_none(),
4812 "build sha must be hidden by default"
4813 );
4814 assert!(v.get("build_timestamp").is_none());
4815 assert!(v.get("rust_version").is_none());
4816 }
4817
4818 #[test]
4819 fn version_exposes_all_when_enabled() {
4820 let v = version_payload("my-server", "1.2.3", true);
4821 assert!(v["build_git_sha"].is_string());
4822 assert!(v["build_timestamp"].is_string());
4823 assert!(v["rust_version"].is_string());
4824 assert!(v["mcpx_version"].is_string());
4825 }
4826
4827 #[tokio::test]
4830 async fn concurrency_limit_layer_composes_and_serves() {
4831 let app = axum::Router::new()
4835 .route("/ok", axum::routing::get(|| async { "ok" }))
4836 .layer(
4837 tower::ServiceBuilder::new()
4838 .layer(axum::error_handling::HandleErrorLayer::new(
4839 |_err: tower::BoxError| async { StatusCode::SERVICE_UNAVAILABLE },
4840 ))
4841 .layer(tower::load_shed::LoadShedLayer::new())
4842 .layer(tower::limit::ConcurrencyLimitLayer::new(4)),
4843 );
4844 let resp = app
4845 .oneshot(Request::builder().uri("/ok").body(Body::empty()).unwrap())
4846 .await
4847 .unwrap();
4848 assert_eq!(resp.status(), StatusCode::OK);
4849 }
4850
4851 #[tokio::test]
4854 async fn compression_layer_gzip_encodes_response() {
4855 use tower_http::compression::Predicate as _;
4856
4857 let big_body = "a".repeat(4096);
4858 let app = axum::Router::new()
4859 .route(
4860 "/big",
4861 axum::routing::get(move || {
4862 let body = big_body.clone();
4863 async move { body }
4864 }),
4865 )
4866 .layer(
4867 tower_http::compression::CompressionLayer::new()
4868 .gzip(true)
4869 .br(true)
4870 .compress_when(
4871 tower_http::compression::DefaultPredicate::new()
4872 .and(tower_http::compression::predicate::SizeAbove::new(1024)),
4873 ),
4874 );
4875
4876 let req = Request::builder()
4877 .uri("/big")
4878 .header(header::ACCEPT_ENCODING, "gzip")
4879 .body(Body::empty())
4880 .unwrap();
4881 let resp = app.oneshot(req).await.unwrap();
4882 assert_eq!(resp.status(), StatusCode::OK);
4883 assert_eq!(
4884 resp.headers().get(header::CONTENT_ENCODING).unwrap(),
4885 "gzip"
4886 );
4887 }
4888
4889 #[tokio::test]
4892 async fn tls_handshake_timeout_reaps_idle_connections() {
4893 use tokio::io::AsyncReadExt as _;
4894
4895 let _ = rustls::crypto::ring::default_provider().install_default();
4896
4897 let key = rcgen::KeyPair::generate().expect("generate key");
4899 let cert = rcgen::CertificateParams::new(vec!["localhost".to_owned()])
4900 .expect("cert params")
4901 .self_signed(&key)
4902 .expect("self-signed cert");
4903 let dir = std::env::temp_dir().join(format!(
4904 "rmcp-server-kit-hs-timeout-{}",
4905 std::time::SystemTime::now()
4906 .duration_since(std::time::UNIX_EPOCH)
4907 .expect("clock after epoch")
4908 .as_nanos()
4909 ));
4910 tokio::fs::create_dir_all(&dir).await.expect("temp dir");
4911 let cert_path = dir.join("server.crt");
4912 let key_path = dir.join("server.key");
4913 tokio::fs::write(&cert_path, cert.pem())
4914 .await
4915 .expect("write cert");
4916 tokio::fs::write(&key_path, key.serialize_pem())
4917 .await
4918 .expect("write key");
4919
4920 let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
4921 let tls = TlsListener::new(
4922 listener,
4923 &cert_path,
4924 &key_path,
4925 None,
4926 None,
4927 Duration::from_millis(200),
4928 8, )
4930 .expect("tls listener");
4931 let addr = axum::serve::Listener::local_addr(&tls).expect("local addr");
4932
4933 let mut idle = tokio::net::TcpStream::connect(addr).await.expect("connect");
4937 let mut buf = [0_u8; 16];
4938 let read = tokio::time::timeout(Duration::from_secs(2), idle.read(&mut buf))
4939 .await
4940 .expect("server must reap the idle handshake within its timeout");
4941 match read {
4942 Ok(0) | Err(_) => {} Ok(n) => panic!("unexpected {n} bytes from server during reaped handshake"),
4944 }
4945
4946 drop(tls);
4947 }
4948
4949 fn assert_owasp_headers(resp: &axum::response::Response, ctx: &str) {
4952 let h = resp.headers();
4953 assert!(
4954 h.contains_key("x-content-type-options"),
4955 "{ctx}: missing X-Content-Type-Options"
4956 );
4957 assert!(
4958 h.contains_key("x-frame-options"),
4959 "{ctx}: missing X-Frame-Options"
4960 );
4961 assert!(
4962 h.contains_key("strict-transport-security"),
4963 "{ctx}: missing Strict-Transport-Security"
4964 );
4965 assert!(
4966 h.contains_key(header::CONTENT_SECURITY_POLICY),
4967 "{ctx}: missing Content-Security-Policy"
4968 );
4969 }
4970
4971 fn m5_router(configure: impl FnOnce(&mut McpServerConfig)) -> axum::Router {
4972 #[derive(Clone)]
4973 struct H;
4974 impl ServerHandler for H {}
4975 let mut config = McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0")
4979 .with_allowed_origins(["http://good.example"])
4980 .with_tls("unused.crt", "unused.key");
4981 configure(&mut config);
4982 let (router, _params) = build_app_router(config, || H).expect("build_app_router");
4983 router
4984 }
4985
4986 #[tokio::test]
4987 async fn headers_on_rejected_origin_403() {
4988 let app = m5_router(|_| {});
4989 let req = Request::builder()
4990 .uri("/healthz")
4991 .header(header::ORIGIN, "http://evil.example")
4992 .body(Body::empty())
4993 .unwrap();
4994 let resp = app.oneshot(req).await.unwrap();
4995 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
4996 assert_owasp_headers(&resp, "origin-403");
4997 }
4998
4999 #[tokio::test]
5000 async fn headers_on_cors_preflight() {
5001 let app = m5_router(|_| {});
5002 let req = Request::builder()
5003 .method(axum::http::Method::OPTIONS)
5004 .uri("/mcp")
5005 .header(header::ORIGIN, "http://good.example")
5006 .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
5007 .body(Body::empty())
5008 .unwrap();
5009 let resp = app.oneshot(req).await.unwrap();
5010 assert_owasp_headers(&resp, "cors-preflight");
5011 }
5012
5013 #[tokio::test]
5014 async fn headers_on_404_fallback() {
5015 let app = m5_router(|_| {});
5016 let req = Request::builder()
5017 .uri("/no-such-route")
5018 .body(Body::empty())
5019 .unwrap();
5020 let resp = app.oneshot(req).await.unwrap();
5021 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
5022 assert_owasp_headers(&resp, "404-fallback");
5023 }
5024
5025 #[tokio::test]
5026 async fn headers_on_overload_503() {
5027 let app = m5_router(|c| c.max_concurrent_requests = Some(0));
5030 let req = Request::builder()
5031 .uri("/healthz")
5032 .body(Body::empty())
5033 .unwrap();
5034 let resp = app.oneshot(req).await.unwrap();
5035 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
5036 assert_owasp_headers(&resp, "overload-503");
5037 }
5038
5039 #[cfg(feature = "oauth")]
5042 fn m6_auth_state() -> (Arc<AuthState>, String, String) {
5043 let (admin_token, admin_hash) = crate::auth::generate_api_key().unwrap();
5044 let (viewer_token, viewer_hash) = crate::auth::generate_api_key().unwrap();
5045 let state = Arc::new(AuthState {
5046 api_keys: ArcSwap::from_pointee(vec![
5047 crate::auth::ApiKeyEntry::new("admin-key", admin_hash, "admin"),
5048 crate::auth::ApiKeyEntry::new("viewer-key", viewer_hash, "viewer"),
5049 ]),
5050 rate_limiter: None,
5051 pre_auth_limiter: None,
5052 jwks_cache: None,
5053 seen_identities: crate::auth::SeenIdentitySet::new(),
5054 counters: crate::auth::AuthCounters::default(),
5055 });
5056 (state, admin_token, viewer_token)
5057 }
5058
5059 #[cfg(feature = "oauth")]
5060 fn m6_admin_router(state: &Arc<AuthState>) -> axum::Router {
5061 let proxy = crate::oauth::OAuthProxyConfig::builder(
5062 "https://idp.example/authorize",
5063 "https://idp.example/token",
5064 "client",
5065 )
5066 .introspection_url("http://127.0.0.1:1/introspect")
5067 .revocation_url("http://127.0.0.1:1/revoke")
5068 .expose_admin_endpoints(true)
5069 .require_auth_on_admin_endpoints(true)
5070 .build();
5071 let http = crate::oauth::OauthHttpClient::new().expect("oauth http client");
5072 build_oauth_admin_router(&proxy, http, Some(state), "admin").expect("admin router")
5073 }
5074
5075 #[cfg(feature = "oauth")]
5076 fn m6_req(path: &str, token: &str) -> Request<Body> {
5077 Request::builder()
5078 .method(axum::http::Method::POST)
5079 .uri(path)
5080 .header(header::AUTHORIZATION, format!("Bearer {token}"))
5081 .body(Body::from("token=abc"))
5082 .unwrap()
5083 }
5084
5085 #[cfg(feature = "oauth")]
5086 #[tokio::test]
5087 async fn oauth_proxy_admin_requires_admin_role() {
5088 let (state, _admin, viewer) = m6_auth_state();
5089 for path in ["/introspect", "/revoke"] {
5090 let app = m6_admin_router(&state);
5091 let resp = app.oneshot(m6_req(path, &viewer)).await.unwrap();
5092 assert_eq!(
5093 resp.status(),
5094 StatusCode::FORBIDDEN,
5095 "an authenticated viewer must be rejected with 403 on {path}"
5096 );
5097 }
5098 }
5099
5100 #[cfg(feature = "oauth")]
5101 #[tokio::test]
5102 async fn oauth_proxy_admin_allows_admin_role() {
5103 let (state, admin, _viewer) = m6_auth_state();
5104 for path in ["/introspect", "/revoke"] {
5105 let app = m6_admin_router(&state);
5106 let resp = app.oneshot(m6_req(path, &admin)).await.unwrap();
5107 assert_ne!(
5111 resp.status(),
5112 StatusCode::FORBIDDEN,
5113 "an authenticated admin must pass the role gate on {path}"
5114 );
5115 assert_ne!(
5116 resp.status(),
5117 StatusCode::UNAUTHORIZED,
5118 "an authenticated admin must pass the auth gate on {path}"
5119 );
5120 }
5121 }
5122}