Skip to main content

rmcp_server_kit/
transport.rs

1use std::{
2    future::Future,
3    net::{IpAddr, SocketAddr},
4    num::NonZeroUsize,
5    path::{Path, PathBuf},
6    pin::Pin,
7    sync::Arc,
8    time::Duration,
9};
10
11use arc_swap::ArcSwap;
12use axum::{
13    body::Body,
14    extract::{ConnectInfo, Request},
15    middleware::Next,
16    response::IntoResponse,
17};
18use rmcp::{
19    ServerHandler,
20    transport::streamable_http_server::{
21        StreamableHttpServerConfig, StreamableHttpService,
22        session::{EventStore, SessionStore, local::LocalSessionManager},
23    },
24};
25use rustls::RootCertStore;
26use secrecy::{ExposeSecret as _, SecretString};
27use tokio::{
28    net::TcpListener,
29    sync::{Semaphore, mpsc},
30};
31use tokio_util::sync::CancellationToken;
32
33use crate::{
34    auth::{
35        AuthConfig, AuthIdentity, AuthState, MtlsConfig, TlsConnInfo, auth_middleware,
36        build_rate_limiter, extract_mtls_identity,
37    },
38    bounded_limiter::{BoundedKeyedLimiter, BoundedLimiterDeny, KeyEvictionPolicy},
39    error::RmcpServerKitError,
40    mtls_revocation::{self, CrlSet, DynamicClientCertVerifier},
41    rbac::{RbacPolicy, ToolRateLimiter, build_tool_rate_limiter_with_policy, rbac_middleware},
42    rbac_context::RbacContextHandler,
43    session_binding::{
44        configured_session_binding_secret, process_session_binding_secret,
45        session_binding_middleware,
46    },
47};
48
49/// Map an internal `anyhow::Error` chain into a public [`RmcpServerKitError::Startup`]
50/// at the public API boundary, flattening the chain via the alternate
51/// formatter so callers see the full causal path.
52#[allow(
53    clippy::needless_pass_by_value,
54    reason = "consumed at .map_err(anyhow_to_startup) call sites; by-value matches the closure shape"
55)]
56fn anyhow_to_startup(e: anyhow::Error) -> RmcpServerKitError {
57    RmcpServerKitError::Startup(format!("{e:#}"))
58}
59
60/// Map a `std::io::Error` produced during server startup into a public
61/// [`RmcpServerKitError::Startup`]. We deliberately do not use the [`RmcpServerKitError::Io`]
62/// `From` impl here because startup-phase IO errors (bind, listener) are
63/// semantically distinct from request-time IO errors and should surface
64/// the originating operation in the message.
65#[allow(
66    clippy::needless_pass_by_value,
67    reason = "consumed at .map_err(|e| io_to_startup(...)) call sites; by-value matches the closure shape"
68)]
69fn io_to_startup(op: &str, e: std::io::Error) -> RmcpServerKitError {
70    RmcpServerKitError::Startup(format!("{op}: {e}"))
71}
72
73/// Async readiness check callback for the `/readyz` endpoint.
74///
75/// Returns a JSON object with at least a `"ready"` boolean.
76/// When `ready` is false, the endpoint returns HTTP 503.
77pub type ReadinessCheck =
78    Arc<dyn Fn() -> Pin<Box<dyn Future<Output = serde_json::Value> + Send>> + Send + Sync>;
79
80/// Direct socket peer address of the current HTTP/TLS connection.
81///
82/// Inserted as a request extension into every request served by [`serve`] -
83/// on both the plain and the TLS listener - and extractable in any axum
84/// handler, including routes mounted via
85/// [`McpServerConfig::with_extra_router`] (which bypass auth/RBAC and
86/// therefore often need the peer address for their own protection, e.g.
87/// per-IP rate limiting).
88///
89/// The same address is also mirrored into
90/// [`axum::extract::ConnectInfo<SocketAddr>`] on the TLS listener, so
91/// third-party middleware that expects the stock axum extension (e.g.
92/// per-IP rate-limit key extractors) works unmodified under TLS.
93///
94/// # Semantics
95///
96/// - **Direct peer only.** This is the socket's remote address. Behind an
97///   L4/L7 proxy or load balancer it is the proxy's address; the framework
98///   performs **no** `X-Forwarded-For` / `Forwarded` interpretation.
99/// - **Available on HTTP and TLS** transports alike ([`serve`]).
100/// - **Absent under [`serve_stdio`]** - a stdio session has no network
101///   peer (stdio bypasses the HTTP stack entirely).
102/// - The separate Prometheus metrics listener (feature `metrics`) is a
103///   different router and does not carry this extension.
104///
105/// # Privacy
106///
107/// `PeerAddr` exposes raw peer network metadata. The framework deliberately
108/// never logs it on its own; whether to log or persist peer addresses is
109/// application policy.
110///
111/// # Example
112///
113/// ```no_run
114/// use axum::{Router, routing::get};
115/// use rmcp_server_kit::transport::{McpServerConfig, PeerAddr};
116///
117/// async fn whoami(peer: PeerAddr) -> String {
118///     peer.addr.ip().to_string()
119/// }
120///
121/// let _config = McpServerConfig::new("127.0.0.1:8443", "my-server", "1.0.0")
122///     .with_extra_router(Router::new().route("/whoami", get(whoami)));
123/// ```
124#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
125#[non_exhaustive]
126pub struct PeerAddr {
127    /// Direct socket peer of this connection.
128    pub addr: SocketAddr,
129}
130
131impl PeerAddr {
132    /// Construct a new [`PeerAddr`]. Framework-internal: downstream code
133    /// receives `PeerAddr` via request extensions and never constructs it.
134    #[must_use]
135    pub(crate) const fn new(addr: SocketAddr) -> Self {
136        Self { addr }
137    }
138}
139
140/// Extract [`PeerAddr`] from request extensions.
141///
142/// # Rejection
143///
144/// Responds `500 Internal Server Error` when the extension is missing.
145/// A missing `PeerAddr` means the handler is not running under [`serve`]
146/// (e.g. the router was mounted on a hand-rolled listener) - a wiring
147/// bug, not a client error.
148impl<S: Send + Sync> axum::extract::FromRequestParts<S> for PeerAddr {
149    type Rejection = (axum::http::StatusCode, &'static str);
150
151    #[allow(
152        clippy::unused_async_trait_impl,
153        reason = "async is mandated by the axum FromRequestParts trait signature; this impl only reads a request extension synchronously"
154    )]
155    async fn from_request_parts(
156        parts: &mut axum::http::request::Parts,
157        _state: &S,
158    ) -> Result<Self, Self::Rejection> {
159        parts.extensions.get::<Self>().copied().ok_or((
160            axum::http::StatusCode::INTERNAL_SERVER_ERROR,
161            "peer address unavailable: not running under rmcp-server-kit serve()",
162        ))
163    }
164}
165
166/// Resolved client IP of the current request.
167///
168/// Inserted as a request extension on every request served by [`serve`],
169/// right after [`PeerAddr`]. Equals the direct peer's IP unless
170/// **trusted-forwarder mode** is active
171/// ([`McpServerConfig::with_trusted_proxies`]) and the request arrived
172/// through a trusted proxy with a verifiable forwarding chain - in that
173/// case it is the rightmost-untrusted address from `X-Forwarded-For`
174/// (or RFC 7239 `Forwarded`, per
175/// [`McpServerConfig::with_forwarded_header`]).
176///
177/// All built-in per-IP rate limiters key by this value. [`PeerAddr`]
178/// keeps its direct-socket-peer contract unchanged; applications that
179/// need provenance can compare `ClientIp.ip` with `PeerAddr.addr.ip()`.
180///
181/// # Security
182///
183/// Resolution only ever activates when the **direct peer** is inside the
184/// operator's trusted-proxy CIDRs; every ambiguous chain (malformed or
185/// obfuscated entries, all-trusted chains, header bombs) falls back to
186/// the direct peer, never to a header value. The framework never logs
187/// this value outside rate-limit deny paths.
188#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
189#[non_exhaustive]
190pub struct ClientIp {
191    /// Resolved client IP (direct peer unless trusted-forwarder resolution applied).
192    pub ip: IpAddr,
193}
194
195impl ClientIp {
196    /// Construct a new [`ClientIp`]. Framework-internal: downstream code
197    /// receives `ClientIp` via request extensions and never constructs it.
198    #[must_use]
199    pub(crate) const fn new(ip: IpAddr) -> Self {
200        Self { ip }
201    }
202}
203
204/// Which forwarding header trusted-forwarder mode reads.
205///
206/// TOML wire values are kebab-case: `"x-forwarded-for"` (default when
207/// unset) and `"forwarded"`.
208#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Deserialize)]
209#[serde(rename_all = "kebab-case")]
210#[non_exhaustive]
211pub enum ForwardedHeaderMode {
212    /// De-facto standard `X-Forwarded-For` list (nginx, HAProxy, CDNs).
213    XForwardedFor,
214    /// RFC 7239 `Forwarded` header (`for=` parameters).
215    Forwarded,
216}
217
218/// Pre-parsed trusted-forwarder configuration captured by the
219/// peer-normalization middleware.
220struct ForwardResolver {
221    trusted: Vec<ipnet::IpNet>,
222    mode: ForwardedHeaderMode,
223    max_scanned_entries: usize,
224}
225
226/// Per-header overrides for the OWASP security headers emitted by the
227/// global response middleware.
228///
229/// Each field follows a three-state semantic:
230///
231/// | Value         | Behaviour                                                |
232/// |---------------|----------------------------------------------------------|
233/// | `None`        | Use the built-in default (current behaviour).            |
234/// | `Some("")`    | **Omit** the header entirely from responses.             |
235/// | `Some(value)` | Emit `header: value`. Validated at config-load time.     |
236///
237/// All non-empty values are validated via
238/// [`axum::http::HeaderValue::from_str`] inside
239/// [`McpServerConfig::validate`]; invalid values fail fast before the
240/// server starts accepting traffic.
241///
242/// `Strict-Transport-Security` has an additional rule: the substring
243/// `preload` (case-insensitive) is rejected. Operators who want to
244/// commit to the HSTS preload list must do so via a future explicit
245/// builder method, not by smuggling it through this knob.
246#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
247#[serde(default)]
248#[serde(deny_unknown_fields)]
249#[non_exhaustive]
250pub struct SecurityHeadersConfig {
251    /// Override for `X-Content-Type-Options`. Default: `nosniff`.
252    pub x_content_type_options: Option<String>,
253    /// Override for `X-Frame-Options`. Default: `deny`.
254    pub x_frame_options: Option<String>,
255    /// Override for `Cache-Control`. Default: `no-store, max-age=0`.
256    pub cache_control: Option<String>,
257    /// Override for `Referrer-Policy`. Default: `no-referrer`.
258    pub referrer_policy: Option<String>,
259    /// Override for `Cross-Origin-Opener-Policy`. Default: `same-origin`.
260    pub cross_origin_opener_policy: Option<String>,
261    /// Override for `Cross-Origin-Resource-Policy`. Default: `same-origin`.
262    pub cross_origin_resource_policy: Option<String>,
263    /// Override for `Cross-Origin-Embedder-Policy`. Default: `require-corp`.
264    pub cross_origin_embedder_policy: Option<String>,
265    /// Override for `Permissions-Policy`. Default:
266    /// `accelerometer=(), camera=(), geolocation=(), microphone=()`.
267    pub permissions_policy: Option<String>,
268    /// Override for `X-Permitted-Cross-Domain-Policies`. Default: `none`.
269    pub x_permitted_cross_domain_policies: Option<String>,
270    /// Override for `Content-Security-Policy`. Default:
271    /// `default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests`.
272    pub content_security_policy: Option<String>,
273    /// Override for `X-DNS-Prefetch-Control`. Default: `off`.
274    pub x_dns_prefetch_control: Option<String>,
275    /// Override for `Strict-Transport-Security`. Default (TLS only):
276    /// `max-age=63072000; includeSubDomains`. Only emitted when TLS is
277    /// active; the override is ignored on plaintext deployments. The
278    /// substring `preload` (any case) is rejected by the validator.
279    pub strict_transport_security: Option<String>,
280}
281
282/// Configuration for the MCP server.
283#[allow(
284    missing_debug_implementations,
285    reason = "contains callback/trait objects that don't impl Debug"
286)]
287#[allow(
288    clippy::struct_excessive_bools,
289    reason = "server configuration naturally has many boolean feature flags"
290)]
291#[non_exhaustive]
292pub struct McpServerConfig {
293    /// Socket address the MCP HTTP server binds to.
294    #[deprecated(
295        since = "0.13.0",
296        note = "use McpServerConfig::new() / with_bind_addr(); direct field access will become pub(crate) in a future major release"
297    )]
298    pub bind_addr: String,
299    /// Server name advertised via MCP `initialize`.
300    #[deprecated(
301        since = "0.13.0",
302        note = "set via McpServerConfig::new(); direct field access will become pub(crate) in a future major release"
303    )]
304    pub name: String,
305    /// Server version advertised via MCP `initialize`.
306    #[deprecated(
307        since = "0.13.0",
308        note = "set via McpServerConfig::new(); direct field access will become pub(crate) in a future major release"
309    )]
310    pub version: String,
311    /// Path to the TLS certificate (PEM). Required for TLS/mTLS.
312    #[deprecated(
313        since = "0.13.0",
314        note = "use McpServerConfig::with_tls(); direct field access will become pub(crate) in a future major release"
315    )]
316    pub tls_cert_path: Option<PathBuf>,
317    /// Path to the TLS private key (PEM). Required for TLS/mTLS.
318    #[deprecated(
319        since = "0.13.0",
320        note = "use McpServerConfig::with_tls(); direct field access will become pub(crate) in a future major release"
321    )]
322    pub tls_key_path: Option<PathBuf>,
323    /// Optional authentication config. When `Some` and `enabled`, auth
324    /// is enforced on `/mcp`. `/healthz` is always open.
325    #[deprecated(
326        since = "0.13.0",
327        note = "use McpServerConfig::with_auth(); direct field access will become pub(crate) in a future major release"
328    )]
329    pub auth: Option<AuthConfig>,
330    /// Optional RBAC policy. When present and enabled, tool calls are
331    /// checked against the policy after authentication.
332    #[deprecated(
333        since = "0.13.0",
334        note = "use McpServerConfig::with_rbac(); direct field access will become pub(crate) in a future major release"
335    )]
336    pub rbac: Option<Arc<RbacPolicy>>,
337    /// Filter `tools/list` responses through RBAC visibility when RBAC
338    /// is enabled and an authenticated role is present. Default: `true`.
339    pub tool_list_filtering: bool,
340    /// Allowed Origin values for DNS rebinding protection (MCP spec MUST).
341    /// When empty and `public_url` is set, the origin is auto-derived from
342    /// the public URL. When both are empty, only requests with no Origin
343    /// header are accepted.
344    /// Example entries: `"http://localhost:3000"`, `"https://myapp.example.com"`.
345    #[deprecated(
346        since = "0.13.0",
347        note = "use McpServerConfig::with_allowed_origins(); direct field access will become pub(crate) in a future major release"
348    )]
349    pub allowed_origins: Vec<String>,
350    /// Maximum tool invocations per source IP per minute.
351    /// When set, enforced on every `tools/call` request.
352    #[deprecated(
353        since = "0.13.0",
354        note = "use McpServerConfig::with_tool_rate_limit(); direct field access will become pub(crate) in a future major release"
355    )]
356    pub tool_rate_limit: Option<u32>,
357    /// Burst capacity for the tool rate limiter: maximum `tools/call`
358    /// requests admitted back-to-back before the sustained
359    /// [`tool_rate_limit`](Self::tool_rate_limit) rate applies. `None`
360    /// (default) keeps governor's default of burst = rate. Requires
361    /// `tool_rate_limit` to be set; must be greater than zero.
362    #[deprecated(
363        since = "1.12.0",
364        note = "use McpServerConfig::with_tool_rate_limit_burst(); direct field access will become pub(crate) in a future major release"
365    )]
366    pub tool_rate_limit_burst: Option<u32>,
367    /// Maximum requests per source IP per minute for routes merged via
368    /// [`with_extra_router`](Self::with_extra_router). Opt-in: `None`
369    /// (the default) installs no limiter. Startup-only (not
370    /// hot-reloadable via [`ReloadHandle`]).
371    ///
372    /// Keyed by the **direct socket peer** ([`PeerAddr`] semantics - no
373    /// `X-Forwarded-For` interpretation): behind a reverse proxy all
374    /// clients share the proxy's bucket, and IPv6 single-host address
375    /// rotation can evade per-IP keying. Treat this as an abuse speed
376    /// bump for unauthenticated application endpoints, not tenant
377    /// isolation. On limit: HTTP 429 with a plain-text body, matching
378    /// the tool/auth limiters.
379    #[deprecated(
380        since = "1.11.0",
381        note = "use McpServerConfig::with_extra_route_rate_limit(); direct field access will become pub(crate) in a future major release"
382    )]
383    pub extra_route_rate_limit: Option<u32>,
384    /// Burst capacity for the extra-route limiter: maximum requests
385    /// admitted back-to-back before the sustained
386    /// [`extra_route_rate_limit`](Self::extra_route_rate_limit) rate
387    /// applies. `None` (default) keeps governor's default of
388    /// burst = rate. Requires `extra_route_rate_limit` to be set; must
389    /// be greater than zero.
390    #[deprecated(
391        since = "1.12.0",
392        note = "use McpServerConfig::with_extra_route_rate_limit_burst(); direct field access will become pub(crate) in a future major release"
393    )]
394    pub extra_route_rate_limit_burst: Option<u32>,
395    /// Exact-match request paths exempt from the extra-route rate
396    /// limiter (e.g. `/.well-known/oauth-authorization-server`, which
397    /// MCP clients fetch on every connect - behind a shared egress the
398    /// limiter would otherwise 429 discovery). Matching is a **raw
399    /// exact string comparison** against `req.uri().path()`: no globs,
400    /// no prefixes, no normalization - trailing slashes,
401    /// percent-encoding, and dot-segments must match byte-for-byte.
402    /// Fail-closed: any path not listed stays rate-limited (a mismatch
403    /// can only mean "still limited", never "accidentally exempt").
404    /// Requires [`extra_route_rate_limit`](Self::extra_route_rate_limit);
405    /// each entry must be non-empty and start with `/` (validated).
406    /// Startup-only.
407    #[deprecated(
408        since = "1.14.0",
409        note = "use McpServerConfig::with_extra_route_rate_limit_exempt_paths(); direct field access will become pub(crate) in a future major release"
410    )]
411    pub extra_route_rate_limit_exempt_paths: Vec<String>,
412
413    /// Full-table policy for per-IP rate limiters. Default: evict LRU.
414    pub key_eviction_policy: KeyEvictionPolicy,
415
416    /// Maximum forwarding-chain entries scanned per request in
417    /// trusted-forwarder mode. Chains longer than this are treated as a
418    /// header bomb and resolution falls back to the direct peer.
419    ///
420    /// Defaults to `16`. Valid range is `1..=64`; the ceiling exists because
421    /// an unbounded value would disable the header-bomb protection entirely.
422    pub trusted_forwarder_max_entries: usize,
423    /// Trusted reverse-proxy networks (CIDRs or bare IPs) for
424    /// **trusted-forwarder mode**. Empty (default) = mode off: every
425    /// limiter keys by the direct socket peer. Nonempty = requests whose
426    /// direct peer is inside one of these networks have their client IP
427    /// resolved from the forwarding header (rightmost-untrusted walk);
428    /// see [`ClientIp`]. Only enable when **all** ingress paths traverse
429    /// the listed proxies. Startup-only.
430    #[deprecated(
431        since = "1.13.0",
432        note = "use McpServerConfig::with_trusted_proxies(); direct field access will become pub(crate) in a future major release"
433    )]
434    pub trusted_proxies: Vec<String>,
435    /// Which forwarding header trusted-forwarder mode reads. `None`
436    /// (default) = `X-Forwarded-For`. Setting this requires
437    /// [`trusted_proxies`](Self::trusted_proxies) to be nonempty
438    /// (validated). Startup-only.
439    #[deprecated(
440        since = "1.13.0",
441        note = "use McpServerConfig::with_forwarded_header(); direct field access will become pub(crate) in a future major release"
442    )]
443    pub forwarded_header: Option<ForwardedHeaderMode>,
444    /// Optional readiness probe for `/readyz`.
445    /// When `None`, `/readyz` mirrors `/healthz` (always OK).
446    #[deprecated(
447        since = "0.13.0",
448        note = "use McpServerConfig::with_readiness_check(); direct field access will become pub(crate) in a future major release"
449    )]
450    pub readiness_check: Option<ReadinessCheck>,
451    /// Maximum request body size in bytes. Default: 1 MiB.
452    /// Protects against oversized payloads causing OOM.
453    #[deprecated(
454        since = "0.13.0",
455        note = "use McpServerConfig::with_max_request_body(); direct field access will become pub(crate) in a future major release"
456    )]
457    pub max_request_body: usize,
458    /// Request processing timeout. Default: 120s.
459    /// Requests exceeding this duration receive 408 Request Timeout.
460    #[deprecated(
461        since = "0.13.0",
462        note = "use McpServerConfig::with_request_timeout(); direct field access will become pub(crate) in a future major release"
463    )]
464    pub request_timeout: Duration,
465    /// Graceful shutdown timeout. Default: 30s.
466    /// After the shutdown signal, in-flight requests have this long to finish.
467    #[deprecated(
468        since = "0.13.0",
469        note = "use McpServerConfig::with_shutdown_timeout(); direct field access will become pub(crate) in a future major release"
470    )]
471    pub shutdown_timeout: Duration,
472    /// Idle timeout for MCP sessions. Sessions with no activity for this
473    /// duration are closed automatically. Default: 20 minutes.
474    #[deprecated(
475        since = "0.13.0",
476        note = "use McpServerConfig::with_session_idle_timeout(); direct field access will become pub(crate) in a future major release"
477    )]
478    pub session_idle_timeout: Duration,
479    /// Bind rmcp session IDs to the authenticated identity using a stateless
480    /// signed wrapper. Default: `true`.
481    ///
482    /// Disabling this is an escape hatch for gateways that re-authenticate
483    /// each request under intentionally different labels; it reinstates the
484    /// CWE-384 risk that a leaked raw session ID can be replayed by another
485    /// authenticated identity.
486    pub session_binding: bool,
487    /// Shared HMAC secret used when session binding must verify across
488    /// multiple server instances. When unset, a process-random secret keeps
489    /// single-instance behaviour unchanged.
490    pub session_binding_secret: Option<SecretString>,
491    /// Optional external rmcp session store for cross-instance recovery.
492    pub session_store: Option<Arc<dyn SessionStore>>,
493    /// Optional external event store backing resumable SSE streams
494    /// (`Last-Event-ID`).
495    ///
496    /// A best-effort in-process resume already works without this, but only
497    /// for a live session whose events are still in rmcp''s bounded channel
498    /// cache. Supplying a store adds durable, cross-instance, and stateless
499    /// replay.
500    ///
501    /// The implementation owns stream isolation: rmcp passes only the last
502    /// event id to `replay_events_after`, never the stream, so event ids must
503    /// be globally unique and must identify their own stream. Replaying an
504    /// event onto a different stream violates the MCP transport spec.
505    pub event_store: Option<Arc<dyn EventStore>>,
506    /// Interval for SSE keep-alive pings. Prevents proxies and load
507    /// balancers from killing idle connections. Default: 15 seconds.
508    #[deprecated(
509        since = "0.13.0",
510        note = "use McpServerConfig::with_sse_keep_alive(); direct field access will become pub(crate) in a future major release"
511    )]
512    pub sse_keep_alive: Duration,
513    /// Callback invoked once the server is built, delivering a
514    /// [`ReloadHandle`] for hot-reloading auth keys and RBAC policy
515    /// at runtime (e.g. on SIGHUP). Only useful when auth/RBAC is enabled.
516    #[deprecated(
517        since = "0.13.0",
518        note = "use McpServerConfig::with_reload_callback(); direct field access will become pub(crate) in a future major release"
519    )]
520    pub on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
521    /// Additional application-specific routes merged into the top-level
522    /// router.  These routes **bypass** the MCP auth and RBAC middleware,
523    /// so the application is responsible for its own auth on them.
524    /// Handlers can extract [`PeerAddr`] (or
525    /// [`axum::extract::ConnectInfo<SocketAddr>`] for third-party
526    /// middleware compatibility) regardless of whether TLS is enabled.
527    #[deprecated(
528        since = "0.13.0",
529        note = "use McpServerConfig::with_extra_router(); direct field access will become pub(crate) in a future major release"
530    )]
531    pub extra_router: Option<axum::Router>,
532    /// Externally reachable base URL (e.g. `https://mcp.example.com`).
533    /// When set, OAuth metadata endpoints advertise this URL instead of
534    /// the listen address. Required when binding `0.0.0.0` behind a
535    /// reverse proxy or inside a container.
536    #[deprecated(
537        since = "0.13.0",
538        note = "use McpServerConfig::with_public_url(); direct field access will become pub(crate) in a future major release"
539    )]
540    pub public_url: Option<String>,
541    /// Log inbound HTTP request headers at DEBUG level.
542    /// Sensitive values remain redacted.
543    #[deprecated(
544        since = "0.13.0",
545        note = "use McpServerConfig::enable_request_header_logging(); direct field access will become pub(crate) in a future major release"
546    )]
547    pub log_request_headers: bool,
548    /// Expose build metadata (`build_git_sha`, `build_timestamp`,
549    /// `rust_version`) on the unauthenticated `/version` endpoint.
550    /// **Default: `false`** -- only `name`, `version`, and `rmcp_server_kit_version`
551    /// are served otherwise, so build fingerprints are not leaked to
552    /// anonymous callers. Enable via
553    /// [`McpServerConfig::expose_build_metadata`].
554    pub expose_build_metadata: bool,
555    /// Enable gzip/br response compression on MCP responses.
556    /// Defaults to `false` to preserve existing behaviour.
557    #[deprecated(
558        since = "0.13.0",
559        note = "use McpServerConfig::enable_compression(); direct field access will become pub(crate) in a future major release"
560    )]
561    pub compression_enabled: bool,
562    /// Minimum response body size (in bytes) before compression kicks in.
563    /// Only used when `compression_enabled` is true. Default: 1024.
564    #[deprecated(
565        since = "0.13.0",
566        note = "use McpServerConfig::enable_compression(); direct field access will become pub(crate) in a future major release"
567    )]
568    pub compression_min_size: u16,
569    /// Global cap on in-flight HTTP requests across the whole server.
570    /// When `Some`, requests over the cap receive 503 Service Unavailable
571    /// via `tower::load_shed`. Default: `None` (unlimited).
572    #[deprecated(
573        since = "0.13.0",
574        note = "use McpServerConfig::with_max_concurrent_requests(); direct field access will become pub(crate) in a future major release"
575    )]
576    pub max_concurrent_requests: Option<usize>,
577    /// Enable `/admin/*` diagnostic endpoints. Requires `auth` to be
578    /// configured and `enabled`. Default: `false`.
579    #[deprecated(
580        since = "0.13.0",
581        note = "use McpServerConfig::enable_admin(); direct field access will become pub(crate) in a future major release"
582    )]
583    pub admin_enabled: bool,
584    /// RBAC role required to access admin endpoints. Default: `"admin"`.
585    #[deprecated(
586        since = "0.13.0",
587        note = "use McpServerConfig::enable_admin(); direct field access will become pub(crate) in a future major release"
588    )]
589    pub admin_role: String,
590    /// Enable Prometheus metrics endpoint on a separate listener.
591    /// Requires the `metrics` crate feature.
592    #[cfg(feature = "metrics")]
593    #[deprecated(
594        since = "0.13.0",
595        note = "use McpServerConfig::with_metrics(); direct field access will become pub(crate) in a future major release"
596    )]
597    pub metrics_enabled: bool,
598    /// Bind address for the Prometheus metrics listener. Default: `127.0.0.1:9090`.
599    #[cfg(feature = "metrics")]
600    #[deprecated(
601        since = "0.13.0",
602        note = "use McpServerConfig::with_metrics(); direct field access will become pub(crate) in a future major release"
603    )]
604    pub metrics_bind: String,
605    /// Per-header overrides for the OWASP security headers emitted by
606    /// the global response middleware. See [`SecurityHeadersConfig`]
607    /// for the three-state semantic and validation rules.
608    #[deprecated(
609        since = "1.5.0",
610        note = "use McpServerConfig::with_security_headers(); direct field access will become pub(crate) in a future major release"
611    )]
612    pub security_headers: SecurityHeadersConfig,
613    /// Per-handshake deadline on the TLS accept path. Idle or slow-loris
614    /// connections are dropped once it elapses. Default: 10s.
615    ///
616    /// Startup-only: bound at listener construction, NOT hot-reloadable
617    /// via [`ReloadHandle`]. Ignored unless TLS is configured.
618    #[deprecated(
619        since = "1.9.0",
620        note = "use McpServerConfig::with_tls_handshake_timeout(); direct field access will become pub(crate) in a future major release"
621    )]
622    pub tls_handshake_timeout: Duration,
623    /// Cap on concurrently in-flight TLS handshakes. At saturation the
624    /// acceptor stops pulling new connections from the kernel backlog
625    /// (backpressure) instead of accepting and dropping. Default: 256.
626    ///
627    /// Startup-only: bound at listener construction, NOT hot-reloadable
628    /// via [`ReloadHandle`]. Ignored unless TLS is configured.
629    #[deprecated(
630        since = "1.9.0",
631        note = "use McpServerConfig::with_max_concurrent_tls_handshakes(); direct field access will become pub(crate) in a future major release"
632    )]
633    pub max_concurrent_tls_handshakes: usize,
634}
635
636/// Marker that wraps a value proven to satisfy its validation
637/// contract.
638///
639/// The only way to obtain `Validated<McpServerConfig>` is by calling
640/// [`McpServerConfig::validate`], which is the contract enforced at
641/// the type level by [`serve`] and [`serve_with_listener`]. The
642/// inner field is private, so downstream code cannot bypass
643/// validation by hand-constructing the wrapper.
644///
645/// Use [`Validated::as_inner`] for read-only borrowing. To mutate,
646/// recover the raw value with [`Validated::into_inner`] and
647/// re-validate.
648///
649/// # Example
650///
651/// ```no_run
652/// use rmcp_server_kit::transport::{McpServerConfig, Validated, serve};
653/// use rmcp::handler::server::ServerHandler;
654/// use rmcp::model::{ServerCapabilities, ServerInfo};
655///
656/// #[derive(Clone)]
657/// struct H;
658/// impl ServerHandler for H {
659///     fn get_info(&self) -> ServerInfo {
660///         ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
661///     }
662/// }
663///
664/// # async fn example() -> rmcp_server_kit::Result<()> {
665/// let config: Validated<McpServerConfig> =
666///     McpServerConfig::new("127.0.0.1:8080", "my-server", "0.1.0").validate()?;
667/// serve(config, || H).await
668/// # }
669/// ```
670///
671/// Forgetting `.validate()?` is a compile error:
672///
673/// ```compile_fail
674/// use rmcp_server_kit::transport::{McpServerConfig, serve};
675/// use rmcp::handler::server::ServerHandler;
676/// use rmcp::model::{ServerCapabilities, ServerInfo};
677///
678/// #[derive(Clone)]
679/// struct H;
680/// impl ServerHandler for H {
681///     fn get_info(&self) -> ServerInfo {
682///         ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
683///     }
684/// }
685///
686/// # async fn example() -> rmcp_server_kit::Result<()> {
687/// let config = McpServerConfig::new("127.0.0.1:8080", "my-server", "0.1.0");
688/// // Missing `.validate()?` -> mismatched types: expected
689/// // `Validated<McpServerConfig>`, found `McpServerConfig`.
690/// serve(config, || H).await
691/// # }
692/// ```
693#[allow(
694    missing_debug_implementations,
695    reason = "wraps T which may not implement Debug; manual impl below avoids leaking inner contents into logs"
696)]
697pub struct Validated<T>(T);
698
699impl<T> std::fmt::Debug for Validated<T> {
700    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
701        f.debug_struct("Validated").finish_non_exhaustive()
702    }
703}
704
705impl<T> Validated<T> {
706    /// Borrow the inner value.
707    #[must_use]
708    pub fn as_inner(&self) -> &T {
709        &self.0
710    }
711
712    /// Recover the raw value, discarding the validation proof.
713    ///
714    /// Re-validate before re-using the value with [`serve`] or
715    /// [`serve_with_listener`].
716    #[must_use]
717    pub fn into_inner(self) -> T {
718        self.0
719    }
720}
721
722#[allow(
723    deprecated,
724    reason = "internal builders/validators legitimately read/write the deprecated `pub` fields they were designed to manage"
725)]
726impl McpServerConfig {
727    /// Create a new server configuration with the given bind address,
728    /// server name, and version. All other fields use safe defaults.
729    ///
730    /// Use the chainable `with_*` / `enable_*` builder methods to
731    /// customize. Call [`McpServerConfig::validate`] to obtain a
732    /// [`Validated<McpServerConfig>`] proof token, which is required by
733    /// [`serve`] and [`serve_with_listener`].
734    #[must_use]
735    pub fn new(
736        bind_addr: impl Into<String>,
737        name: impl Into<String>,
738        version: impl Into<String>,
739    ) -> Self {
740        Self {
741            bind_addr: bind_addr.into(),
742            name: name.into(),
743            version: version.into(),
744            tls_cert_path: None,
745            tls_key_path: None,
746            auth: None,
747            rbac: None,
748            tool_list_filtering: true,
749            allowed_origins: Vec::new(),
750            tool_rate_limit: None,
751            readiness_check: None,
752            max_request_body: 1024 * 1024,
753            request_timeout: Duration::from_mins(2),
754            shutdown_timeout: Duration::from_secs(30),
755            session_idle_timeout: Duration::from_mins(20),
756            session_binding: true,
757            session_binding_secret: None,
758            session_store: None,
759            event_store: None,
760            sse_keep_alive: Duration::from_secs(15),
761            on_reload_ready: None,
762            extra_router: None,
763            public_url: None,
764            log_request_headers: false,
765            expose_build_metadata: false,
766            compression_enabled: false,
767            compression_min_size: 1024,
768            max_concurrent_requests: None,
769            admin_enabled: false,
770            admin_role: "admin".to_owned(),
771            #[cfg(feature = "metrics")]
772            metrics_enabled: false,
773            #[cfg(feature = "metrics")]
774            metrics_bind: "127.0.0.1:9090".into(),
775            security_headers: SecurityHeadersConfig::default(),
776            tls_handshake_timeout: DEFAULT_TLS_HANDSHAKE_TIMEOUT,
777            max_concurrent_tls_handshakes: DEFAULT_MAX_CONCURRENT_TLS_HANDSHAKES,
778            extra_route_rate_limit: None,
779            tool_rate_limit_burst: None,
780            extra_route_rate_limit_burst: None,
781            extra_route_rate_limit_exempt_paths: Vec::new(),
782            key_eviction_policy: KeyEvictionPolicy::default(),
783            trusted_forwarder_max_entries: crate::forwarded::MAX_SCANNED_ENTRIES,
784            trusted_proxies: Vec::new(),
785            forwarded_header: None,
786        }
787    }
788
789    // ---------------------------------------------------------------
790    // Builder methods (fluent, consume + return self).
791    //
792    // Each method is `#[must_use]` because dropping the returned
793    // `McpServerConfig` discards the configuration change.
794    // ---------------------------------------------------------------
795
796    /// Attach an authentication configuration. Required for
797    /// [`enable_admin`](Self::enable_admin) and any non-public deployment.
798    #[must_use]
799    pub fn with_auth(mut self, auth: AuthConfig) -> Self {
800        self.auth = Some(auth);
801        self
802    }
803
804    /// Override one or more of the OWASP security headers emitted on
805    /// every response. See [`SecurityHeadersConfig`] for the three-state
806    /// semantic (`None` = default, `Some("")` = omit, `Some(v)` =
807    /// override). Values are validated by [`Self::validate`].
808    #[must_use]
809    pub fn with_security_headers(mut self, headers: SecurityHeadersConfig) -> Self {
810        self.security_headers = headers;
811        self
812    }
813
814    /// Override the bind address (e.g. `127.0.0.1:8080`). Useful when the
815    /// final port is only known after pre-binding an ephemeral listener
816    /// (tests, dynamic-port deployments).
817    #[must_use]
818    pub fn with_bind_addr(mut self, addr: impl Into<String>) -> Self {
819        self.bind_addr = addr.into();
820        self
821    }
822
823    /// Attach an RBAC policy. Tool calls are checked against the policy
824    /// after authentication.
825    #[must_use]
826    pub fn with_rbac(mut self, rbac: Arc<RbacPolicy>) -> Self {
827        self.rbac = Some(rbac);
828        self
829    }
830
831    /// Enable or disable RBAC-derived filtering of `tools/list` responses.
832    ///
833    /// Filtering is meaningful only when RBAC is enabled and the request has
834    /// an authenticated non-empty role. When active, denied tools are hidden
835    /// from the list and the response cache scope is forced to private.
836    #[must_use]
837    pub const fn with_tool_list_filtering(mut self, enabled: bool) -> Self {
838        self.tool_list_filtering = enabled;
839        self
840    }
841
842    /// Configure TLS by providing the certificate and private key paths
843    /// (PEM). Both must be readable at startup. Without this call, the
844    /// server runs plain HTTP.
845    #[must_use]
846    pub fn with_tls(mut self, cert_path: impl Into<PathBuf>, key_path: impl Into<PathBuf>) -> Self {
847        self.tls_cert_path = Some(cert_path.into());
848        self.tls_key_path = Some(key_path.into());
849        self
850    }
851
852    /// Set the externally reachable base URL (e.g. `https://mcp.example.com`).
853    /// Required when binding `0.0.0.0` behind a reverse proxy or inside
854    /// a container so OAuth metadata and auto-derived origins resolve correctly.
855    #[must_use]
856    pub fn with_public_url(mut self, url: impl Into<String>) -> Self {
857        self.public_url = Some(url.into());
858        self
859    }
860
861    /// Replace the allowed Origin allow-list (DNS-rebinding protection).
862    /// When empty and [`with_public_url`](Self::with_public_url) is set,
863    /// the origin is auto-derived.
864    #[must_use]
865    pub fn with_allowed_origins<I, S>(mut self, origins: I) -> Self
866    where
867        I: IntoIterator<Item = S>,
868        S: Into<String>,
869    {
870        self.allowed_origins = origins.into_iter().map(Into::into).collect();
871        self
872    }
873
874    /// Merge an additional axum router at the top level. Routes added
875    /// here **bypass** rmcp-server-kit auth and RBAC; the application is responsible
876    /// for its own protection.
877    ///
878    /// To support that protection (e.g. per-IP rate limiting on
879    /// unauthenticated endpoints), every request served by [`serve`]
880    /// carries the client peer address regardless of whether TLS is
881    /// enabled: extract the framework-owned [`PeerAddr`] in your
882    /// handlers, or rely on [`axum::extract::ConnectInfo<SocketAddr>`]
883    /// for stock third-party middleware (e.g. per-IP rate-limit key
884    /// extractors). Neither extension exists under [`serve_stdio`],
885    /// which has no network peer.
886    ///
887    /// # Path collisions are only partially detected
888    ///
889    /// These routes are merged into the framework router. A route whose path
890    /// **exactly overlaps** a framework route (`/mcp`, `/healthz`, `/readyz`,
891    /// `/version`, and, when enabled, `/admin/status` and the OAuth
892    /// `/.well-known/*` endpoints) causes `axum::Router::merge` to **panic at
893    /// startup**. That panic is intentional upstream behaviour and is not
894    /// converted into a [`RmcpServerKitError`]: the release profile builds
895    /// with `panic = "abort"`, so catching it is not possible.
896    ///
897    /// A path that merely sits *under* a framework prefix without exactly
898    /// overlapping an existing route -- `/admin/custom` alongside
899    /// `/admin/status`, say -- does **not** panic and is **not** validated.
900    /// `axum::Router` exposes no route-enumeration API, so the framework
901    /// cannot inspect these paths. Avoiding such collisions is the caller's
902    /// responsibility.
903    ///
904    /// The Prometheus `/metrics` endpoint is unaffected: it is served on its
905    /// own listener, not merged here.
906    #[must_use]
907    pub fn with_extra_router(mut self, router: axum::Router) -> Self {
908        self.extra_router = Some(router);
909        self
910    }
911
912    /// Override the forwarding-chain scan cap for trusted-forwarder mode.
913    ///
914    /// Defaults to 16. Validated to `1..=64` by [`Self::validate`]: `0` would
915    /// pin every client to the proxy address, and an unbounded value would
916    /// re-open the header-bomb vector the cap exists to close.
917    #[must_use]
918    pub const fn with_trusted_forwarder_max_entries(mut self, max_entries: usize) -> Self {
919        self.trusted_forwarder_max_entries = max_entries;
920        self
921    }
922
923    /// Install an async readiness probe for `/readyz`. Without this call,
924    /// `/readyz` mirrors `/healthz` (always 200 OK).
925    #[must_use]
926    pub fn with_readiness_check(mut self, check: ReadinessCheck) -> Self {
927        self.readiness_check = Some(check);
928        self
929    }
930
931    /// Override the maximum request body (bytes). Must be `> 0`.
932    /// Default: 1 MiB.
933    #[must_use]
934    pub fn with_max_request_body(mut self, bytes: usize) -> Self {
935        self.max_request_body = bytes;
936        self
937    }
938
939    /// Override the per-request processing timeout. Default: 2 minutes.
940    #[must_use]
941    pub fn with_request_timeout(mut self, timeout: Duration) -> Self {
942        self.request_timeout = timeout;
943        self
944    }
945
946    /// Override the graceful shutdown grace period. Default: 30 seconds.
947    #[must_use]
948    pub fn with_shutdown_timeout(mut self, timeout: Duration) -> Self {
949        self.shutdown_timeout = timeout;
950        self
951    }
952
953    /// Override the MCP session idle timeout. Default: 20 minutes.
954    #[must_use]
955    pub fn with_session_idle_timeout(mut self, timeout: Duration) -> Self {
956        self.session_idle_timeout = timeout;
957        self
958    }
959
960    /// Enable or disable stateless binding of rmcp session IDs to the
961    /// authenticated identity. Enabled by default; disabling reinstates the
962    /// CWE-384 risk from reusable unbound session IDs.
963    #[must_use]
964    pub const fn with_session_binding(mut self, enabled: bool) -> Self {
965        self.session_binding = enabled;
966        self
967    }
968
969    /// Set the shared HMAC secret used to bind MCP session IDs to identities.
970    #[must_use]
971    pub fn with_session_binding_secret(mut self, secret: SecretString) -> Self {
972        self.session_binding_secret = Some(secret);
973        self
974    }
975
976    /// Persist rmcp session state in an application-provided external store.
977    #[must_use]
978    pub fn with_session_store(mut self, session_store: Arc<dyn SessionStore>) -> Self {
979        self.session_store = Some(session_store);
980        self
981    }
982
983    /// Back resumable SSE streams with an application-provided event store.
984    ///
985    /// See [`McpServerConfig::event_store`] for the stream-isolation
986    /// obligation this places on the implementation.
987    #[must_use]
988    pub fn with_event_store(mut self, event_store: Arc<dyn EventStore>) -> Self {
989        self.event_store = Some(event_store);
990        self
991    }
992
993    /// Override the SSE keep-alive interval. Default: 15 seconds.
994    #[must_use]
995    pub fn with_sse_keep_alive(mut self, interval: Duration) -> Self {
996        self.sse_keep_alive = interval;
997        self
998    }
999
1000    /// Cap the global number of in-flight HTTP requests via
1001    /// `tower::load_shed`. Excess requests receive 503 Service Unavailable.
1002    /// Default: unlimited.
1003    #[must_use]
1004    pub fn with_max_concurrent_requests(mut self, limit: usize) -> Self {
1005        self.max_concurrent_requests = Some(limit);
1006        self
1007    }
1008
1009    /// Override the per-handshake deadline on the TLS accept path.
1010    /// Idle or slow-loris connections are dropped once it elapses.
1011    /// Default: 10s. Must be greater than zero.
1012    ///
1013    /// Startup-only: the value is bound at listener construction and is
1014    /// NOT hot-reloadable via [`ReloadHandle`]. Has no effect unless TLS
1015    /// is configured via [`Self::with_tls`].
1016    #[must_use]
1017    pub fn with_tls_handshake_timeout(mut self, timeout: Duration) -> Self {
1018        self.tls_handshake_timeout = timeout;
1019        self
1020    }
1021
1022    /// Override the cap on concurrently in-flight TLS handshakes. At
1023    /// saturation the acceptor stops pulling new connections from the
1024    /// kernel backlog (backpressure) instead of accepting and dropping.
1025    /// Default: 256. Must be greater than zero.
1026    ///
1027    /// Startup-only: the value is bound at listener construction and is
1028    /// NOT hot-reloadable via [`ReloadHandle`]. Has no effect unless TLS
1029    /// is configured via [`Self::with_tls`].
1030    #[must_use]
1031    pub fn with_max_concurrent_tls_handshakes(mut self, limit: usize) -> Self {
1032        self.max_concurrent_tls_handshakes = limit;
1033        self
1034    }
1035
1036    /// Cap tool invocations per source IP per minute. Enforced on every
1037    /// `tools/call` request.
1038    #[must_use]
1039    pub fn with_tool_rate_limit(mut self, per_minute: u32) -> Self {
1040        self.tool_rate_limit = Some(per_minute);
1041        self
1042    }
1043
1044    /// Cap requests per source IP per minute on routes merged via
1045    /// [`with_extra_router`](Self::with_extra_router) - the natural
1046    /// protection for unauthenticated application endpoints (OAuth
1047    /// callbacks, registration, …) that bypass auth/RBAC by design.
1048    ///
1049    /// Must be greater than zero (validated by
1050    /// [`validate`](Self::validate)). Startup-only. See the
1051    /// `extra_route_rate_limit` field docs for keying semantics and
1052    /// caveats (direct peer only, IPv6 rotation, proxy collapse,
1053    /// bounded-memory shared-fate under key spray).
1054    #[must_use]
1055    pub fn with_extra_route_rate_limit(mut self, per_minute: u32) -> Self {
1056        self.extra_route_rate_limit = Some(per_minute);
1057        self
1058    }
1059
1060    /// Set the burst capacity for the tool rate limiter (bucket size;
1061    /// the sustained rate stays [`with_tool_rate_limit`](Self::with_tool_rate_limit)).
1062    /// Requires the tool rate limit to be set; must be greater than zero
1063    /// (both validated by [`validate`](Self::validate)).
1064    #[must_use]
1065    pub fn with_tool_rate_limit_burst(mut self, burst: u32) -> Self {
1066        self.tool_rate_limit_burst = Some(burst);
1067        self
1068    }
1069
1070    /// Set the burst capacity for the extra-route rate limiter (bucket
1071    /// size; the sustained rate stays
1072    /// [`with_extra_route_rate_limit`](Self::with_extra_route_rate_limit)).
1073    /// Requires the extra-route rate limit to be set; must be greater
1074    /// than zero (both validated by [`validate`](Self::validate)).
1075    #[must_use]
1076    pub fn with_extra_route_rate_limit_burst(mut self, burst: u32) -> Self {
1077        self.extra_route_rate_limit_burst = Some(burst);
1078        self
1079    }
1080
1081    /// Exempt specific request paths from the extra-route rate limiter.
1082    ///
1083    /// Matching is a **raw exact string comparison** against
1084    /// `req.uri().path()` - no globs, no prefixes, no normalization
1085    /// (trailing slashes, percent-encoding, and dot-segments must match
1086    /// byte-for-byte). The check is fail-closed: anything not listed
1087    /// stays rate-limited, so a mismatch can only keep a request
1088    /// limited, never accidentally exempt it. The exemption is checked
1089    /// before key extraction, so exempt requests consume no limiter
1090    /// budget and never appear in deny telemetry.
1091    ///
1092    /// Typical use: the RFC 8414 authorization-server metadata document
1093    /// (`/.well-known/oauth-authorization-server`), fetched by MCP
1094    /// clients on every connect.
1095    ///
1096    /// Requires the extra-route rate limit to be set
1097    /// ([`with_extra_route_rate_limit`](Self::with_extra_route_rate_limit));
1098    /// each entry must be non-empty and start with `/` (both validated
1099    /// by [`validate`](Self::validate)). Startup-only.
1100    #[must_use]
1101    pub fn with_extra_route_rate_limit_exempt_paths<I, S>(mut self, paths: I) -> Self
1102    where
1103        I: IntoIterator<Item = S>,
1104        S: Into<String>,
1105    {
1106        self.extra_route_rate_limit_exempt_paths = paths.into_iter().map(Into::into).collect();
1107        self
1108    }
1109
1110    /// Set the tracked-key full-table policy for all per-IP rate limiters.
1111    #[must_use]
1112    pub const fn with_key_eviction_policy(mut self, policy: KeyEvictionPolicy) -> Self {
1113        self.key_eviction_policy = policy;
1114        self
1115    }
1116
1117    /// Enable **trusted-forwarder mode**: requests whose direct peer is
1118    /// inside one of these networks (CIDRs or bare IPs) have their
1119    /// client IP resolved from the forwarding header via the
1120    /// rightmost-untrusted walk; all per-IP rate limiters then key by
1121    /// the resolved [`ClientIp`]. Headers from peers outside these
1122    /// networks are ignored entirely.
1123    ///
1124    /// Only enable when **all** ingress paths traverse the listed
1125    /// proxies - otherwise direct clients keep their own buckets and
1126    /// proxied clients collapse into the proxy's. Entries are validated
1127    /// at [`validate`](Self::validate) time. Startup-only.
1128    #[must_use]
1129    pub fn with_trusted_proxies<I, S>(mut self, proxies: I) -> Self
1130    where
1131        I: IntoIterator<Item = S>,
1132        S: Into<String>,
1133    {
1134        self.trusted_proxies = proxies.into_iter().map(Into::into).collect();
1135        self
1136    }
1137
1138    /// Select which forwarding header trusted-forwarder mode reads
1139    /// (default: `X-Forwarded-For`). Requires
1140    /// [`with_trusted_proxies`](Self::with_trusted_proxies) to be set
1141    /// (validated).
1142    #[must_use]
1143    pub fn with_forwarded_header(mut self, mode: ForwardedHeaderMode) -> Self {
1144        self.forwarded_header = Some(mode);
1145        self
1146    }
1147
1148    /// Register a callback that receives the [`ReloadHandle`] after the
1149    /// server is built. Use it to wire SIGHUP-style hot reloads of API
1150    /// keys and RBAC policy.
1151    #[must_use]
1152    pub fn with_reload_callback<F>(mut self, callback: F) -> Self
1153    where
1154        F: FnOnce(ReloadHandle) + Send + 'static,
1155    {
1156        self.on_reload_ready = Some(Box::new(callback));
1157        self
1158    }
1159
1160    /// Enable gzip/brotli response compression on MCP responses.
1161    /// `min_size` is the smallest body size (bytes) eligible for
1162    /// compression. Default min size: 1024.
1163    #[must_use]
1164    pub fn enable_compression(mut self, min_size: u16) -> Self {
1165        self.compression_enabled = true;
1166        self.compression_min_size = min_size;
1167        self
1168    }
1169
1170    /// Enable `/admin/*` diagnostic endpoints. Requires
1171    /// [`with_auth`](Self::with_auth) to be set and enabled; otherwise
1172    /// [`validate`](Self::validate) returns an error. `role` is the RBAC
1173    /// role gate (default: `"admin"`).
1174    #[must_use]
1175    pub fn enable_admin(mut self, role: impl Into<String>) -> Self {
1176        self.admin_enabled = true;
1177        self.admin_role = role.into();
1178        self
1179    }
1180
1181    /// Log inbound HTTP request headers at DEBUG level. Sensitive
1182    /// values remain redacted by the logging layer.
1183    #[must_use]
1184    pub fn enable_request_header_logging(mut self) -> Self {
1185        self.log_request_headers = true;
1186        self
1187    }
1188
1189    /// Expose build metadata (`build_git_sha`, `build_timestamp`,
1190    /// `rust_version`) on the unauthenticated `/version` endpoint. Off by
1191    /// default so `/version` reveals only `name`, `version`, and
1192    /// `rmcp_server_kit_version`.
1193    #[must_use]
1194    pub fn expose_build_metadata(mut self) -> Self {
1195        self.expose_build_metadata = true;
1196        self
1197    }
1198
1199    /// Enable the Prometheus metrics listener on `bind` (e.g.
1200    /// `127.0.0.1:9090`). Requires the `metrics` crate feature.
1201    #[cfg(feature = "metrics")]
1202    #[must_use]
1203    pub fn with_metrics(mut self, bind: impl Into<String>) -> Self {
1204        self.metrics_enabled = true;
1205        self.metrics_bind = bind.into();
1206        self
1207    }
1208
1209    /// Validate the configuration and consume `self`, returning a
1210    /// [`Validated<McpServerConfig>`] proof token required by [`serve`]
1211    /// and [`serve_with_listener`]. This is the only way to construct
1212    /// `Validated<McpServerConfig>`, so the type system guarantees
1213    /// validation has run before the server starts.
1214    ///
1215    /// Checks:
1216    ///
1217    /// 1. `admin_enabled` requires `auth` to be configured and enabled.
1218    /// 2. `tls_cert_path` and `tls_key_path` must both be set or both
1219    ///    be unset.
1220    /// 3. `bind_addr` must parse as a [`SocketAddr`].
1221    /// 4. `public_url`, when set, must start with `http://` or `https://`.
1222    /// 5. Each entry in `allowed_origins` must start with `http://` or
1223    ///    `https://`.
1224    /// 6. `max_request_body` must be greater than zero.
1225    /// 7. When the `oauth` feature is enabled and an [`OAuthConfig`] is
1226    ///    present, all OAuth URL fields (`jwks_uri`, `proxy.authorize_url`,
1227    ///    `proxy.token_url`, `proxy.introspection_url`,
1228    ///    `proxy.revocation_url`, `token_exchange.token_url`) must parse
1229    ///    and use the `https` scheme. Set
1230    ///    [`OAuthConfig::allow_http_oauth_urls`] to permit `http://`
1231    ///    targets (strongly discouraged in production - see the field-level
1232    ///    docs for the threat model).
1233    ///
1234    /// [`OAuthConfig`]: crate::oauth::OAuthConfig
1235    /// [`OAuthConfig::allow_http_oauth_urls`]: crate::oauth::OAuthConfig::allow_http_oauth_urls
1236    ///
1237    /// # Errors
1238    ///
1239    /// Returns [`RmcpServerKitError::Config`] with a human-readable message on
1240    /// the first validation failure.
1241    pub fn validate(self) -> Result<Validated<Self>, RmcpServerKitError> {
1242        self.check()?;
1243        Ok(Validated(self))
1244    }
1245
1246    /// Validate the burst knobs: every burst must be greater than zero
1247    /// when set, and the two top-level bursts require their base limiter
1248    /// to be configured. The auth bursts
1249    /// (`RateLimitConfig::{burst, pre_auth_burst}`) have no orphan rule:
1250    /// their base rates always resolve (`max_attempts_per_minute` is
1251    /// mandatory; the pre-auth base derives from it when unset).
1252    fn check_burst_knobs(&self) -> Result<(), RmcpServerKitError> {
1253        if self.tool_rate_limit_burst == Some(0) {
1254            return Err(RmcpServerKitError::Config(
1255                "tool_rate_limit_burst must be greater than zero".into(),
1256            ));
1257        }
1258        if self.extra_route_rate_limit_burst == Some(0) {
1259            return Err(RmcpServerKitError::Config(
1260                "extra_route_rate_limit_burst must be greater than zero".into(),
1261            ));
1262        }
1263        if self.trusted_forwarder_max_entries == 0
1264            || self.trusted_forwarder_max_entries
1265                > crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES
1266        {
1267            return Err(RmcpServerKitError::Config(format!(
1268                "trusted_forwarder_max_entries must be in 1..={}, got {}",
1269                crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES,
1270                self.trusted_forwarder_max_entries
1271            )));
1272        }
1273        if self.tool_rate_limit_burst.is_some() && self.tool_rate_limit.is_none() {
1274            return Err(RmcpServerKitError::Config(
1275                "tool_rate_limit_burst requires tool_rate_limit to be set".into(),
1276            ));
1277        }
1278        if self.extra_route_rate_limit_burst.is_some() && self.extra_route_rate_limit.is_none() {
1279            return Err(RmcpServerKitError::Config(
1280                "extra_route_rate_limit_burst requires extra_route_rate_limit to be set".into(),
1281            ));
1282        }
1283        if !self.extra_route_rate_limit_exempt_paths.is_empty()
1284            && self.extra_route_rate_limit.is_none()
1285        {
1286            return Err(RmcpServerKitError::Config(
1287                "extra_route_rate_limit_exempt_paths requires extra_route_rate_limit to be set"
1288                    .into(),
1289            ));
1290        }
1291        for path in &self.extra_route_rate_limit_exempt_paths {
1292            if path.is_empty() || !path.starts_with('/') {
1293                return Err(RmcpServerKitError::Config(format!(
1294                    "extra_route_rate_limit_exempt_paths entries must be non-empty and start with '/': {path:?}"
1295                )));
1296            }
1297        }
1298        if let Some(rl) = self.auth.as_ref().and_then(|a| a.rate_limit.as_ref()) {
1299            if rl.burst == Some(0) {
1300                return Err(RmcpServerKitError::Config(
1301                    "auth rate_limit.burst must be greater than zero".into(),
1302                ));
1303            }
1304            if rl.pre_auth_burst == Some(0) {
1305                return Err(RmcpServerKitError::Config(
1306                    "auth rate_limit.pre_auth_burst must be greater than zero".into(),
1307                ));
1308            }
1309        }
1310        Ok(())
1311    }
1312
1313    /// Validate the trusted-forwarder knobs: every `trusted_proxies`
1314    /// entry must parse as a CIDR (`ipnet::IpNet`) or a bare IP
1315    /// (normalized to a host network), and `forwarded_header` requires a
1316    /// nonempty proxy list (fail-fast over a silent no-op).
1317    fn check_trusted_forwarder(&self) -> Result<(), RmcpServerKitError> {
1318        for entry in &self.trusted_proxies {
1319            validate_trusted_proxy_entry(entry).map_err(RmcpServerKitError::Config)?;
1320        }
1321        if self.forwarded_header.is_some() && self.trusted_proxies.is_empty() {
1322            return Err(RmcpServerKitError::Config(
1323                "forwarded_header requires trusted_proxies to be nonempty".into(),
1324            ));
1325        }
1326        Ok(())
1327    }
1328
1329    fn check_session_binding_config(&self) -> Result<(), RmcpServerKitError> {
1330        if self.session_store.is_some()
1331            && self.session_binding
1332            && self.auth.as_ref().is_some_and(|auth| auth.enabled)
1333            && self.session_binding_secret.is_none()
1334        {
1335            return Err(RmcpServerKitError::Config(
1336                "session_store with session_binding enabled and auth configured requires \
1337                 session_binding_secret: a shared secret is required for cross-instance \
1338                 session verification"
1339                    .into(),
1340            ));
1341        }
1342
1343        if let Some(secret) = &self.session_binding_secret {
1344            crate::session_binding::validate_configured_secret(
1345                "session_binding_secret",
1346                secret.expose_secret(),
1347            )?;
1348        }
1349        Ok(())
1350    }
1351
1352    /// Run the validation checks without consuming `self`. Used by
1353    /// internal call sites (e.g. tests) that need to inspect a config
1354    /// without taking ownership.
1355    fn check(&self) -> Result<(), RmcpServerKitError> {
1356        // Delegated to `check_shared_config_invariants` so this validator and
1357        // `validate_server_config` cannot drift in ordering. Wording stays
1358        // local: this type names which TLS half is missing, the TOML validator
1359        // emits one combined message.
1360        //
1361        // 1. admin <-> auth dependency mirrors the runtime check in
1362        //    `build_app_router`: admin endpoints require an auth state, built
1363        //    only when `auth` is `Some` *and* `enabled`.
1364        // 2. TLS cert / key must be paired.
1365        // 2b. mTLS requires TLS. A plaintext listener never performs a TLS
1366        //     handshake, so it cannot populate `ConnectInfo<TlsConnInfo>` and
1367        //     the client-certificate identity is never extracted. Accepting
1368        //     this combination silently disables client-cert authentication
1369        //     for an operator who believes it is switched on.
1370        if let Err(violation) = crate::config::check_shared_config_invariants(
1371            self.admin_enabled,
1372            self.auth.as_ref().is_some_and(|a| a.enabled),
1373            self.tls_cert_path.is_some(),
1374            self.tls_key_path.is_some(),
1375            self.auth.as_ref().is_some_and(|a| a.mtls.is_some()),
1376        ) {
1377            return Err(RmcpServerKitError::Config(
1378                match violation {
1379                    crate::config::SharedConfigViolation::AdminRequiresAuth => {
1380                        "admin_enabled=true requires auth to be configured and enabled"
1381                    }
1382                    crate::config::SharedConfigViolation::TlsCertWithoutKey => {
1383                        "tls_cert_path is set but tls_key_path is missing"
1384                    }
1385                    crate::config::SharedConfigViolation::TlsKeyWithoutCert => {
1386                        "tls_key_path is set but tls_cert_path is missing"
1387                    }
1388                    crate::config::SharedConfigViolation::MtlsRequiresTls => {
1389                        "auth.mtls requires TLS: set both tls_cert_path and tls_key_path \
1390                         (mTLS client certificates cannot be verified on a plaintext listener)"
1391                    }
1392                }
1393                .into(),
1394            ));
1395        }
1396
1397        if let Some(auth) = &self.auth {
1398            auth.validate_api_key_names()?;
1399        }
1400
1401        // 3. bind_addr parses
1402        if self.bind_addr.parse::<SocketAddr>().is_err() {
1403            return Err(RmcpServerKitError::Config(format!(
1404                "bind_addr {:?} is not a valid socket address (expected e.g. 127.0.0.1:8080)",
1405                self.bind_addr
1406            )));
1407        }
1408
1409        // 4. public_url scheme
1410        if let Some(ref url) = self.public_url
1411            && !(url.starts_with("http://") || url.starts_with("https://"))
1412        {
1413            return Err(RmcpServerKitError::Config(format!(
1414                "public_url {url:?} must start with http:// or https://"
1415            )));
1416        }
1417
1418        // 5. allowed_origins scheme
1419        for origin in &self.allowed_origins {
1420            if !(origin.starts_with("http://") || origin.starts_with("https://")) {
1421                return Err(RmcpServerKitError::Config(format!(
1422                    "allowed_origins entry {origin:?} must start with http:// or https://"
1423                )));
1424            }
1425        }
1426
1427        // 6. max_request_body > 0
1428        if self.max_request_body == 0 {
1429            return Err(RmcpServerKitError::Config(
1430                "max_request_body must be greater than zero".into(),
1431            ));
1432        }
1433
1434        // 6b. extra_route_rate_limit, when set, must be > 0. Unlike the
1435        // legacy tool_rate_limit (which clamps 0 to its default at
1436        // construction), new knobs fail fast on nonsensical values.
1437        if self.extra_route_rate_limit == Some(0) {
1438            return Err(RmcpServerKitError::Config(
1439                "extra_route_rate_limit must be greater than zero".into(),
1440            ));
1441        }
1442
1443        // 6c. Burst knobs (extracted helper).
1444        self.check_burst_knobs()?;
1445
1446        // 6d. Trusted-forwarder knobs (extracted helper).
1447        self.check_trusted_forwarder()?;
1448
1449        // 7. OAuth URL fields enforce HTTPS (unless `allow_http_oauth_urls`)
1450        #[cfg(feature = "oauth")]
1451        if let Some(auth_cfg) = &self.auth
1452            && let Some(oauth_cfg) = &auth_cfg.oauth
1453        {
1454            oauth_cfg.validate()?;
1455        }
1456
1457        self.check_session_binding_config()?;
1458
1459        // 8. Security-header overrides parse as valid HTTP header values,
1460        //    and HSTS does not smuggle in a `preload` directive.
1461        validate_security_headers(&self.security_headers)?;
1462
1463        // 9. max_concurrent_requests must be > 0 when set. Zero would
1464        //    deadlock the global concurrency limiter and reject every
1465        //    request. Mirrors the TOML-side check in `src/config.rs`.
1466        if self.max_concurrent_requests == Some(0) {
1467            return Err(RmcpServerKitError::Config(
1468                "max_concurrent_requests must be greater than zero when set".into(),
1469            ));
1470        }
1471
1472        // 10. Auth rate-limit `max_tracked_keys` must be > 0. A zero cap
1473        //     would force `BoundedKeyedLimiter` to evict on every insert
1474        //     and effectively disable rate limiting.
1475        if let Some(auth_cfg) = &self.auth
1476            && let Some(rl) = &auth_cfg.rate_limit
1477            && rl.max_tracked_keys == 0
1478        {
1479            return Err(RmcpServerKitError::Config(
1480                "auth.rate_limit.max_tracked_keys must be greater than zero".into(),
1481            ));
1482        }
1483
1484        check_auth_capacity_knobs(self.auth.as_ref())?;
1485
1486        // 11. tls_handshake_timeout must be > 0. A zero deadline would
1487        //     reap every handshake before it could complete, rejecting
1488        //     all TLS connections. Mirrors the TOML-side check in
1489        //     `src/config.rs`.
1490        if self.tls_handshake_timeout == Duration::ZERO {
1491            return Err(RmcpServerKitError::Config(
1492                "tls_handshake_timeout must be greater than zero".into(),
1493            ));
1494        }
1495
1496        // 12. max_concurrent_tls_handshakes must be > 0. A zero-permit
1497        //     semaphore would never admit a handshake, deadlocking the
1498        //     TLS accept path. Mirrors the TOML-side check in
1499        //     `src/config.rs`.
1500        if self.max_concurrent_tls_handshakes == 0 {
1501            return Err(RmcpServerKitError::Config(
1502                "max_concurrent_tls_handshakes must be greater than zero".into(),
1503            ));
1504        }
1505
1506        Ok(())
1507    }
1508}
1509
1510/// Handle for hot-reloading server configuration without restart.
1511///
1512/// Obtained via [`McpServerConfig::on_reload_ready`].
1513/// All swap operations are lock-free and wait-free -- in-flight requests
1514/// finish with the old values while new requests see the update immediately.
1515#[allow(
1516    missing_debug_implementations,
1517    reason = "contains Arc<AuthState> with non-Debug fields"
1518)]
1519pub struct ReloadHandle {
1520    auth: Option<Arc<AuthState>>,
1521    rbac: Option<Arc<ArcSwap<RbacPolicy>>>,
1522    crl_set: Option<Arc<CrlSet>>,
1523}
1524
1525impl ReloadHandle {
1526    /// Validate and atomically replace the API key list used by the auth
1527    /// middleware.
1528    ///
1529    /// The new keys are validated before any swap: a blank key name is
1530    /// rejected because it would collapse distinct principals to one
1531    /// session-binding fingerprint (CWE-384). On error the previously
1532    /// installed keys stay in place. With no auth state configured this is a
1533    /// successful no-op.
1534    ///
1535    /// This is the fallible counterpart to [`Self::reload_auth_keys`]; prefer
1536    /// it so a rejected reload is observable rather than only logged.
1537    ///
1538    /// # Errors
1539    ///
1540    /// Returns [`RmcpServerKitError::Config`] naming the first blank-named
1541    /// index; the currently installed key list is left untouched.
1542    pub fn try_reload_auth_keys(
1543        &self,
1544        keys: Vec<crate::auth::ApiKeyEntry>,
1545    ) -> Result<(), RmcpServerKitError> {
1546        if let Some(ref auth) = self.auth {
1547            auth.try_reload_keys(keys)
1548        } else {
1549            Ok(())
1550        }
1551    }
1552
1553    /// Atomically replace the API key list used by the auth middleware.
1554    ///
1555    /// Compatibility wrapper over [`Self::try_reload_auth_keys`]. Invalid
1556    /// input (a blank key name) is **rejected, not installed**: the error is
1557    /// logged via `tracing::error!` and the previously installed keys are left
1558    /// untouched, so a bad reload never silently swaps in
1559    /// session-binding-colliding keys. Prefer [`Self::try_reload_auth_keys`]
1560    /// to observe and handle the failure directly.
1561    pub fn reload_auth_keys(&self, keys: Vec<crate::auth::ApiKeyEntry>) {
1562        if let Err(error) = self.try_reload_auth_keys(keys) {
1563            tracing::error!(%error, "API key hot reload rejected: keys left unchanged");
1564        }
1565    }
1566
1567    /// Atomically replace the RBAC policy used by the RBAC middleware.
1568    pub fn reload_rbac(&self, policy: RbacPolicy) {
1569        if let Some(ref rbac) = self.rbac {
1570            rbac.store(Arc::new(policy));
1571            tracing::info!("RBAC policy reloaded");
1572        }
1573    }
1574
1575    /// Force an immediate refresh of all cached mTLS CRLs.
1576    ///
1577    /// # Errors
1578    ///
1579    /// Returns an error if CRL refresh is unavailable or verifier rebuild fails.
1580    // cancel-safe: delegates to `CrlSet::force_refresh`, which stages every
1581    // fetch locally and publishes the cache and verifier state together under
1582    // `commit_lock` with no await between them. Cancellation therefore leaves
1583    // either the previous or the new generation, never a mixed one.
1584    pub async fn refresh_crls(&self) -> Result<(), RmcpServerKitError> {
1585        let Some(ref crl_set) = self.crl_set else {
1586            return Err(RmcpServerKitError::Config(
1587                "CRL refresh requested but mTLS CRL support is not configured".into(),
1588            ));
1589        };
1590
1591        crl_set.force_refresh().await
1592    }
1593}
1594
1595/// Generic MCP HTTP server.
1596///
1597/// Wraps an axum server with `/healthz` and `/mcp` endpoints.
1598/// When `tls_cert_path` and `tls_key_path` are both set, the server binds
1599/// with TLS (rustls). Optionally supports mTLS client certificate auth.
1600///
1601/// # Errors
1602///
1603/// Returns an error if the TCP listener cannot bind, TLS config is invalid,
1604/// or the server fails.
1605// NOTE: cognitive complexity reduced from 111/25 to 83/25 by
1606// extracting `run_server` (serve-loop tail) and `install_oauth_proxy_routes`.
1607// Remaining flow is a linear router builder: middleware layering, feature-
1608// gated auth/RBAC wiring, and PRM/metrics installation. Further extraction
1609// would require threading many `&mut Router` helpers and hurt readability
1610// of the layer order (which is security-relevant and must stay visible).
1611#[allow(
1612    clippy::too_many_lines,
1613    clippy::cognitive_complexity,
1614    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"
1615)]
1616/// Internal bundle of values produced by [`build_app_router`] and
1617/// consumed by [`serve`] / [`serve_with_listener`] when driving the
1618/// HTTP listener.
1619struct AppRunParams {
1620    /// TLS cert/key paths when TLS is configured.
1621    tls_paths: Option<(PathBuf, PathBuf)>,
1622    /// Per-handshake deadline on the TLS accept path.
1623    tls_handshake_timeout: Duration,
1624    /// Cap on concurrently in-flight TLS handshakes.
1625    max_concurrent_tls_handshakes: usize,
1626    /// mTLS configuration when mutual-TLS auth is enabled.
1627    mtls_config: Option<MtlsConfig>,
1628    /// Graceful shutdown drain window.
1629    shutdown_timeout: Duration,
1630    /// Shared auth state used by hot-reload callbacks.
1631    auth_state: Option<Arc<AuthState>>,
1632    /// Hot-reloadable RBAC state used by reload callbacks.
1633    rbac_swap: Arc<ArcSwap<RbacPolicy>>,
1634    /// Optional callback that receives the final [`ReloadHandle`].
1635    on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
1636    /// Server-internal lifecycle cancellation token. Cancelled by
1637    /// [`run_server`] once the shutdown trigger fires, stopping the metrics
1638    /// listener, the CRL refresher, and any external shutdown wiring.
1639    ct: CancellationToken,
1640    /// Cancellation token handed to the MCP service, kept SEPARATE from
1641    /// [`Self::ct`].
1642    ///
1643    /// Cancelling it terminates in-flight MCP sessions and SSE streams, so it
1644    /// must not fire at the *start* of the grace period - that truncates
1645    /// responses a normal SIGTERM rollout is supposed to let finish. It is
1646    /// cancelled only after axum has drained, or when the force-exit timer
1647    /// wins. The force-exit path must still cancel it, or a stuck stream turns
1648    /// a truncation bug into a shutdown hang.
1649    session_ct: CancellationToken,
1650    /// `"http"` or `"https"` -- used only for boot-time logging.
1651    scheme: &'static str,
1652    /// Server name -- used only for boot-time logging.
1653    name: String,
1654}
1655
1656/// Build the full application axum [`axum::Router`] (MCP route +
1657/// middleware stack + admin + OAuth + health endpoints + security
1658/// headers + CORS + compression + concurrency limit + origin check)
1659/// and the [`AppRunParams`] needed to drive it.
1660///
1661/// This is the shared core of [`serve`] and [`serve_with_listener`].
1662/// It performs *no* network I/O: callers are responsible for binding
1663/// (or accepting a pre-bound) [`TcpListener`] and invoking
1664/// [`run_server`].
1665#[allow(
1666    clippy::cognitive_complexity,
1667    reason = "router assembly is intrinsically sequential; splitting harms readability"
1668)]
1669#[allow(
1670    deprecated,
1671    reason = "internal router assembly reads deprecated `pub` config fields by design until 1.0 makes them pub(crate)"
1672)]
1673fn build_app_router<H, F>(
1674    mut config: McpServerConfig,
1675    handler_factory: F,
1676) -> anyhow::Result<(axum::Router, AppRunParams)>
1677where
1678    H: ServerHandler + 'static,
1679    F: Fn() -> H + Send + Sync + Clone + 'static,
1680{
1681    let ct = CancellationToken::new();
1682    let session_ct = CancellationToken::new();
1683
1684    let allowed_hosts = derive_allowed_hosts(&config.bind_addr, config.public_url.as_deref());
1685    tracing::info!(allowed_hosts = %allowed_hosts.join(", "), "configured Streamable HTTP allowed hosts");
1686
1687    if config.max_concurrent_requests.is_none() {
1688        tracing::warn!(
1689            "max_concurrent_requests is unset: in-flight HTTP requests are unlimited; \
1690             set McpServerConfig::with_max_concurrent_requests or front the server with \
1691             an external concurrency limit"
1692        );
1693    }
1694
1695    // Build the RBAC policy swap before constructing the MCP service so the
1696    // universal handler wrapper and RBAC middleware share hot-reload state.
1697    let rbac_swap = Arc::new(ArcSwap::new(
1698        config
1699            .rbac
1700            .clone()
1701            .unwrap_or_else(|| Arc::new(RbacPolicy::disabled())),
1702    ));
1703
1704    let rbac_for_handler = Arc::clone(&rbac_swap);
1705    let tool_list_filtering = config.tool_list_filtering;
1706    let session_store = config.session_store.take();
1707    let mut rmcp_config = StreamableHttpServerConfig::default()
1708        .with_allowed_hosts(allowed_hosts)
1709        .with_sse_keep_alive(Some(config.sse_keep_alive))
1710        .with_cancellation_token(session_ct.clone());
1711    rmcp_config.session_store = session_store;
1712    let event_store = config.event_store.take();
1713    let mcp_service = StreamableHttpService::new(
1714        move || {
1715            Ok(RbacContextHandler::new(
1716                handler_factory(),
1717                Arc::clone(&rbac_for_handler),
1718                tool_list_filtering,
1719            ))
1720        },
1721        {
1722            let mut mgr = LocalSessionManager::default();
1723            mgr.session_config.keep_alive = Some(config.session_idle_timeout);
1724            if let Some(event_store) = event_store {
1725                mgr = mgr.with_event_store(event_store);
1726            }
1727            mgr.into()
1728        },
1729        rmcp_config,
1730    );
1731
1732    // Build the MCP route, optionally wrapped with auth and RBAC middleware.
1733    let mut mcp_router = axum::Router::new().nest_service("/mcp", mcp_service);
1734
1735    // Build auth state eagerly when auth is configured so we can wire both
1736    // the auth middleware *and* the optional admin router against the same
1737    // state. The middleware itself is installed further down in layer order.
1738    let auth_state: Option<Arc<AuthState>> = match config.auth {
1739        Some(ref auth_config) if auth_config.enabled => {
1740            let rate_limiter = auth_config.rate_limit.as_ref().map(build_rate_limiter);
1741            let pre_auth_limiter = auth_config
1742                .rate_limit
1743                .as_ref()
1744                .map(crate::auth::build_pre_auth_limiter);
1745
1746            #[cfg(feature = "oauth")]
1747            let jwks_cache = auth_config
1748                .oauth
1749                .as_ref()
1750                .map(|c| crate::oauth::JwksCache::new(c).map(Arc::new))
1751                .transpose()
1752                .map_err(|e| std::io::Error::other(format!("JWKS HTTP client: {e}")))?;
1753
1754            Some(Arc::new(AuthState {
1755                api_keys: ArcSwap::new(Arc::new(auth_config.api_keys.clone())),
1756                rate_limiter,
1757                pre_auth_limiter,
1758                #[cfg(feature = "oauth")]
1759                jwks_cache,
1760                seen_identities: crate::auth::SeenIdentitySet::new(),
1761                counters: crate::auth::AuthCounters::default(),
1762                // Absolute only when `public_url` supplies a trustworthy
1763                // external origin. Without it `derive_server_url` falls back
1764                // to the bind address, which behind a TLS-terminating proxy
1765                // is an internal `http://` address -- advertising that would
1766                // send clients somewhere they cannot reach. `None` keeps the
1767                // relative path, which resolves against whatever origin the
1768                // client was actually challenged from.
1769                resource_metadata_url: config.public_url.as_ref().map(|url| {
1770                    format!(
1771                        "{}/.well-known/oauth-protected-resource/mcp",
1772                        url.trim_end_matches('/')
1773                    )
1774                }),
1775            }))
1776        }
1777        _ => None,
1778    };
1779
1780    // Optional /admin/* diagnostic routes. Merged BEFORE the
1781    // body-limit/timeout/RBAC/origin/auth layers so all of them apply.
1782    if config.admin_enabled {
1783        let Some(ref auth_state_ref) = auth_state else {
1784            return Err(anyhow::anyhow!(
1785                "admin_enabled=true requires auth to be configured and enabled"
1786            ));
1787        };
1788        let admin_state = crate::admin::AdminState {
1789            started_at: std::time::Instant::now(),
1790            name: config.name.clone(),
1791            version: config.version.clone(),
1792            auth: Some(Arc::clone(auth_state_ref)),
1793            rbac: Arc::clone(&rbac_swap),
1794        };
1795        let admin_cfg = crate::admin::AdminConfig {
1796            role: config.admin_role.clone(),
1797        };
1798        mcp_router = mcp_router.merge(crate::admin::admin_router(admin_state, &admin_cfg));
1799        tracing::info!(role = %config.admin_role, "/admin/* endpoints enabled");
1800    }
1801
1802    // ----- Middleware order (CRITICAL: read carefully) ------------------
1803    //
1804    // axum/tower applies layers **bottom-up** at runtime: the LAST layer
1805    // added is the OUTERMOST (runs first on a request). To achieve a
1806    // request-time flow of:
1807    //
1808    //   body-limit -> timeout -> auth -> rbac -> handler
1809    //
1810    // we add layers in the REVERSE order:
1811    //
1812    //   1. RBAC               (innermost, runs last before handler)
1813    //   2. auth               (parses identity, sets extension for RBAC)
1814    //   3. timeout            (bounds total request time)
1815    //   4. body-limit         (outermost on /mcp; caps payload before
1816    //                          anything else reads/buffers it)
1817    //
1818    // Origin validation is installed on the OUTER router (after the
1819    // /mcp router is merged in), so it also protects /healthz, /readyz,
1820    // /version, and any OAuth proxy endpoints.
1821    //
1822    // Rationale:
1823    // - Body-limit must be outermost on /mcp so RBAC (which reads the
1824    //   JSON-RPC body) cannot be DoS'd by a 100MB payload.
1825    // - Auth must run before RBAC because RBAC consumes
1826    //   `req.extensions().get::<AuthIdentity>()` to enforce per-role
1827    //   policy.
1828    // - Origin runs before auth so we reject cross-origin requests
1829    //   without spending Argon2 cycles on unauthenticated callers.
1830
1831    // [0] Session identity-binding layer (innermost; RBAC wraps it so invalid
1832    // session tool calls are still charged by the RBAC tool-rate limiter).
1833    if config.session_binding {
1834        let secret = match config.session_binding_secret.as_ref() {
1835            Some(configured) => configured_session_binding_secret(configured)?,
1836            None => process_session_binding_secret().clone(),
1837        };
1838        mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
1839            let secret = secret.clone();
1840            session_binding_middleware(secret, req, next)
1841        }));
1842    }
1843
1844    // [1] RBAC + tool rate-limit layer (inside auth; wraps session binding).
1845    // Always installed: even when RBAC is disabled, tool rate limiting may
1846    // be active (MCP spec: servers MUST rate limit tool invocations).
1847    {
1848        let tool_limiter: Option<Arc<ToolRateLimiter>> = config.tool_rate_limit.map(|per_minute| {
1849            build_tool_rate_limiter_with_policy(
1850                per_minute,
1851                config.tool_rate_limit_burst,
1852                config.key_eviction_policy,
1853            )
1854        });
1855
1856        if rbac_swap.load().is_enabled() {
1857            tracing::info!("RBAC enforcement enabled on /mcp");
1858        }
1859        if let Some(limit) = config.tool_rate_limit {
1860            tracing::info!(limit, "tool rate limiting enabled (calls/min per IP)");
1861        }
1862
1863        let rbac_for_mw = Arc::clone(&rbac_swap);
1864        mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
1865            let p = rbac_for_mw.load_full();
1866            let tl = tool_limiter.clone();
1867            rbac_middleware(p, tl, req, next)
1868        }));
1869    }
1870
1871    // [2] Auth layer (runs before RBAC so AuthIdentity is in extensions).
1872    if let Some(ref auth_config) = config.auth
1873        && auth_config.enabled
1874    {
1875        let Some(ref state) = auth_state else {
1876            return Err(anyhow::anyhow!("auth state missing despite enabled config"));
1877        };
1878
1879        let methods: Vec<&str> = [
1880            auth_config.mtls.is_some().then_some("mTLS"),
1881            (!auth_config.api_keys.is_empty()).then_some("bearer"),
1882            #[cfg(feature = "oauth")]
1883            auth_config.oauth.is_some().then_some("oauth-jwt"),
1884        ]
1885        .into_iter()
1886        .flatten()
1887        .collect();
1888
1889        tracing::info!(
1890            methods = %methods.join(", "),
1891            api_keys = auth_config.api_keys.len(),
1892            "auth enabled on /mcp"
1893        );
1894
1895        let state_for_mw = Arc::clone(state);
1896        mcp_router = mcp_router.layer(axum::middleware::from_fn(move |req, next| {
1897            let s = Arc::clone(&state_for_mw);
1898            auth_middleware(s, req, next)
1899        }));
1900    }
1901
1902    // [3] Request timeout (returns 408 on expiry). Bounds total request
1903    // duration including auth + handler.
1904    mcp_router = mcp_router.layer(tower_http::timeout::TimeoutLayer::with_status_code(
1905        axum::http::StatusCode::REQUEST_TIMEOUT,
1906        config.request_timeout,
1907    ));
1908
1909    // [4] Request body size limit (OUTERMOST on /mcp). Prevents OOM /
1910    // DoS from oversized payloads BEFORE any inner layer (auth, RBAC)
1911    // attempts to buffer or parse the body.
1912    mcp_router = mcp_router.layer(tower_http::limit::RequestBodyLimitLayer::new(
1913        config.max_request_body,
1914    ));
1915
1916    // Compute the effective allowed-origins list for the outer
1917    // origin-check layer (installed on the merged router below). When
1918    // `allowed_origins` is empty but `public_url` is set, auto-derive
1919    // the origin from the public URL so MCP clients (e.g. Claude Code)
1920    // that send `Origin: <server-url>` are accepted without explicit
1921    // config.
1922    let mut effective_origins = config.allowed_origins.clone();
1923    if effective_origins.is_empty()
1924        && let Some(ref url) = config.public_url
1925    {
1926        // Origin = scheme + "://" + host (+ ":" + port if non-default).
1927        // Strip any path/query from the public URL. Offsets come from
1928        // `find`, so they are char-boundary-aligned; `get(..)` keeps that
1929        // machine-checked (a violation degrades to an empty slice).
1930        if let Some(scheme_end) = url.find("://") {
1931            let scheme_with_sep = url.get(..scheme_end + 3).unwrap_or_default();
1932            let after_scheme = url.get(scheme_end + 3..).unwrap_or_default();
1933            let host_end = after_scheme.find('/').unwrap_or(after_scheme.len());
1934            let host = after_scheme.get(..host_end).unwrap_or_default();
1935            let origin = format!("{scheme_with_sep}{host}");
1936            tracing::info!(
1937                %origin,
1938                "auto-derived allowed origin from public_url"
1939            );
1940            effective_origins.push(origin);
1941        }
1942    }
1943    let allowed_origins: Arc<[String]> = Arc::from(effective_origins);
1944    let cors_origins = Arc::clone(&allowed_origins);
1945    let log_request_headers = config.log_request_headers;
1946
1947    let readyz_route = if let Some(check) = config.readiness_check.take() {
1948        axum::routing::get(move || readyz(Arc::clone(&check)))
1949    } else {
1950        axum::routing::get(healthz)
1951    };
1952
1953    #[allow(
1954        unused_mut,
1955        reason = "the binding is only reassigned when the `oauth` feature adds the \
1956                  protected-resource-metadata route below"
1957    )]
1958    let mut router = axum::Router::new()
1959        .route("/healthz", axum::routing::get(healthz))
1960        .route("/readyz", readyz_route)
1961        .route(
1962            "/version",
1963            axum::routing::get({
1964                // Pre-serialize the version payload once at router-build
1965                // time. The handler then serves a cheap `Arc::clone` of the
1966                // immutable bytes per request, avoiding `serde_json::Value`
1967                // allocation + serialization on every `/version` hit.
1968                let payload_bytes: Arc<[u8]> = serialize_version_payload(
1969                    &config.name,
1970                    &config.version,
1971                    config.expose_build_metadata,
1972                );
1973                move || {
1974                    let p = Arc::clone(&payload_bytes);
1975                    async move {
1976                        (
1977                            [(axum::http::header::CONTENT_TYPE, "application/json")],
1978                            p.to_vec(),
1979                        )
1980                    }
1981                }
1982            }),
1983        )
1984        .merge(mcp_router);
1985
1986    // Merge application-specific routes (bypass MCP auth/RBAC middleware).
1987    // When configured, wrap them - and only them - in the per-IP rate
1988    // limiter BEFORE merging: axum layers wrap only the routes already
1989    // present on the sub-router, so the limiter can never leak onto
1990    // `/mcp`, health, admin, or OAuth endpoints, while top-level layers
1991    // (origin check, peer-address normalization, ...) still run first.
1992    if let Some(extra) = config.extra_router.take() {
1993        let extra = match config.extra_route_rate_limit {
1994            Some(per_minute) => {
1995                let max_tracked_keys =
1996                    NonZeroUsize::new(EXTRA_ROUTE_MAX_TRACKED_KEYS).unwrap_or(NonZeroUsize::MIN);
1997                let limiter = build_extra_route_rate_limiter_with_policy(
1998                    per_minute,
1999                    config.extra_route_rate_limit_burst,
2000                    config.key_eviction_policy,
2001                    max_tracked_keys,
2002                );
2003                let exempt: Arc<std::collections::HashSet<String>> = Arc::new(
2004                    config
2005                        .extra_route_rate_limit_exempt_paths
2006                        .iter()
2007                        .cloned()
2008                        .collect(),
2009                );
2010                tracing::info!(
2011                    per_minute,
2012                    exempt_paths = exempt.len(),
2013                    "extra-route per-IP rate limit enabled"
2014                );
2015                extra.layer(axum::middleware::from_fn(move |req, next| {
2016                    let l = Arc::clone(&limiter);
2017                    let e = Arc::clone(&exempt);
2018                    extra_route_rate_limit_middleware(l, e, req, next)
2019                }))
2020            }
2021            None => extra,
2022        };
2023        router = router.merge(extra);
2024    }
2025
2026    // RFC 9728: Protected Resource Metadata endpoint.
2027    // When OAuth is configured, serve full metadata with authorization_servers.
2028    // Otherwise, serve a minimal document with just the resource URL and no
2029    // authorization_servers -- this tells MCP clients (e.g. Claude Code SDK)
2030    // that the server exists but does NOT require OAuth authentication,
2031    // preventing them from gating the connection behind a broken auth flow.
2032    let server_url = derive_server_url(&config);
2033    let resource_url = format!("{server_url}/mcp");
2034
2035    #[cfg(feature = "oauth")]
2036    let prm_metadata = if let Some(ref auth_config) = config.auth
2037        && let Some(ref oauth_config) = auth_config.oauth
2038    {
2039        crate::oauth::protected_resource_metadata(&resource_url, &server_url, oauth_config)
2040    } else {
2041        serde_json::json!({ "resource": resource_url })
2042    };
2043    #[cfg(not(feature = "oauth"))]
2044    let prm_metadata = serde_json::json!({ "resource": resource_url });
2045
2046    // RFC 9728 3.1: for a resource whose identifier carries a path, the
2047    // well-known segment is inserted between host and path, so the canonical
2048    // location for resource `{server_url}/mcp` is
2049    // `/.well-known/oauth-protected-resource/mcp`. The root path is retained
2050    // as a compatibility alias for clients that only probe there.
2051    let prm_root = prm_metadata.clone();
2052    router = router.route(
2053        "/.well-known/oauth-protected-resource",
2054        axum::routing::get(move || {
2055            let m = prm_root.clone();
2056            async move { axum::Json(m) }
2057        }),
2058    );
2059    router = router.route(
2060        "/.well-known/oauth-protected-resource/mcp",
2061        axum::routing::get(move || {
2062            let m = prm_metadata.clone();
2063            async move { axum::Json(m) }
2064        }),
2065    );
2066
2067    // OAuth 2.1 proxy endpoints: when an OAuth proxy is configured, expose
2068    // /authorize, /token, /register, and authorization server metadata so
2069    // MCP clients can perform Authorization Code + PKCE against the upstream
2070    // IdP (e.g. Keycloak) transparently.
2071    #[cfg(feature = "oauth")]
2072    if let Some(ref auth_config) = config.auth
2073        && let Some(ref oauth_config) = auth_config.oauth
2074        && oauth_config.proxy.is_some()
2075    {
2076        router = install_oauth_proxy_routes(
2077            router,
2078            &server_url,
2079            oauth_config,
2080            auth_state.as_ref(),
2081            config.max_request_body,
2082            &config.admin_role,
2083        )?;
2084    }
2085
2086    // OWASP security response headers are installed LAST (after the origin
2087    // layer, below) so they form the OUTERMOST response layer and therefore
2088    // also decorate origin-403, CORS-preflight, overload-503, and 404-fallback
2089    // responses. See the `security_headers_middleware` install site below.
2090
2091    // CORS preflight layer (required for browser-based MCP clients).
2092    // Uses the same effective origins as the origin check middleware
2093    // (including auto-derived origin from public_url).
2094    if !cors_origins.is_empty() {
2095        let cors = tower_http::cors::CorsLayer::new()
2096            .allow_origin(
2097                cors_origins
2098                    .iter()
2099                    .filter_map(|o| o.parse::<axum::http::HeaderValue>().ok())
2100                    .collect::<Vec<_>>(),
2101            )
2102            .allow_methods([
2103                axum::http::Method::GET,
2104                axum::http::Method::POST,
2105                axum::http::Method::OPTIONS,
2106            ])
2107            .allow_headers([
2108                axum::http::header::CONTENT_TYPE,
2109                axum::http::header::AUTHORIZATION,
2110            ]);
2111        router = router.layer(cors);
2112    }
2113
2114    // Optional response compression (gzip + brotli). Skips small bodies
2115    // to avoid overhead. Applied after CORS so preflight responses remain
2116    // uncompressed.
2117    if config.compression_enabled {
2118        use tower_http::compression::Predicate as _;
2119        let predicate = tower_http::compression::DefaultPredicate::new().and(
2120            tower_http::compression::predicate::SizeAbove::new(u64::from(
2121                config.compression_min_size,
2122            )),
2123        );
2124        router = router.layer(
2125            tower_http::compression::CompressionLayer::new()
2126                .gzip(true)
2127                .br(true)
2128                .compress_when(predicate),
2129        );
2130        tracing::info!(
2131            min_size = config.compression_min_size,
2132            "response compression enabled (gzip, br)"
2133        );
2134    }
2135
2136    // Optional global concurrency cap. `load_shed` converts the
2137    // `ConcurrencyLimit` back-pressure error into 503 instead of hanging.
2138    if let Some(max) = config.max_concurrent_requests {
2139        let overload_handler = tower::ServiceBuilder::new()
2140            .layer(axum::error_handling::HandleErrorLayer::new(
2141                |_err: tower::BoxError| async {
2142                    (
2143                        axum::http::StatusCode::SERVICE_UNAVAILABLE,
2144                        axum::Json(serde_json::json!({
2145                            "error": "overloaded",
2146                            "error_description": "server is at capacity, retry later"
2147                        })),
2148                    )
2149                },
2150            ))
2151            .layer(tower::load_shed::LoadShedLayer::new())
2152            .layer(tower::limit::ConcurrencyLimitLayer::new(max));
2153        router = router.layer(overload_handler);
2154        tracing::info!(max, "global concurrency limit enabled");
2155    }
2156
2157    // JSON fallback for unmatched routes. Without this, axum returns
2158    // an empty-body 404 that breaks MCP clients (e.g. Claude Code SDK)
2159    // when they probe OAuth endpoints like /authorize or /token.
2160    router = router.fallback(|| async {
2161        (
2162            axum::http::StatusCode::NOT_FOUND,
2163            axum::Json(serde_json::json!({
2164                "error": "not_found",
2165                "error_description": "The requested endpoint does not exist"
2166            })),
2167        )
2168    });
2169
2170    // Prometheus metrics: recording middleware + separate listener.
2171    #[cfg(feature = "metrics")]
2172    if config.metrics_enabled {
2173        let metrics = Arc::new(
2174            crate::metrics::McpMetrics::new().map_err(|e| anyhow::anyhow!("metrics init: {e}"))?,
2175        );
2176        let m = Arc::clone(&metrics);
2177        router = router.layer(axum::middleware::from_fn(
2178            move |req: Request<Body>, next: Next| {
2179                let m = Arc::clone(&m);
2180                metrics_middleware(m, req, next)
2181            },
2182        ));
2183        let metrics_bind = config.metrics_bind.clone();
2184        let metrics_shutdown = ct.clone();
2185        tokio::spawn(async move {
2186            if let Err(e) =
2187                crate::metrics::serve_metrics(metrics_bind, metrics, metrics_shutdown).await
2188            {
2189                tracing::error!("metrics listener failed: {e}");
2190            }
2191        });
2192    }
2193
2194    // Peer-address normalization. Mirrors the TLS branch's peer address
2195    // into `ConnectInfo<SocketAddr>` and exposes the framework-owned
2196    // `PeerAddr` extension on both listener branches, so ALL routes on
2197    // the merged router (`/mcp`, `/healthz`, OAuth proxy endpoints,
2198    // admin endpoints, extra_router, ...) and all inner middleware see a
2199    // uniform peer-address contract regardless of TLS. Installed just
2200    // inside the origin check, which stays outermost by design.
2201    let forward_resolver: Option<Arc<ForwardResolver>> = if config.trusted_proxies.is_empty() {
2202        None
2203    } else {
2204        // Entries are guaranteed parseable by `check_trusted_forwarder`;
2205        // filter_map is defensive only.
2206        Some(Arc::new(ForwardResolver {
2207            trusted: config
2208                .trusted_proxies
2209                .iter()
2210                .filter_map(|entry| parse_proxy_net(entry))
2211                .collect(),
2212            mode: config
2213                .forwarded_header
2214                .unwrap_or(ForwardedHeaderMode::XForwardedFor),
2215            max_scanned_entries: config.trusted_forwarder_max_entries,
2216        }))
2217    };
2218    if forward_resolver.is_some() {
2219        tracing::info!(
2220            proxies = config.trusted_proxies.len(),
2221            "trusted-forwarder mode enabled: limiters key by resolved client IP"
2222        );
2223    }
2224    router = router.layer(axum::middleware::from_fn(move |req, next| {
2225        let r = forward_resolver.clone();
2226        normalize_peer_addr_middleware(r, req, next)
2227    }));
2228
2229    // Origin validation layer (MCP spec: servers MUST validate the
2230    // Origin header to prevent DNS rebinding attacks). Installed as the
2231    // outermost REQUEST-side security layer so it protects ALL routes
2232    // (`/mcp`, `/healthz`, `/readyz`, `/version`, OAuth proxy endpoints,
2233    // admin endpoints, extra_router, etc.) and runs BEFORE auth so we
2234    // reject cross-origin attackers without spending Argon2 cycles. Only
2235    // the response-decorating security-headers layer below sits further out.
2236    //
2237    // Origin-less requests (e.g. server-to-server probes, curl, native
2238    // MCP clients) are permitted; only requests with an Origin header
2239    // that does not match `effective_origins` are rejected.
2240    router = router.layer(axum::middleware::from_fn(move |req, next| {
2241        let origins = Arc::clone(&allowed_origins);
2242        origin_check_middleware(origins, log_request_headers, req, next)
2243    }));
2244
2245    // OWASP security response headers. Installed LAST, making this the
2246    // OUTERMOST response layer: every response -- normal handler output,
2247    // origin-403, CORS preflight, the overload-503 (already converted to a
2248    // Response by the HandleErrorLayer nested inside the load-shed stack),
2249    // and the 404 fallback -- flows back out through it and gains the headers.
2250    // This is response-only decoration: on the request path it is a
2251    // pass-through, so origin still runs before auth and the rate limiter
2252    // still sits inside auth.
2253    let is_tls = config.tls_cert_path.is_some();
2254    warn_security_header_overrides(&config.security_headers);
2255    let security_headers_cfg = Arc::new(config.security_headers.clone());
2256    router = router.layer(axum::middleware::from_fn(move |req, next| {
2257        let cfg = Arc::clone(&security_headers_cfg);
2258        security_headers_middleware(is_tls, cfg, req, next)
2259    }));
2260
2261    let scheme = if config.tls_cert_path.is_some() {
2262        "https"
2263    } else {
2264        "http"
2265    };
2266
2267    let tls_paths = match (&config.tls_cert_path, &config.tls_key_path) {
2268        (Some(cert), Some(key)) => Some((cert.clone(), key.clone())),
2269        _ => None,
2270    };
2271    let tls_handshake_timeout = config.tls_handshake_timeout;
2272    let max_concurrent_tls_handshakes = config.max_concurrent_tls_handshakes;
2273    let mtls_config = config.auth.as_ref().and_then(|a| a.mtls.as_ref()).cloned();
2274
2275    Ok((
2276        router,
2277        AppRunParams {
2278            tls_paths,
2279            tls_handshake_timeout,
2280            max_concurrent_tls_handshakes,
2281            mtls_config,
2282            shutdown_timeout: config.shutdown_timeout,
2283            auth_state,
2284            rbac_swap,
2285            on_reload_ready: config.on_reload_ready.take(),
2286            ct,
2287            session_ct,
2288            scheme,
2289            name: config.name.clone(),
2290        },
2291    ))
2292}
2293
2294/// Cancels the held [`CancellationToken`] when dropped.
2295///
2296/// Startup spawns background tasks (the Prometheus metrics listener, the CRL
2297/// refresher, the external-shutdown bridge) before every fallible step has
2298/// completed. Without this guard a later failure -- a main-bind `AddrInUse`,
2299/// an unreadable TLS key -- returns `Err` while those tasks keep running and
2300/// keep their ports bound for the lifetime of the process.
2301///
2302/// The guard is deliberately never disarmed: once the serve function returns,
2303/// by success or by failure, the server is finished and its background tasks
2304/// must stop. On the success path the token has already been cancelled by the
2305/// shutdown signal, and cancelling twice is a no-op.
2306struct CancelOnDrop(CancellationToken);
2307
2308impl Drop for CancelOnDrop {
2309    fn drop(&mut self) {
2310        self.0.cancel();
2311    }
2312}
2313
2314/// Forwards an externally-supplied shutdown token into the server-internal one.
2315///
2316/// Returns the task handle so the wiring can be exercised directly in tests.
2317fn spawn_external_shutdown_bridge(
2318    external: CancellationToken,
2319    internal: CancellationToken,
2320) -> tokio::task::JoinHandle<()> {
2321    tokio::spawn(async move {
2322        // The second arm is load-bearing: without it this task parks forever
2323        // on a caller token that may never be cancelled, outliving a failed
2324        // startup even though the internal token was already cancelled.
2325        tokio::select! {
2326            () = external.cancelled() => internal.cancel(),
2327            () = internal.cancelled() => {}
2328        }
2329    })
2330}
2331
2332/// Run the MCP HTTP server, binding to `config.bind_addr` and serving
2333/// until an OS shutdown signal (Ctrl-C / SIGTERM) is received.
2334///
2335/// This is the standard entry point for production deployments. For
2336/// deterministic shutdown control (e.g. integration tests), see
2337/// [`serve_with_listener`].
2338///
2339/// The configuration must be validated first via
2340/// [`McpServerConfig::validate`], which returns a [`Validated`] proof
2341/// token. This typestate guarantees, at compile time, that the server
2342/// never starts with an invalid configuration.
2343///
2344/// # Errors
2345///
2346/// Returns [`RmcpServerKitError::Startup`] if binding to `config.bind_addr`
2347/// fails, or if the underlying axum server returns an error.
2348// NOT cancel-safe: dropping after `build_app_router` starts metrics, or after
2349// `run_server` spawns shutdown/CRL tasks, can detach them; use OS signal or
2350// `serve_with_listener`'s shutdown token for cooperative shutdown.
2351pub async fn serve<H, F>(
2352    config: Validated<McpServerConfig>,
2353    handler_factory: F,
2354) -> Result<(), RmcpServerKitError>
2355where
2356    H: ServerHandler + 'static,
2357    F: Fn() -> H + Send + Sync + Clone + 'static,
2358{
2359    let config = config.into_inner();
2360    #[allow(
2361        deprecated,
2362        reason = "internal serve() reads `bind_addr` to construct the listener; field becomes pub(crate) in 1.0"
2363    )]
2364    let bind_addr = config.bind_addr.clone();
2365    let (router, params) = build_app_router(config, handler_factory).map_err(anyhow_to_startup)?;
2366    let _cancel_guard = CancelOnDrop(params.ct.clone());
2367
2368    let listener = TcpListener::bind(&bind_addr)
2369        .await
2370        .map_err(|e| io_to_startup(&format!("bind {bind_addr}"), e))?;
2371    log_listening(&params.name, params.scheme, &bind_addr);
2372
2373    run_server(
2374        router,
2375        listener,
2376        params.tls_paths,
2377        params.tls_handshake_timeout,
2378        params.max_concurrent_tls_handshakes,
2379        params.mtls_config,
2380        params.shutdown_timeout,
2381        params.auth_state,
2382        params.rbac_swap,
2383        params.on_reload_ready,
2384        params.ct,
2385        params.session_ct,
2386    )
2387    .await
2388    .map_err(anyhow_to_startup)
2389}
2390
2391/// Run the MCP HTTP server on a pre-bound [`TcpListener`], with optional
2392/// readiness signalling and external shutdown control.
2393///
2394/// This variant is intended for **deterministic integration tests** and
2395/// for embedders that need to bind the listening socket themselves
2396/// (e.g. systemd socket activation). Compared to [`serve`]:
2397///
2398/// * The caller passes a `TcpListener` that is already bound. This
2399///   eliminates the bind race in tests that previously required
2400///   poll-the-`/healthz`-loop start-up detection.
2401/// * `ready_tx`, when `Some`, receives the socket's
2402///   [`SocketAddr`] *after* the router is built and immediately before
2403///   the server starts accepting connections. Tests can `await` the
2404///   matching `oneshot::Receiver` to know exactly when it is safe to
2405///   issue requests.
2406/// * `shutdown`, when `Some`, gives the caller a
2407///   [`CancellationToken`] that triggers the same graceful-shutdown
2408///   path as a real OS signal. This avoids cross-platform issues with
2409///   sending real `SIGTERM` from tests on Windows.
2410///
2411/// All three optional parameters degrade gracefully: if `ready_tx` is
2412/// `None`, no signal is sent; if `shutdown` is `None`, the server only
2413/// stops on an OS signal (just like [`serve`]).
2414///
2415/// # Errors
2416///
2417/// Returns [`RmcpServerKitError::Startup`] if router construction fails, if reading
2418/// the listener's `local_addr()` fails, or if the underlying axum
2419/// server returns an error.
2420// NOT cancel-safe: the `shutdown` token is the cancellation boundary. Dropping
2421// after readiness fires or `run_server` starts can detach shutdown/metrics
2422// tasks while callers believe the listener lifetime ended.
2423pub async fn serve_with_listener<H, F>(
2424    listener: TcpListener,
2425    config: Validated<McpServerConfig>,
2426    handler_factory: F,
2427    ready_tx: Option<tokio::sync::oneshot::Sender<SocketAddr>>,
2428    shutdown: Option<CancellationToken>,
2429) -> Result<(), RmcpServerKitError>
2430where
2431    H: ServerHandler + 'static,
2432    F: Fn() -> H + Send + Sync + Clone + 'static,
2433{
2434    let config = config.into_inner();
2435    let local_addr = listener
2436        .local_addr()
2437        .map_err(|e| io_to_startup("listener.local_addr", e))?;
2438    let (router, params) = build_app_router(config, handler_factory).map_err(anyhow_to_startup)?;
2439    let _cancel_guard = CancelOnDrop(params.ct.clone());
2440
2441    log_listening(&params.name, params.scheme, &local_addr.to_string());
2442
2443    // Forward external shutdown into the server-internal cancellation
2444    // token so `run_server`'s shutdown trigger picks it up alongside
2445    // any real OS signal.
2446    if let Some(external) = shutdown {
2447        let _bridge_task = spawn_external_shutdown_bridge(external, params.ct.clone());
2448    }
2449
2450    // Signal readiness *after* the router is fully built and external
2451    // shutdown is wired, but *before* run_server takes ownership of
2452    // the listener. The receiver can immediately issue requests.
2453    if let Some(tx) = ready_tx {
2454        // Receiver may have been dropped (test gave up). That's fine.
2455        let _ = tx.send(local_addr);
2456    }
2457
2458    run_server(
2459        router,
2460        listener,
2461        params.tls_paths,
2462        params.tls_handshake_timeout,
2463        params.max_concurrent_tls_handshakes,
2464        params.mtls_config,
2465        params.shutdown_timeout,
2466        params.auth_state,
2467        params.rbac_swap,
2468        params.on_reload_ready,
2469        params.ct,
2470        params.session_ct,
2471    )
2472    .await
2473    .map_err(anyhow_to_startup)
2474}
2475
2476/// Emit the standard "listening on …" log lines used by both
2477/// [`serve`] and [`serve_with_listener`].
2478#[allow(
2479    clippy::cognitive_complexity,
2480    reason = "tracing::info! macro expansions inflate the score; logic is trivial"
2481)]
2482fn log_listening(name: &str, scheme: &str, addr: &str) {
2483    tracing::info!("{name} listening on {addr}");
2484    tracing::info!("  MCP endpoint: {scheme}://{addr}/mcp");
2485    tracing::info!("  Health check: {scheme}://{addr}/healthz");
2486    tracing::info!("  Readiness:   {scheme}://{addr}/readyz");
2487}
2488
2489/// Drive the chosen axum server variant (TLS or plain) with a graceful
2490/// shutdown window. Consumes the router and listener.
2491///
2492/// # Shutdown semantics
2493///
2494/// A single shutdown trigger (the FIRST of: OS signal via
2495/// `shutdown_signal()`, or external cancellation of `ct`) starts BOTH:
2496///
2497/// 1. axum's `.with_graceful_shutdown(...)` future, which stops
2498///    accepting new connections and waits for in-flight requests to
2499///    drain;
2500/// 2. a `tokio::time::sleep(shutdown_timeout)` race that forces exit if
2501///    drainage exceeds `shutdown_timeout`.
2502///
2503/// Previously this function awaited `shutdown_signal()` independently
2504/// in BOTH branches of a `tokio::select!`. Because `shutdown_signal`
2505/// resolves once per future and consumes one signal, the force-exit
2506/// timer was tied to a SECOND signal (a second SIGTERM the operator
2507/// would never send). Under a single SIGTERM the graceful drain could
2508/// hang indefinitely. The current implementation derives both branches
2509/// from a single shared trigger so the timeout race is anchored to the
2510/// FIRST (and only) signal.
2511#[allow(
2512    clippy::too_many_arguments,
2513    clippy::cognitive_complexity,
2514    reason = "server start-up threads TLS, reload state, and graceful shutdown through one flow"
2515)]
2516// NOT cancel-safe: external cancellation is modeled by `ct`. Dropping/aborting
2517// can skip `session_ct.cancel()` and leave the spawned shutdown trigger or CRL
2518// refresher running with cloned cancellation tokens.
2519async fn run_server(
2520    router: axum::Router,
2521    listener: TcpListener,
2522    tls_paths: Option<(PathBuf, PathBuf)>,
2523    tls_handshake_timeout: Duration,
2524    max_concurrent_tls_handshakes: usize,
2525    mtls_config: Option<MtlsConfig>,
2526    shutdown_timeout: Duration,
2527    auth_state: Option<Arc<AuthState>>,
2528    rbac_swap: Arc<ArcSwap<RbacPolicy>>,
2529    mut on_reload_ready: Option<Box<dyn FnOnce(ReloadHandle) + Send>>,
2530    ct: CancellationToken,
2531    session_ct: CancellationToken,
2532) -> anyhow::Result<()> {
2533    // `shutdown_trigger` fires when the FIRST source resolves: either
2534    // an OS signal (Ctrl-C / SIGTERM) or external cancellation of `ct`
2535    // (which the test harness uses for deterministic shutdown).
2536    let shutdown_trigger = CancellationToken::new();
2537    {
2538        let trigger = shutdown_trigger.clone();
2539        let parent = ct.clone();
2540        tokio::spawn(async move {
2541            // cancel-safe: both arms (signal future, CancellationToken::cancelled)
2542            // are cancel-safe; the losing arm holds no state.
2543            tokio::select! {
2544                () = shutdown_signal() => {}
2545                () = parent.cancelled() => {}
2546            }
2547            trigger.cancel();
2548        });
2549    }
2550
2551    let graceful = {
2552        let trigger = shutdown_trigger.clone();
2553        let ct = ct.clone();
2554        async move {
2555            trigger.cancelled().await;
2556            tracing::info!("shutting down (grace period: {shutdown_timeout:?})");
2557            ct.cancel();
2558        }
2559    };
2560
2561    let force_exit_timer = {
2562        let trigger = shutdown_trigger.clone();
2563        async move {
2564            trigger.cancelled().await;
2565            tokio::time::sleep(shutdown_timeout).await;
2566        }
2567    };
2568
2569    if let Some((cert_path, key_path)) = tls_paths {
2570        let crl_set = if let Some(mtls) = mtls_config.as_ref()
2571            && mtls.crl_enabled
2572        {
2573            let (ca_certs, roots) = load_client_auth_roots(&mtls.ca_cert_path)?;
2574            let (crl_set, discover_rx) =
2575                mtls_revocation::bootstrap_fetch(roots, &ca_certs, mtls.clone())
2576                    .await
2577                    .map_err(|error| anyhow::anyhow!(error.to_string()))?;
2578            tokio::spawn(mtls_revocation::run_crl_refresher(
2579                Arc::clone(&crl_set),
2580                discover_rx,
2581                ct.clone(),
2582            ));
2583            Some(crl_set)
2584        } else {
2585            None
2586        };
2587
2588        if let Some(cb) = on_reload_ready.take() {
2589            cb(ReloadHandle {
2590                auth: auth_state.clone(),
2591                rbac: Some(Arc::clone(&rbac_swap)),
2592                crl_set: crl_set.clone(),
2593            });
2594        }
2595
2596        let tls_listener = TlsListener::new(
2597            listener,
2598            &cert_path,
2599            &key_path,
2600            mtls_config.as_ref(),
2601            crl_set,
2602            tls_handshake_timeout,
2603            max_concurrent_tls_handshakes,
2604        )?;
2605        let make_svc = router.into_make_service_with_connect_info::<TlsConnInfo>();
2606        // cancel-safe: dropping the serve future on force-exit is intentional
2607        // forced-shutdown semantics; force_exit_timer is a Sleep chain.
2608        tokio::select! {
2609            result = axum::serve(tls_listener, make_svc)
2610                .with_graceful_shutdown(graceful) => { session_ct.cancel(); result?; }
2611            () = force_exit_timer => {
2612                tracing::warn!("shutdown timeout exceeded, forcing exit");
2613                session_ct.cancel();
2614            }
2615        }
2616    } else {
2617        if let Some(cb) = on_reload_ready.take() {
2618            cb(ReloadHandle {
2619                auth: auth_state,
2620                rbac: Some(rbac_swap),
2621                crl_set: None,
2622            });
2623        }
2624
2625        let make_svc = router.into_make_service_with_connect_info::<SocketAddr>();
2626        // cancel-safe: dropping the serve future on force-exit is intentional
2627        // forced-shutdown semantics; force_exit_timer is a Sleep chain.
2628        tokio::select! {
2629            result = axum::serve(listener, make_svc)
2630                .with_graceful_shutdown(graceful) => { session_ct.cancel(); result?; }
2631            () = force_exit_timer => {
2632                tracing::warn!("shutdown timeout exceeded, forcing exit");
2633                session_ct.cancel();
2634            }
2635        }
2636    }
2637
2638    Ok(())
2639}
2640
2641/// Install the OAuth 2.1 proxy endpoints (`/authorize`, `/token`,
2642/// `/register`, and authorization server metadata) on `router`. The
2643/// caller must ensure `oauth_config.proxy` is `Some`.
2644///
2645/// # Errors
2646///
2647/// Returns [`RmcpServerKitError::Startup`] if the shared
2648/// [`crate::oauth::OauthHttpClient`] cannot be initialized.
2649#[cfg(feature = "oauth")]
2650fn install_oauth_proxy_routes(
2651    router: axum::Router,
2652    server_url: &str,
2653    oauth_config: &crate::oauth::OAuthConfig,
2654    auth_state: Option<&Arc<AuthState>>,
2655    max_request_body: usize,
2656    admin_role: &str,
2657) -> Result<axum::Router, RmcpServerKitError> {
2658    let Some(ref proxy) = oauth_config.proxy else {
2659        return Ok(router);
2660    };
2661
2662    // Single shared HTTP client for all proxy endpoints. Cloning is
2663    // cheap (refcounted) and shares the underlying connection pool.
2664    let http = crate::oauth::OauthHttpClient::with_config(oauth_config)?;
2665
2666    // Build the proxy endpoints on a DEDICATED sub-router so the request-body
2667    // cap below applies to exactly these routes and cannot leak onto `/mcp`,
2668    // health, or `/version`. Without this, the proxy routes would fall back to
2669    // axum's 2 MB `DefaultBodyLimit` and silently ignore the operator's
2670    // configured `max_request_body` (rust-review MEDIUM finding).
2671    let proxy_router = axum::Router::new();
2672
2673    let asm = crate::oauth::authorization_server_metadata(server_url, oauth_config);
2674    let proxy_router = proxy_router.route(
2675        "/.well-known/oauth-authorization-server",
2676        axum::routing::get(move || {
2677            let m = asm.clone();
2678            async move { axum::Json(m) }
2679        }),
2680    );
2681
2682    let proxy_authorize = proxy.clone();
2683    let proxy_router = proxy_router.route(
2684        "/authorize",
2685        axum::routing::get(
2686            move |axum::extract::RawQuery(query): axum::extract::RawQuery| {
2687                let p = proxy_authorize.clone();
2688                async move { crate::oauth::handle_authorize(&p, &query.unwrap_or_default()) }
2689            },
2690        ),
2691    );
2692
2693    let proxy_token = proxy.clone();
2694    let token_http = http.clone();
2695    let proxy_router = proxy_router.route(
2696        "/token",
2697        axum::routing::post(move |body: String| {
2698            let p = proxy_token.clone();
2699            let h = token_http.clone();
2700            async move { crate::oauth::handle_token(&h, &p, &body).await }
2701        })
2702        .layer(axum::middleware::from_fn(
2703            oauth_token_cache_headers_middleware,
2704        )),
2705    );
2706
2707    let proxy_register = proxy.clone();
2708    let proxy_router = proxy_router.route(
2709        "/register",
2710        axum::routing::post(move |axum::Json(body): axum::Json<serde_json::Value>| {
2711            let p = proxy_register;
2712            async move { axum::Json(crate::oauth::handle_register(&p, &body)) }
2713        })
2714        .layer(axum::middleware::from_fn(
2715            oauth_token_cache_headers_middleware,
2716        )),
2717    );
2718
2719    let admin_routes_enabled = proxy.expose_admin_endpoints
2720        && (proxy.introspection_url.is_some() || proxy.revocation_url.is_some());
2721    if proxy.expose_admin_endpoints
2722        && !proxy.require_auth_on_admin_endpoints
2723        && proxy.allow_unauthenticated_admin_endpoints
2724    {
2725        // M3 escape-hatch in effect: validate() let this through because
2726        // the operator explicitly opted in. Surface it loudly at startup
2727        // so the choice is auditable in logs.
2728        tracing::warn!(
2729            "OAuth introspect/revoke endpoints are unauthenticated by explicit \
2730             allow_unauthenticated_admin_endpoints opt-out; ensure an \
2731             authenticated reverse proxy fronts these routes"
2732        );
2733    }
2734
2735    let admin_router = if admin_routes_enabled {
2736        build_oauth_admin_router(proxy, http, auth_state, admin_role)?
2737    } else {
2738        axum::Router::new()
2739    };
2740
2741    // Merge admin (introspect/revoke) BEFORE applying the body-limit layer so
2742    // those routes inherit the cap too. `.layer` only wraps routes already
2743    // present on `proxy_router`, so this cannot affect the outer router.
2744    let proxy_router =
2745        proxy_router
2746            .merge(admin_router)
2747            .layer(tower_http::limit::RequestBodyLimitLayer::new(
2748                max_request_body,
2749            ));
2750
2751    let router = router.merge(proxy_router);
2752
2753    tracing::info!(
2754        introspect = proxy.expose_admin_endpoints && proxy.introspection_url.is_some(),
2755        revoke = proxy.expose_admin_endpoints && proxy.revocation_url.is_some(),
2756        max_request_body,
2757        "OAuth 2.1 proxy endpoints enabled (/authorize, /token, /register)"
2758    );
2759    Ok(router)
2760}
2761
2762/// Build the optional `/introspect` + `/revoke` admin sub-router.
2763///
2764/// Layered with [`oauth_token_cache_headers_middleware`] so RFC 6749 §5.1
2765/// / RFC 6750 §5.4 cache headers are emitted, and conditionally with the
2766/// auth middleware when `proxy.require_auth_on_admin_endpoints` is set.
2767#[cfg(feature = "oauth")]
2768fn build_oauth_admin_router(
2769    proxy: &crate::oauth::OAuthProxyConfig,
2770    http: crate::oauth::OauthHttpClient,
2771    auth_state: Option<&Arc<AuthState>>,
2772    admin_role: &str,
2773) -> Result<axum::Router, RmcpServerKitError> {
2774    let mut admin_router = axum::Router::new();
2775    if proxy.introspection_url.is_some() {
2776        let proxy_introspect = proxy.clone();
2777        let introspect_http = http.clone();
2778        admin_router = admin_router.route(
2779            "/introspect",
2780            axum::routing::post(move |body: String| {
2781                let p = proxy_introspect.clone();
2782                let h = introspect_http.clone();
2783                async move { crate::oauth::handle_introspect(&h, &p, &body).await }
2784            }),
2785        );
2786    }
2787    if proxy.revocation_url.is_some() {
2788        let proxy_revoke = proxy.clone();
2789        let revoke_http = http;
2790        admin_router = admin_router.route(
2791            "/revoke",
2792            axum::routing::post(move |body: String| {
2793                let p = proxy_revoke.clone();
2794                let h = revoke_http.clone();
2795                async move { crate::oauth::handle_revoke(&h, &p, &body).await }
2796            }),
2797        );
2798    }
2799
2800    let admin_router = admin_router.layer(axum::middleware::from_fn(
2801        oauth_token_cache_headers_middleware,
2802    ));
2803
2804    if proxy.require_auth_on_admin_endpoints {
2805        let Some(state) = auth_state else {
2806            return Err(RmcpServerKitError::Startup(
2807                "oauth proxy admin endpoints require auth state".into(),
2808            ));
2809        };
2810        let state_for_mw = Arc::clone(state);
2811        let required_role: Arc<str> = Arc::from(admin_role);
2812        // M6: gate introspect/revoke behind the admin role. Layers are added
2813        // inner-first, so the role check is added BEFORE auth in order to run
2814        // AFTER it at runtime: auth_middleware (outermost) authenticates and
2815        // populates the AuthIdentity, then require_admin_role rejects any
2816        // authenticated-but-non-admin caller with 403.
2817        Ok(admin_router
2818            .layer(axum::middleware::from_fn(move |req, next| {
2819                let r = Arc::clone(&required_role);
2820                crate::admin::require_admin_role(r, req, next)
2821            }))
2822            .layer(axum::middleware::from_fn(move |req, next| {
2823                let s = Arc::clone(&state_for_mw);
2824                auth_middleware(s, req, next)
2825            })))
2826    } else {
2827        Ok(admin_router)
2828    }
2829}
2830
2831/// This server's externally-visible origin.
2832///
2833/// Prefers the operator-supplied `public_url`; otherwise reconstructs it from
2834/// the bind address, choosing the scheme from whether TLS is configured.
2835/// Shared by the OAuth metadata documents and the `WWW-Authenticate`
2836/// `resource_metadata` URL so they can never disagree.
2837#[allow(
2838    deprecated,
2839    reason = "internal metadata assembly reads deprecated `pub` config fields by design until 1.0 makes them pub(crate)"
2840)]
2841fn derive_server_url(config: &McpServerConfig) -> String {
2842    config.public_url.as_ref().map_or_else(
2843        || {
2844            let scheme = if config.tls_cert_path.is_some() {
2845                "https"
2846            } else {
2847                "http"
2848            };
2849            format!("{scheme}://{}", config.bind_addr)
2850        },
2851        |url| url.trim_end_matches('/').to_owned(),
2852    )
2853}
2854
2855/// Build the host allow-list for rmcp's DNS rebinding protection.
2856///
2857/// Includes loopback hosts by default, then augments with host/authority
2858/// derived from `public_url` and the server bind address.
2859fn derive_allowed_hosts(bind_addr: &str, public_url: Option<&str>) -> Vec<String> {
2860    let mut hosts = vec![
2861        "localhost".to_owned(),
2862        "127.0.0.1".to_owned(),
2863        "::1".to_owned(),
2864    ];
2865
2866    if let Some(url) = public_url
2867        && let Ok(uri) = url.parse::<axum::http::Uri>()
2868        && let Some(authority) = uri.authority()
2869    {
2870        let host = authority.host().to_owned();
2871        if !hosts.iter().any(|h| h == &host) {
2872            hosts.push(host);
2873        }
2874
2875        let authority = authority.as_str().to_owned();
2876        if !hosts.iter().any(|h| h == &authority) {
2877            hosts.push(authority);
2878        }
2879    }
2880
2881    if let Ok(uri) = format!("http://{bind_addr}").parse::<axum::http::Uri>()
2882        && let Some(authority) = uri.authority()
2883    {
2884        let host = authority.host().to_owned();
2885        if !hosts.iter().any(|h| h == &host) {
2886            hosts.push(host);
2887        }
2888
2889        let authority = authority.as_str().to_owned();
2890        if !hosts.iter().any(|h| h == &authority) {
2891            hosts.push(authority);
2892        }
2893    }
2894
2895    hosts
2896}
2897
2898// - TLS support -
2899
2900/// Implement axum's `Connected` trait for `TlsConnInfo` so that
2901/// `ConnectInfo<TlsConnInfo>` is available in middleware when serving
2902/// over our custom `TlsListener`.
2903///
2904/// The identity is read directly from the wrapping
2905/// [`AuthenticatedTlsStream`], which guarantees one-to-one correspondence
2906/// between the TLS connection and its mTLS identity. This eliminates the
2907/// previous shared-map approach which was vulnerable to ephemeral-port
2908/// reuse races (an unauthenticated reconnection from the same `(IP, port)`
2909/// pair could alias a stale entry).
2910impl axum::extract::connect_info::Connected<axum::serve::IncomingStream<'_, TlsListener>>
2911    for TlsConnInfo
2912{
2913    fn connect_info(target: axum::serve::IncomingStream<'_, TlsListener>) -> Self {
2914        let addr = *target.remote_addr();
2915        let identity = target.io().identity().cloned();
2916        Self::new(addr, identity)
2917    }
2918}
2919
2920/// Default per-handshake deadline on the TLS accept path. Prevents idle
2921/// or slow-loris connections from pinning handshake worker tasks (and
2922/// their semaphore permits) indefinitely.
2923///
2924/// Configurable since 1.9.0 via
2925/// [`McpServerConfig::with_tls_handshake_timeout`].
2926const DEFAULT_TLS_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
2927
2928/// Default upper bound on concurrently in-flight TLS handshakes. When
2929/// saturated, the acceptor task stops pulling new connections from the
2930/// kernel backlog (backpressure) instead of accepting and dropping them
2931/// in user space.
2932///
2933/// Configurable since 1.9.0 via
2934/// [`McpServerConfig::with_max_concurrent_tls_handshakes`].
2935const DEFAULT_MAX_CONCURRENT_TLS_HANDSHAKES: usize = 256;
2936
2937/// Capacity of the completed-handshake queue between the acceptor task and
2938/// `axum::serve`'s `accept()` loop. Handshake workers block on `send` when
2939/// the queue is full, so a slow accept loop back-pressures handshakes
2940/// rather than buffering completed connections unboundedly.
2941const TLS_ACCEPT_CHANNEL_CAPACITY: usize = 32;
2942
2943/// A TLS-wrapping listener that implements axum's `Listener` trait.
2944///
2945/// TCP accepts and TLS handshakes run on a dedicated background task: each
2946/// accepted connection's handshake is spawned onto its own worker task,
2947/// bounded by a configurable concurrent-handshake cap (default
2948/// [`DEFAULT_MAX_CONCURRENT_TLS_HANDSHAKES`]) and a per-handshake timeout
2949/// (default [`DEFAULT_TLS_HANDSHAKE_TIMEOUT`]). A slow or idle client
2950/// therefore cannot stall other connections behind a serialized inline
2951/// handshake.
2952///
2953/// When mTLS is configured, client certificates are verified against the
2954/// configured CA and the client identity is extracted at handshake time.
2955/// The extracted identity is bound to the connection itself via the
2956/// returned [`AuthenticatedTlsStream`], so it is impossible for an
2957/// unrelated connection to observe it.
2958struct TlsListener {
2959    /// Bound address, captured eagerly before the `TcpListener` moves into
2960    /// the acceptor task.
2961    local_addr: SocketAddr,
2962    /// Completed handshakes produced by the acceptor task's workers.
2963    rx: mpsc::Receiver<(AuthenticatedTlsStream, SocketAddr)>,
2964    /// Background task driving TCP accepts and concurrent TLS handshakes.
2965    /// Aborted on drop so the listener releases its port deterministically.
2966    acceptor_task: tokio::task::JoinHandle<()>,
2967}
2968
2969impl TlsListener {
2970    fn new(
2971        inner: TcpListener,
2972        cert_path: &Path,
2973        key_path: &Path,
2974        mtls_config: Option<&MtlsConfig>,
2975        crl_set: Option<Arc<CrlSet>>,
2976        handshake_timeout: Duration,
2977        max_concurrent_handshakes: usize,
2978    ) -> anyhow::Result<Self> {
2979        // Install the ring crypto provider (ok to call multiple times).
2980        rustls::crypto::ring::default_provider()
2981            .install_default()
2982            .ok();
2983
2984        let certs = load_certs(cert_path)?;
2985        let key = load_key(key_path)?;
2986
2987        let mtls_default_role;
2988
2989        let tls_config = if let Some(mtls) = mtls_config {
2990            mtls_default_role = mtls.default_role.clone();
2991            let verifier: Arc<dyn rustls::server::danger::ClientCertVerifier> = if mtls.crl_enabled
2992            {
2993                let Some(crl_set) = crl_set else {
2994                    return Err(anyhow::anyhow!(
2995                        "mTLS CRL verifier requested but CRL state was not initialized"
2996                    ));
2997                };
2998                Arc::new(DynamicClientCertVerifier::new(crl_set))
2999            } else {
3000                let (_, root_store) = load_client_auth_roots(&mtls.ca_cert_path)?;
3001                if mtls.required {
3002                    rustls::server::WebPkiClientVerifier::builder(root_store)
3003                        .build()
3004                        .map_err(|e| anyhow::anyhow!("mTLS verifier error: {e}"))?
3005                } else {
3006                    rustls::server::WebPkiClientVerifier::builder(root_store)
3007                        .allow_unauthenticated()
3008                        .build()
3009                        .map_err(|e| anyhow::anyhow!("mTLS verifier error: {e}"))?
3010                }
3011            };
3012
3013            tracing::info!(
3014                ca = %mtls.ca_cert_path.display(),
3015                required = mtls.required,
3016                crl_enabled = mtls.crl_enabled,
3017                "mTLS client auth configured"
3018            );
3019
3020            rustls::ServerConfig::builder_with_protocol_versions(&[
3021                &rustls::version::TLS12,
3022                &rustls::version::TLS13,
3023            ])
3024            .with_client_cert_verifier(verifier)
3025            .with_single_cert(certs, key)?
3026        } else {
3027            mtls_default_role = "viewer".to_owned();
3028            rustls::ServerConfig::builder_with_protocol_versions(&[
3029                &rustls::version::TLS12,
3030                &rustls::version::TLS13,
3031            ])
3032            .with_no_client_auth()
3033            .with_single_cert(certs, key)?
3034        };
3035
3036        let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config));
3037        tracing::info!(
3038            "TLS enabled (cert: {}, key: {})",
3039            cert_path.display(),
3040            key_path.display()
3041        );
3042        let local_addr = inner.local_addr()?;
3043        let (tx, rx) = mpsc::channel(TLS_ACCEPT_CHANNEL_CAPACITY);
3044        let acceptor_task = tokio::spawn(run_tls_acceptor(
3045            inner,
3046            acceptor,
3047            mtls_default_role,
3048            tx,
3049            handshake_timeout,
3050            max_concurrent_handshakes,
3051        ));
3052        Ok(Self {
3053            local_addr,
3054            rx,
3055            acceptor_task,
3056        })
3057    }
3058
3059    /// Extract the mTLS client cert identity from a completed TLS handshake.
3060    /// Returns `None` if no client certificate was presented or if the
3061    /// certificate could not be parsed into an [`AuthIdentity`].
3062    fn extract_handshake_identity(
3063        tls_stream: &tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
3064        default_role: &str,
3065        addr: SocketAddr,
3066    ) -> Option<AuthIdentity> {
3067        let (_, server_conn) = tls_stream.get_ref();
3068        let cert_der = server_conn.peer_certificates()?.first()?;
3069        let id = extract_mtls_identity(cert_der.as_ref(), default_role)?;
3070        tracing::debug!(name = %id.name, peer = %addr, "mTLS client cert accepted");
3071        Some(id)
3072    }
3073}
3074
3075/// Drive TCP accepts and concurrent TLS handshakes for [`TlsListener`].
3076///
3077/// Each accepted connection's handshake runs on its own worker task under
3078/// a permit from a `max_concurrent_handshakes`-sized semaphore and a
3079/// `handshake_timeout` deadline. Completed handshakes are pushed to `tx`;
3080/// failures and timeouts are logged at DEBUG and the connection dropped.
3081/// The loop exits when the owning [`TlsListener`] is dropped.
3082// cancel-safe: aborted from `TlsListener::drop`; `accept` is cancel-safe,
3083// semaphore permits are RAII, and spawned handshake workers own streams and
3084// discard completed sends when `rx` is closed.
3085async fn run_tls_acceptor(
3086    listener: TcpListener,
3087    acceptor: tokio_rustls::TlsAcceptor,
3088    default_role: String,
3089    tx: mpsc::Sender<(AuthenticatedTlsStream, SocketAddr)>,
3090    handshake_timeout: Duration,
3091    max_concurrent_handshakes: usize,
3092) {
3093    let inflight = Arc::new(Semaphore::new(max_concurrent_handshakes));
3094    loop {
3095        // Acquire the permit BEFORE accepting: at saturation, pending
3096        // connections wait in the kernel backlog instead of being accepted
3097        // and then buffered or dropped in user space.
3098        let Ok(permit) = Arc::clone(&inflight).acquire_owned().await else {
3099            // The semaphore is never closed; defensive exit.
3100            return;
3101        };
3102        let (stream, addr) = match listener.accept().await {
3103            Ok(pair) => pair,
3104            Err(e) => {
3105                tracing::debug!("TCP accept error: {e}");
3106                continue;
3107            }
3108        };
3109        if tx.is_closed() {
3110            // The listener was dropped (shutdown): stop accepting.
3111            return;
3112        }
3113        let acceptor = acceptor.clone();
3114        let default_role = default_role.clone();
3115        let tx = tx.clone();
3116        tokio::spawn(async move {
3117            let _permit = permit;
3118            match tokio::time::timeout(handshake_timeout, acceptor.accept(stream)).await {
3119                Ok(Ok(tls_stream)) => {
3120                    let identity =
3121                        TlsListener::extract_handshake_identity(&tls_stream, &default_role, addr);
3122                    let wrapped = AuthenticatedTlsStream {
3123                        inner: tls_stream,
3124                        identity,
3125                    };
3126                    // The receiver only disappears during shutdown; discard
3127                    // the completed connection quietly rather than logging.
3128                    let _ = tx.send((wrapped, addr)).await;
3129                }
3130                Ok(Err(e)) => {
3131                    tracing::debug!("TLS handshake failed from {addr}: {e}");
3132                }
3133                Err(_elapsed) => {
3134                    tracing::debug!(
3135                        "TLS handshake timed out from {addr} after {handshake_timeout:?}"
3136                    );
3137                }
3138            }
3139        });
3140    }
3141}
3142
3143/// A TLS stream paired with the mTLS identity extracted at handshake time.
3144///
3145/// Wraps [`tokio_rustls::server::TlsStream`] so the verified client
3146/// identity travels with the connection itself. This replaces the previous
3147/// shared `MtlsIdentities` map, eliminating the
3148/// `(SocketAddr) -> AuthIdentity` aliasing risk caused by ephemeral-port
3149/// reuse and removing the need for an LRU eviction policy.
3150///
3151/// The wrapper is `Unpin` (its inner stream is `Unpin` because
3152/// [`tokio::net::TcpStream`] is `Unpin`), so `AsyncRead`/`AsyncWrite`
3153/// delegation uses safe pin projection via `Pin::new(&mut self.inner)`.
3154pub(crate) struct AuthenticatedTlsStream {
3155    inner: tokio_rustls::server::TlsStream<tokio::net::TcpStream>,
3156    identity: Option<AuthIdentity>,
3157}
3158
3159impl AuthenticatedTlsStream {
3160    /// Returns the verified mTLS client identity, if any.
3161    #[must_use]
3162    pub(crate) const fn identity(&self) -> Option<&AuthIdentity> {
3163        self.identity.as_ref()
3164    }
3165}
3166
3167impl std::fmt::Debug for AuthenticatedTlsStream {
3168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3169        f.debug_struct("AuthenticatedTlsStream")
3170            .field("identity", &self.identity.as_ref().map(|id| &id.name))
3171            .finish_non_exhaustive()
3172    }
3173}
3174
3175impl tokio::io::AsyncRead for AuthenticatedTlsStream {
3176    fn poll_read(
3177        mut self: Pin<&mut Self>,
3178        cx: &mut std::task::Context<'_>,
3179        buf: &mut tokio::io::ReadBuf<'_>,
3180    ) -> std::task::Poll<std::io::Result<()>> {
3181        Pin::new(&mut self.inner).poll_read(cx, buf)
3182    }
3183}
3184
3185impl tokio::io::AsyncWrite for AuthenticatedTlsStream {
3186    fn poll_write(
3187        mut self: Pin<&mut Self>,
3188        cx: &mut std::task::Context<'_>,
3189        buf: &[u8],
3190    ) -> std::task::Poll<std::io::Result<usize>> {
3191        Pin::new(&mut self.inner).poll_write(cx, buf)
3192    }
3193
3194    fn poll_flush(
3195        mut self: Pin<&mut Self>,
3196        cx: &mut std::task::Context<'_>,
3197    ) -> std::task::Poll<std::io::Result<()>> {
3198        Pin::new(&mut self.inner).poll_flush(cx)
3199    }
3200
3201    fn poll_shutdown(
3202        mut self: Pin<&mut Self>,
3203        cx: &mut std::task::Context<'_>,
3204    ) -> std::task::Poll<std::io::Result<()>> {
3205        Pin::new(&mut self.inner).poll_shutdown(cx)
3206    }
3207
3208    fn poll_write_vectored(
3209        mut self: Pin<&mut Self>,
3210        cx: &mut std::task::Context<'_>,
3211        bufs: &[std::io::IoSlice<'_>],
3212    ) -> std::task::Poll<std::io::Result<usize>> {
3213        Pin::new(&mut self.inner).poll_write_vectored(cx, bufs)
3214    }
3215
3216    fn is_write_vectored(&self) -> bool {
3217        self.inner.is_write_vectored()
3218    }
3219}
3220
3221impl axum::serve::Listener for TlsListener {
3222    type Io = AuthenticatedTlsStream;
3223    type Addr = SocketAddr;
3224
3225    /// Yield the next fully-handshaken TLS connection.
3226    ///
3227    /// Cancel-safe: this is a plain `mpsc::Receiver::recv`, so cancelling
3228    /// the future (axum selects it against graceful shutdown) never loses
3229    /// a connection.
3230    async fn accept(&mut self) -> (Self::Io, Self::Addr) {
3231        if let Some(pair) = self.rx.recv().await {
3232            return pair;
3233        }
3234        // The channel only closes if the acceptor task terminated, which
3235        // means the TcpListener is gone and the OS already refuses new
3236        // connections. `Listener::accept` is infallible and panicking is
3237        // forbidden, so park forever: existing connections keep being
3238        // served and graceful shutdown still completes.
3239        tracing::error!("TLS acceptor task terminated; no further connections will be accepted");
3240        std::future::pending().await
3241    }
3242
3243    fn local_addr(&self) -> std::io::Result<Self::Addr> {
3244        Ok(self.local_addr)
3245    }
3246}
3247
3248impl Drop for TlsListener {
3249    fn drop(&mut self) {
3250        // Stop accepting immediately and release the bound port. In-flight
3251        // handshake workers notice the closed channel and exit quietly.
3252        self.acceptor_task.abort();
3253    }
3254}
3255
3256fn load_certs(path: &Path) -> anyhow::Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
3257    use rustls::pki_types::pem::PemObject;
3258    let certs: Vec<_> = rustls::pki_types::CertificateDer::pem_file_iter(path)
3259        .map_err(|e| anyhow::anyhow!("failed to read certs from {}: {e}", path.display()))?
3260        .collect::<Result<_, _>>()
3261        .map_err(|e| anyhow::anyhow!("invalid cert in {}: {e}", path.display()))?;
3262    anyhow::ensure!(
3263        !certs.is_empty(),
3264        "no certificates found in {}",
3265        path.display()
3266    );
3267    Ok(certs)
3268}
3269
3270fn load_client_auth_roots(
3271    path: &Path,
3272) -> anyhow::Result<(
3273    Vec<rustls::pki_types::CertificateDer<'static>>,
3274    Arc<RootCertStore>,
3275)> {
3276    let ca_certs = load_certs(path)?;
3277    let mut root_store = RootCertStore::empty();
3278    for cert in &ca_certs {
3279        root_store
3280            .add(cert.clone())
3281            .map_err(|error| anyhow::anyhow!("invalid CA cert: {error}"))?;
3282    }
3283
3284    Ok((ca_certs, Arc::new(root_store)))
3285}
3286
3287fn load_key(path: &Path) -> anyhow::Result<rustls::pki_types::PrivateKeyDer<'static>> {
3288    use rustls::pki_types::pem::PemObject;
3289    rustls::pki_types::PrivateKeyDer::from_pem_file(path)
3290        .map_err(|e| anyhow::anyhow!("failed to read key from {}: {e}", path.display()))
3291}
3292
3293// cancel-safe: builds a constant JSON body with no awaits and no shared state.
3294#[allow(
3295    clippy::unused_async,
3296    reason = "axum route handler signature requires `async fn` even when the body is synchronous"
3297)]
3298async fn healthz() -> impl IntoResponse {
3299    axum::Json(serde_json::json!({
3300        "status": "ok",
3301    }))
3302}
3303
3304/// Build the `/version` JSON payload for a given server name and version.
3305///
3306/// `name`, `version`, and `rmcp_server_kit_version` are always included. Build
3307/// metadata (`build_git_sha`, `build_timestamp`, `rust_version`) is added
3308/// only when `expose_build_metadata` is true, so anonymous `/version`
3309/// callers do not receive build fingerprints by default. The build values
3310/// are read at compile time from `RMCP_SERVER_KIT_BUILD_SHA`,
3311/// `RMCP_SERVER_KIT_BUILD_TIME`, and `RMCP_SERVER_KIT_RUSTC_VERSION`;
3312/// unset values resolve to `"unknown"`.
3313fn version_payload(name: &str, version: &str, expose_build_metadata: bool) -> serde_json::Value {
3314    let mut map = serde_json::Map::new();
3315    map.insert("name".into(), name.into());
3316    map.insert("version".into(), version.into());
3317    map.insert(
3318        "rmcp_server_kit_version".into(),
3319        env!("CARGO_PKG_VERSION").into(),
3320    );
3321    if expose_build_metadata {
3322        map.insert(
3323            "build_git_sha".into(),
3324            option_env!("RMCP_SERVER_KIT_BUILD_SHA")
3325                .unwrap_or("unknown")
3326                .into(),
3327        );
3328        map.insert(
3329            "build_timestamp".into(),
3330            option_env!("RMCP_SERVER_KIT_BUILD_TIME")
3331                .unwrap_or("unknown")
3332                .into(),
3333        );
3334        map.insert(
3335            "rust_version".into(),
3336            option_env!("RMCP_SERVER_KIT_RUSTC_VERSION")
3337                .unwrap_or("unknown")
3338                .into(),
3339        );
3340    }
3341    serde_json::Value::Object(map)
3342}
3343
3344/// Pre-serialize the `/version` payload to immutable bytes.
3345///
3346/// This is called once at router-build time so per-request handling can
3347/// reuse a cheap `Arc<[u8]>` clone instead of re-serializing a
3348/// [`serde_json::Value`] on every hit.
3349///
3350/// Serialization of a flat `serde_json::Value` of static-string fields
3351/// cannot fail in practice; the fallback to `b"{}"` exists only to
3352/// satisfy the crate-wide `unwrap_used` / `expect_used` lint policy.
3353fn serialize_version_payload(name: &str, version: &str, expose_build_metadata: bool) -> Arc<[u8]> {
3354    let value = version_payload(name, version, expose_build_metadata);
3355    serde_json::to_vec(&value).map_or_else(|_| Arc::from(&b"{}"[..]), Arc::from)
3356}
3357
3358// NOT cancel-safe in general: the kit itself mutates no state here, but this
3359// awaits a consumer-supplied readiness future. Cancellation drops that future
3360// at whatever await it is parked on, so cancel safety is the callback author's
3361// contract -- a readiness probe must not leave partial state behind.
3362async fn readyz(check: ReadinessCheck) -> impl IntoResponse {
3363    let status = check().await;
3364    let ready = status
3365        .get("ready")
3366        .and_then(serde_json::Value::as_bool)
3367        .unwrap_or(false);
3368    let code = if ready {
3369        axum::http::StatusCode::OK
3370    } else {
3371        axum::http::StatusCode::SERVICE_UNAVAILABLE
3372    };
3373    (code, axum::Json(status))
3374}
3375
3376/// Wait for SIGINT (ctrl-c) or SIGTERM (container stop).
3377///
3378/// On non-Unix platforms, only SIGINT is handled.
3379async fn shutdown_signal() {
3380    let ctrl_c = tokio::signal::ctrl_c();
3381
3382    #[cfg(unix)]
3383    {
3384        match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
3385            Ok(mut term) => {
3386                // cancel-safe: signal-listener futures are cancel-safe per
3387                // tokio docs; no partial state in either arm.
3388                tokio::select! {
3389                    _ = ctrl_c => {}
3390                    _ = term.recv() => {}
3391                }
3392            }
3393            Err(e) => {
3394                tracing::warn!(error = %e, "failed to register SIGTERM handler, using SIGINT only");
3395                ctrl_c.await.ok();
3396            }
3397        }
3398    }
3399
3400    #[cfg(not(unix))]
3401    {
3402        ctrl_c.await.ok();
3403    }
3404}
3405
3406// -- Origin validation (MCP 2025-11-25 spec, section 2.0.1) --
3407
3408/// Middleware that validates the `Origin` header on incoming HTTP requests.
3409///
3410/// Collapse a request into a bounded set of Prometheus label values.
3411///
3412/// Prometheus retains one time series per distinct label set, and this
3413/// middleware runs OUTSIDE the auth layer, so any label derived from raw
3414/// request input is an unauthenticated memory-growth primitive. Both label
3415/// values must therefore come from a closed set.
3416///
3417/// `MatchedPath` yields the route template for ordinary registered routes,
3418/// but it is not available everywhere: `/mcp` is mounted with `nest_service`,
3419/// whose tail match may carry `MatchedNestedPath` instead, and unmatched
3420/// requests that hit the 404 fallback carry neither. The raw URI path is
3421/// never used as a fallback -- that is precisely the unbounded input.
3422#[cfg(feature = "metrics")]
3423fn metrics_labels(req: &Request<Body>) -> (&'static str, String) {
3424    let method = match *req.method() {
3425        axum::http::Method::GET => "GET",
3426        axum::http::Method::POST => "POST",
3427        axum::http::Method::PUT => "PUT",
3428        axum::http::Method::PATCH => "PATCH",
3429        axum::http::Method::DELETE => "DELETE",
3430        axum::http::Method::HEAD => "HEAD",
3431        axum::http::Method::OPTIONS => "OPTIONS",
3432        axum::http::Method::TRACE => "TRACE",
3433        axum::http::Method::CONNECT => "CONNECT",
3434        // HTTP permits extension methods, so anything else collapses to a
3435        // single bucket rather than minting a series per invented verb.
3436        _ => "OTHER",
3437    };
3438
3439    let path = req
3440        .extensions()
3441        .get::<axum::extract::MatchedPath>()
3442        .map_or_else(
3443            || {
3444                let raw = req.uri().path();
3445                if raw == "/mcp" || raw.starts_with("/mcp/") {
3446                    "/mcp".to_owned()
3447                } else {
3448                    "<unmatched>".to_owned()
3449                }
3450            },
3451            |matched| matched.as_str().to_owned(),
3452        );
3453
3454    (method, path)
3455}
3456
3457/// Record HTTP request metrics (method, path, status, duration).
3458///
3459/// Also exposes the shared [`crate::metrics::McpMetrics`] handle to
3460/// inner middleware via a request extension, so the rate limiters can
3461/// increment `rmcp_server_kit_rate_limited_total` at their deny sites
3462/// (see [`crate::metrics::record_rate_limit_deny`]).
3463// cancel-safe: counters are incremented synchronously around the single
3464// `next.run(req)` await, so cancellation loses the observation rather than
3465// leaving a half-updated metric.
3466#[cfg(feature = "metrics")]
3467async fn metrics_middleware(
3468    metrics: Arc<crate::metrics::McpMetrics>,
3469    mut req: Request<Body>,
3470    next: Next,
3471) -> axum::response::Response {
3472    let (method, path) = metrics_labels(&req);
3473    let start = std::time::Instant::now();
3474
3475    req.extensions_mut().insert(Arc::clone(&metrics));
3476    let response = next.run(req).await;
3477
3478    let mut status_buf = core::fmt::NumBuffer::<u16>::new();
3479    let status = response.status().as_u16().format_into(&mut status_buf);
3480    let duration = start.elapsed().as_secs_f64();
3481
3482    metrics
3483        .http_requests_total
3484        .with_label_values(&[method, &path, status])
3485        .inc();
3486    metrics
3487        .http_request_duration_seconds
3488        .with_label_values(&[method, &path])
3489        .observe(duration);
3490
3491    response
3492}
3493
3494/// OWASP security header hardening applied to every response.
3495///
3496/// Sets: `X-Content-Type-Options`, `X-Frame-Options`, `Cache-Control`,
3497/// `Referrer-Policy`, `Cross-Origin-Opener-Policy`, `Cross-Origin-Resource-Policy`,
3498/// `Cross-Origin-Embedder-Policy`, `Permissions-Policy`,
3499/// `X-Permitted-Cross-Domain-Policies`, `Content-Security-Policy`,
3500/// `X-DNS-Prefetch-Control`, and (when TLS is active) `Strict-Transport-Security`.
3501///
3502/// Each header's value can be customised via [`SecurityHeadersConfig`]
3503/// on [`McpServerConfig`]. See that type for the three-state semantic
3504/// (`None` = default, `Some("")` = omit, `Some(v)` = override).
3505// cancel-safe: the only await is `next.run(req)`; header mutation afterwards is
3506// synchronous, so cancellation simply drops the response before it is sent.
3507async fn security_headers_middleware(
3508    is_tls: bool,
3509    cfg: Arc<SecurityHeadersConfig>,
3510    req: Request<Body>,
3511    next: Next,
3512) -> axum::response::Response {
3513    use axum::http::{HeaderName, header};
3514
3515    let mut resp = next.run(req).await;
3516    let headers = resp.headers_mut();
3517
3518    // Strip server identity headers to reduce information leakage.
3519    headers.remove(header::SERVER);
3520    headers.remove(HeaderName::from_static("x-powered-by"));
3521
3522    apply_security_header(
3523        headers,
3524        header::X_CONTENT_TYPE_OPTIONS,
3525        cfg.x_content_type_options.as_deref(),
3526        "nosniff",
3527    );
3528    apply_security_header(
3529        headers,
3530        header::X_FRAME_OPTIONS,
3531        cfg.x_frame_options.as_deref(),
3532        "deny",
3533    );
3534    apply_security_header(
3535        headers,
3536        header::CACHE_CONTROL,
3537        cfg.cache_control.as_deref(),
3538        "no-store, max-age=0",
3539    );
3540    apply_security_header(
3541        headers,
3542        header::REFERRER_POLICY,
3543        cfg.referrer_policy.as_deref(),
3544        "no-referrer",
3545    );
3546    apply_security_header(
3547        headers,
3548        HeaderName::from_static("cross-origin-opener-policy"),
3549        cfg.cross_origin_opener_policy.as_deref(),
3550        "same-origin",
3551    );
3552    apply_security_header(
3553        headers,
3554        HeaderName::from_static("cross-origin-resource-policy"),
3555        cfg.cross_origin_resource_policy.as_deref(),
3556        "same-origin",
3557    );
3558    apply_security_header(
3559        headers,
3560        HeaderName::from_static("cross-origin-embedder-policy"),
3561        cfg.cross_origin_embedder_policy.as_deref(),
3562        "require-corp",
3563    );
3564    apply_security_header(
3565        headers,
3566        HeaderName::from_static("permissions-policy"),
3567        cfg.permissions_policy.as_deref(),
3568        "accelerometer=(), camera=(), geolocation=(), microphone=()",
3569    );
3570    apply_security_header(
3571        headers,
3572        HeaderName::from_static("x-permitted-cross-domain-policies"),
3573        cfg.x_permitted_cross_domain_policies.as_deref(),
3574        "none",
3575    );
3576    apply_security_header(
3577        headers,
3578        HeaderName::from_static("content-security-policy"),
3579        cfg.content_security_policy.as_deref(),
3580        "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests",
3581    );
3582    apply_security_header(
3583        headers,
3584        HeaderName::from_static("x-dns-prefetch-control"),
3585        cfg.x_dns_prefetch_control.as_deref(),
3586        "off",
3587    );
3588
3589    if is_tls {
3590        apply_security_header(
3591            headers,
3592            header::STRICT_TRANSPORT_SECURITY,
3593            cfg.strict_transport_security.as_deref(),
3594            "max-age=63072000; includeSubDomains",
3595        );
3596    }
3597
3598    resp
3599}
3600
3601/// Set a single security header on the response, honouring the
3602/// three-state override semantic (None = default, Some("") = omit,
3603/// Some(value) = override).
3604///
3605/// Defence-in-depth: if an override value somehow reaches this point
3606/// despite [`validate_security_headers`] having approved it (e.g. a
3607/// runtime mutation on a non-`Validated` field), we log at error level
3608/// and fall back to the static default rather than panicking. The
3609/// `Validated<McpServerConfig>` type makes that path unreachable in
3610/// well-typed code paths.
3611fn apply_security_header(
3612    headers: &mut axum::http::HeaderMap,
3613    name: axum::http::HeaderName,
3614    override_value: Option<&str>,
3615    default: &'static str,
3616) {
3617    use axum::http::HeaderValue;
3618
3619    match override_value {
3620        None => {
3621            headers.insert(name, HeaderValue::from_static(default));
3622        }
3623        Some("") => {
3624            // Operator explicitly opted out of this header.
3625        }
3626        Some(v) => match HeaderValue::from_str(v) {
3627            Ok(hv) => {
3628                headers.insert(name, hv);
3629            }
3630            Err(err) => {
3631                tracing::error!(
3632                    header = %name,
3633                    error = %err,
3634                    "invalid security header override reached middleware; using default"
3635                );
3636                headers.insert(name, HeaderValue::from_static(default));
3637            }
3638        },
3639    }
3640}
3641
3642/// Validate every non-empty entry in a [`SecurityHeadersConfig`].
3643///
3644/// - `None` and `Some("")` are accepted unconditionally (use-default and
3645///   omit, respectively).
3646/// - `Some(v)` is rejected if `axum::http::HeaderValue::from_str(v)` fails.
3647/// - `strict_transport_security` additionally rejects any value
3648///   containing `preload` (case-insensitive). Operators who genuinely
3649///   want to commit to the HSTS preload list must do so via a future
3650///   explicit `with_hsts_preload(true)` builder, not by smuggling
3651///   `preload` through this knob.
3652fn validate_security_headers(cfg: &SecurityHeadersConfig) -> Result<(), RmcpServerKitError> {
3653    use axum::http::HeaderValue;
3654
3655    let fields: &[(&str, Option<&str>)] = &[
3656        (
3657            "x_content_type_options",
3658            cfg.x_content_type_options.as_deref(),
3659        ),
3660        ("x_frame_options", cfg.x_frame_options.as_deref()),
3661        ("cache_control", cfg.cache_control.as_deref()),
3662        ("referrer_policy", cfg.referrer_policy.as_deref()),
3663        (
3664            "cross_origin_opener_policy",
3665            cfg.cross_origin_opener_policy.as_deref(),
3666        ),
3667        (
3668            "cross_origin_resource_policy",
3669            cfg.cross_origin_resource_policy.as_deref(),
3670        ),
3671        (
3672            "cross_origin_embedder_policy",
3673            cfg.cross_origin_embedder_policy.as_deref(),
3674        ),
3675        ("permissions_policy", cfg.permissions_policy.as_deref()),
3676        (
3677            "x_permitted_cross_domain_policies",
3678            cfg.x_permitted_cross_domain_policies.as_deref(),
3679        ),
3680        (
3681            "content_security_policy",
3682            cfg.content_security_policy.as_deref(),
3683        ),
3684        (
3685            "x_dns_prefetch_control",
3686            cfg.x_dns_prefetch_control.as_deref(),
3687        ),
3688        (
3689            "strict_transport_security",
3690            cfg.strict_transport_security.as_deref(),
3691        ),
3692    ];
3693
3694    for (field, value) in fields {
3695        let Some(v) = value else { continue };
3696        if v.is_empty() {
3697            continue;
3698        }
3699        if let Err(err) = HeaderValue::from_str(v) {
3700            return Err(RmcpServerKitError::Config(format!(
3701                "invalid security_headers.{field}: {err}"
3702            )));
3703        }
3704    }
3705
3706    if let Some(v) = cfg.strict_transport_security.as_deref()
3707        && !v.is_empty()
3708        && v.to_ascii_lowercase().contains("preload")
3709    {
3710        return Err(RmcpServerKitError::Config(format!(
3711            "invalid security_headers.strict_transport_security: {v:?} contains the `preload` directive; \
3712             HSTS preload must be opted into explicitly via a dedicated builder, not via this knob"
3713        )));
3714    }
3715
3716    Ok(())
3717}
3718
3719/// Append RFC 6749 §5.1 / RFC 6750 §5.4 cache and `Vary` headers required
3720/// on OAuth token-issuing responses.
3721///
3722/// `Cache-Control: no-store, max-age=0` is already applied globally by
3723/// [`security_headers_middleware`]; this middleware adds:
3724///
3725/// - `Pragma: no-cache` -- mandated by RFC 6749 §5.1 for HTTP/1.0 caches.
3726/// - `Vary: Authorization` -- mandated by RFC 6750 §5.4 for endpoints
3727///   whose response depends on the `Authorization` header.
3728///
3729/// Applied only to the OAuth proxy token-class endpoints (`/token`,
3730/// `/register`, `/introspect`, `/revoke`). `Vary` is appended (not
3731/// inserted) so any `Vary` value already present (e.g. `Accept-Encoding`
3732/// from a compression layer, or `Origin` from a CORS layer) is preserved.
3733#[cfg(feature = "oauth")]
3734async fn oauth_token_cache_headers_middleware(
3735    req: Request<Body>,
3736    next: Next,
3737) -> axum::response::Response {
3738    use axum::http::{HeaderValue, header};
3739
3740    let mut resp = next.run(req).await;
3741    let headers = resp.headers_mut();
3742    headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
3743    headers.append(header::VARY, HeaderValue::from_static("Authorization"));
3744    resp
3745}
3746
3747/// Normalize peer-address request extensions across listener branches.
3748///
3749/// The make-service installs `ConnectInfo<SocketAddr>` on the plain
3750/// listener but `ConnectInfo<TlsConnInfo>` on the TLS listener (the
3751/// latter additionally carries the connection-bound mTLS identity and
3752/// stays `pub(crate)` - see the anti-aliasing rationale on
3753/// [`TlsConnInfo`]). Application routes - in particular those merged via
3754/// [`McpServerConfig::with_extra_router`], which bypass the auth
3755/// middleware and its private fallback - could therefore not read the
3756/// peer address under TLS.
3757///
3758/// This middleware makes both branches look identical to every route and
3759/// inner middleware:
3760///
3761/// 1. mirrors the TLS peer address into `ConnectInfo<SocketAddr>` when
3762///    (and only when) it is absent, so stock axum-ecosystem extractors
3763///    work unmodified,
3764/// 2. inserts the framework-owned [`PeerAddr`] extension on both
3765///    branches, and
3766/// 3. inserts the resolved [`ClientIp`] extension: the direct peer's IP,
3767///    unless trusted-forwarder mode is configured AND the direct peer is
3768///    a trusted proxy AND the forwarding chain resolves - every
3769///    ambiguous chain falls back to the direct peer with only a reason
3770///    code logged at `debug` (never raw header contents).
3771///
3772/// Precedence mirrors the auth middleware: an existing
3773/// `ConnectInfo<SocketAddr>` always wins and is never overwritten. The
3774/// peer address is deliberately not logged here.
3775// cancel-safe: inserts request extensions synchronously before the single
3776// `next.run(req)` await; the extensions die with the dropped request.
3777async fn normalize_peer_addr_middleware(
3778    resolver: Option<Arc<ForwardResolver>>,
3779    mut req: Request<Body>,
3780    next: Next,
3781) -> axum::response::Response {
3782    let direct = req
3783        .extensions()
3784        .get::<ConnectInfo<SocketAddr>>()
3785        .map(|ci| ci.0);
3786    let from_tls = req
3787        .extensions()
3788        .get::<ConnectInfo<TlsConnInfo>>()
3789        .map(|ci| ci.0.addr);
3790    if let Some(addr) = direct.or(from_tls) {
3791        if direct.is_none() {
3792            req.extensions_mut().insert(ConnectInfo(addr));
3793        }
3794        req.extensions_mut().insert(PeerAddr::new(addr));
3795        let client_ip = match &resolver {
3796            Some(r) => crate::forwarded::resolve_client_ip(
3797                addr.ip(),
3798                req.headers(),
3799                &r.trusted,
3800                r.mode,
3801                r.max_scanned_entries,
3802            )
3803            .unwrap_or_else(|reason| {
3804                tracing::debug!(
3805                    reason = ?reason,
3806                    "forwarded-header resolution fell back to direct peer"
3807                );
3808                addr.ip()
3809            }),
3810            None => addr.ip(),
3811        };
3812        req.extensions_mut().insert(ClientIp::new(client_ip));
3813    }
3814    next.run(req).await
3815}
3816
3817/// Parse a trusted-proxy entry: a CIDR (`10.0.0.0/8`) or a bare IP
3818/// (normalized to a `/32` / `/128` host network).
3819fn parse_proxy_net(entry: &str) -> Option<ipnet::IpNet> {
3820    if let Ok(net) = entry.parse::<ipnet::IpNet>() {
3821        return Some(net);
3822    }
3823    entry.parse::<IpAddr>().ok().map(ipnet::IpNet::from)
3824}
3825
3826/// Validate one `trusted_proxies` entry. Accepts a CIDR (`ipnet::IpNet`)
3827/// or a bare IP; **rejects a `/0` prefix**, which would mark every peer
3828/// trusted and let any client spoof the resolved client IP via forwarding
3829/// headers. Shared by the builder ([`McpServerConfig::check_trusted_forwarder`])
3830/// and the TOML validator so the two validators cannot drift.
3831///
3832/// # Errors
3833///
3834/// Returns a message when the entry is unparseable or carries a `/0` prefix.
3835pub(crate) fn validate_trusted_proxy_entry(entry: &str) -> Result<(), String> {
3836    match parse_proxy_net(entry) {
3837        None => Err(format!(
3838            "trusted_proxies entry {entry:?} is neither a CIDR nor an IP address"
3839        )),
3840        Some(net) if net.prefix_len() == 0 => Err(format!(
3841            "trusted_proxies entry {entry:?}: prefix length 0 is forbidden (marks every peer trusted, enabling client-IP spoofing)"
3842        )),
3843        Some(_) => Ok(()),
3844    }
3845}
3846
3847/// Rate-limit key for the current request: the resolved [`ClientIp`]
3848/// when present, else the direct peer from either `ConnectInfo` form.
3849/// All four built-in limiters key through this helper.
3850pub(crate) fn limiter_client_ip(extensions: &axum::http::Extensions) -> Option<IpAddr> {
3851    if let Some(client) = extensions.get::<ClientIp>() {
3852        return Some(client.ip);
3853    }
3854    extensions
3855        .get::<ConnectInfo<SocketAddr>>()
3856        .map(|ci| ci.0.ip())
3857        .or_else(|| {
3858            extensions
3859                .get::<ConnectInfo<TlsConnInfo>>()
3860                .map(|ci| ci.0.addr.ip())
3861        })
3862}
3863
3864/// Rate-limit bucket identity for a request.
3865///
3866/// A request whose source address cannot be resolved must not become
3867/// *exempt* from rate limiting, so such requests share one bounded
3868/// [`RateLimitKey::Unattributed`] bucket instead.
3869///
3870/// This is an enum rather than a sentinel `IpAddr` (e.g. `0.0.0.0`)
3871/// because [`limiter_client_ip`] consults [`ClientIp`] first, and in
3872/// trusted-forwarder mode that value is header-derived:
3873/// [`crate::forwarded`] does not filter unspecified or reserved
3874/// addresses, so a forwarded `0.0.0.0` would collide with the sentinel
3875/// and share a bucket with genuinely unattributable traffic.
3876#[derive(Clone, PartialEq, Eq, Hash, Debug)]
3877pub(crate) enum RateLimitKey {
3878    /// A resolved client address.
3879    Ip(IpAddr),
3880    /// Source address could not be determined.
3881    Unattributed,
3882}
3883
3884impl std::fmt::Display for RateLimitKey {
3885    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3886        match self {
3887            Self::Ip(ip) => write!(f, "{ip}"),
3888            Self::Unattributed => f.write_str("unattributed"),
3889        }
3890    }
3891}
3892
3893/// Emitted at most once per process; see [`limiter_client_key`].
3894static UNATTRIBUTED_WARNED: std::sync::atomic::AtomicBool =
3895    std::sync::atomic::AtomicBool::new(false);
3896
3897/// Rate-limit key for the current request.
3898///
3899/// Falls back to [`RateLimitKey::Unattributed`] when no address can be
3900/// resolved, which cannot happen for a request served by [`serve`] (the
3901/// peer-address normalisation layer inserts `ConnectInfo` on both the TLS
3902/// and plaintext paths) but is reachable if this crate's middleware is
3903/// composed into a router built elsewhere. The warning fires once per
3904/// process rather than per request: a broken invariant holds for *every*
3905/// request, so per-request logging would amplify it into its own denial
3906/// of service.
3907pub(crate) fn limiter_client_key(extensions: &axum::http::Extensions) -> RateLimitKey {
3908    if let Some(ip) = limiter_client_ip(extensions) {
3909        return RateLimitKey::Ip(ip);
3910    }
3911    if !UNATTRIBUTED_WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
3912        tracing::warn!(
3913            "request carries no resolvable client address; rate limiting is \
3914             falling back to a single shared bucket. This indicates \
3915             rmcp-server-kit middleware composed outside serve()."
3916        );
3917    }
3918    RateLimitKey::Unattributed
3919}
3920
3921/// Per-IP rate limiter for `extra_router` routes, keyed by the direct
3922/// socket peer address. Same memory-bounded machinery as the tool
3923/// limiter ([`crate::rbac`]).
3924pub(crate) type ExtraRouteRateLimiter = BoundedKeyedLimiter<RateLimitKey>;
3925
3926/// Cap on distinct source IPs tracked by the extra-route limiter.
3927/// Mirrors the tool limiter's bound: memory stays bounded at saturation
3928/// via idle-prune + LRU eviction, at the cost of shared-fate fairness
3929/// under key spray (an attacker churning many IPs can reset quieter
3930/// legitimate IPs to fresh buckets).
3931const EXTRA_ROUTE_MAX_TRACKED_KEYS: usize = 10_000;
3932
3933/// Idle-eviction window for the extra-route limiter (15 minutes),
3934/// mirroring the tool limiter.
3935const EXTRA_ROUTE_IDLE_EVICTION: Duration = Duration::from_mins(15);
3936
3937/// Build the per-IP limiter for `extra_router` routes.
3938///
3939/// `per_minute` and `burst` are validated nonzero by
3940/// [`McpServerConfig::validate`]; the `NonZeroU32` fallbacks here are
3941/// defensive only. `burst` overrides governor's default bucket capacity
3942/// (burst = rate).
3943fn build_extra_route_rate_limiter_with_policy(
3944    per_minute: u32,
3945    burst: Option<u32>,
3946    key_eviction_policy: KeyEvictionPolicy,
3947    max_tracked_keys: NonZeroUsize,
3948) -> Arc<ExtraRouteRateLimiter> {
3949    let rate = std::num::NonZeroU32::new(per_minute.max(1)).unwrap_or(std::num::NonZeroU32::MIN);
3950    let mut quota = governor::Quota::per_minute(rate);
3951    if let Some(b) = burst.and_then(std::num::NonZeroU32::new) {
3952        quota = quota.allow_burst(b);
3953    }
3954    Arc::new(BoundedKeyedLimiter::new_with_policy(
3955        quota,
3956        max_tracked_keys,
3957        EXTRA_ROUTE_IDLE_EVICTION,
3958        key_eviction_policy,
3959    ))
3960}
3961
3962/// Per-IP rate limit middleware for `extra_router` routes.
3963///
3964/// Applied to the application-supplied router **before** it is merged
3965/// into the top-level router, so it wraps exactly the extra routes
3966/// (and their fallback, if any) and nothing else - `/mcp`, health,
3967/// admin, and OAuth endpoints are never affected. Outer layers (origin
3968/// check, peer-address normalization, security headers, metrics) still
3969/// wrap these routes and run first, so both `ConnectInfo` forms are
3970/// populated by the time this middleware reads them.
3971///
3972/// Semantics mirror the tool/auth limiters exactly: keyed by the
3973/// direct peer `IpAddr` (no `X-Forwarded-For`), fail-open when no peer
3974/// address is present (cannot happen under [`serve`]), and on limit a
3975/// plain-text 429 via [`RmcpServerKitError::RateLimitedFor`] carrying a
3976/// `Retry-After` header (delta-seconds), consistent with every other
3977/// limiter in the crate.
3978///
3979/// `exempt` holds raw exact-match paths (validated at config time)
3980/// checked against `req.uri().path()` **before** key extraction:
3981/// exempt requests consume no limiter budget and produce no deny
3982/// telemetry. Fail-closed - any non-listed path stays limited.
3983// cancel-safe: the limiter is charged synchronously before the `next.run(req)`
3984// await. Charging an attempt that a later timeout cancels is deliberate -- the
3985// request was admitted, so it must cost budget (same posture as auth/rbac).
3986async fn extra_route_rate_limit_middleware(
3987    limiter: Arc<ExtraRouteRateLimiter>,
3988    exempt: Arc<std::collections::HashSet<String>>,
3989    req: Request<Body>,
3990    next: Next,
3991) -> axum::response::Response {
3992    if exempt.contains(req.uri().path()) {
3993        return next.run(req).await;
3994    }
3995    let peer_key = limiter_client_key(req.extensions());
3996    match limiter.check_key_detailed(&peer_key) {
3997        Ok(()) => {}
3998        Err(BoundedLimiterDeny::RateLimited(wait)) => {
3999            #[cfg(feature = "metrics")]
4000            crate::metrics::record_rate_limit_deny(req.extensions(), "extra_route");
4001            tracing::warn!(rate_limit_key = %peer_key, "extra route request rate limited");
4002            return RmcpServerKitError::RateLimitedFor {
4003                message: "too many requests to application routes from this source".into(),
4004                retry_after: wait,
4005            }
4006            .into_response();
4007        }
4008        Err(BoundedLimiterDeny::CapacityFull) => {
4009            tracing::warn!(
4010                rate_limit_key = %peer_key,
4011                "extra route limiter rejected unseen key because tracked-key capacity is full"
4012            );
4013            return (
4014                axum::http::StatusCode::SERVICE_UNAVAILABLE,
4015                "rate limiter capacity exhausted",
4016            )
4017                .into_response();
4018        }
4019    }
4020    next.run(req).await
4021}
4022
4023/// Per the MCP spec: if the Origin header is present and its value is not in
4024/// the allowed list, respond with 403 Forbidden. Requests without an Origin
4025/// header are allowed through (e.g. non-browser clients like curl, SDKs).
4026// cancel-safe: origin validation and request logging are synchronous and happen
4027// before the single `next.run(req)` await; nothing is published on cancellation.
4028async fn origin_check_middleware(
4029    allowed: Arc<[String]>,
4030    log_request_headers: bool,
4031    req: Request<Body>,
4032    next: Next,
4033) -> axum::response::Response {
4034    let method = req.method().clone();
4035    let path = req.uri().path().to_owned();
4036
4037    log_incoming_request(&method, &path, req.headers(), log_request_headers);
4038
4039    if let Some(origin) = req.headers().get(axum::http::header::ORIGIN) {
4040        let origin_str = origin.to_str().unwrap_or("");
4041        if !allowed.iter().any(|a| a == origin_str) {
4042            tracing::warn!(
4043                origin = origin_str,
4044                %method,
4045                %path,
4046                allowed = ?&*allowed,
4047                "rejected request: Origin not allowed"
4048            );
4049            return (
4050                axum::http::StatusCode::FORBIDDEN,
4051                "Forbidden: Origin not allowed",
4052            )
4053                .into_response();
4054        }
4055    }
4056    next.run(req).await
4057}
4058
4059/// Emit a DEBUG log for an incoming request, optionally including the full
4060/// (redacted) header set.
4061fn log_incoming_request(
4062    method: &axum::http::Method,
4063    path: &str,
4064    headers: &axum::http::HeaderMap,
4065    log_request_headers: bool,
4066) {
4067    if log_request_headers {
4068        tracing::debug!(
4069            %method,
4070            %path,
4071            headers = %format_request_headers_for_log(headers),
4072            "incoming request"
4073        );
4074    } else {
4075        tracing::debug!(%method, %path, "incoming request");
4076    }
4077}
4078
4079/// Header names whose values are never rendered into logs.
4080///
4081/// SECURITY: the first three carry credentials. The forwarding headers carry
4082/// client IPs and proxy topology and are attacker-controlled on any hop the
4083/// operator has not declared trusted, so logging them verbatim lets a caller
4084/// plant misleading provenance in an incident-response trail. The trusted,
4085/// resolved address is published separately by `resolve_client_ip`.
4086const REDACTED_LOG_HEADERS: [&str; 6] = [
4087    "authorization",
4088    "cookie",
4089    "proxy-authorization",
4090    "forwarded",
4091    "x-forwarded-for",
4092    "x-real-ip",
4093];
4094
4095fn format_request_headers_for_log(headers: &axum::http::HeaderMap) -> String {
4096    headers
4097        .iter()
4098        .map(|(k, v)| {
4099            let name = k.as_str();
4100            if REDACTED_LOG_HEADERS.contains(&name) {
4101                format!("{name}: [REDACTED]")
4102            } else {
4103                format!("{name}: {}", v.to_str().unwrap_or("<non-utf8>"))
4104            }
4105        })
4106        .collect::<Vec<_>>()
4107        .join(", ")
4108}
4109
4110// -- stdio transport --
4111
4112/// Serve an MCP server over stdin/stdout (stdio transport).
4113///
4114/// # Security warnings
4115///
4116/// - **No authentication**: the parent process has full, unrestricted access.
4117/// - **No RBAC**: all tools are available regardless of policy.
4118/// - **No TLS**: messages travel over OS pipes in plaintext.
4119/// - **Single client**: only the parent process can connect.
4120/// - **No Origin validation**: not applicable to stdio.
4121///
4122/// Use this only when the MCP client spawns the server as a trusted subprocess
4123/// (e.g. Claude Desktop, VS Code Copilot). For network-accessible deployments,
4124/// use `serve()` (Streamable HTTP) instead.
4125///
4126/// # Errors
4127///
4128/// Returns [`RmcpServerKitError::Startup`] if the handler fails to initialize or the
4129/// transport disconnects unexpectedly.
4130// NOTE: reported complexity 32/25 is driven entirely by `tracing::*!`
4131// macro expansion in this 18-line function (info/warn/info + two matches).
4132// There is nothing meaningful to extract; the allow stays.
4133#[allow(
4134    clippy::cognitive_complexity,
4135    reason = "complexity is purely tracing macro expansion (info/warn + match arms); 18 lines of straight-line code, nothing meaningful to extract"
4136)]
4137pub async fn serve_stdio<H>(handler: H) -> Result<(), RmcpServerKitError>
4138where
4139    H: ServerHandler + 'static,
4140{
4141    use rmcp::ServiceExt as _;
4142
4143    tracing::info!("stdio transport: serving on stdin/stdout");
4144    tracing::warn!("stdio mode: auth, RBAC, TLS, and Origin checks are DISABLED");
4145
4146    let transport = rmcp::transport::io::stdio();
4147
4148    let service = handler
4149        .serve(transport)
4150        .await
4151        .map_err(|e| RmcpServerKitError::Startup(format!("stdio initialize failed: {e}")))?;
4152
4153    if let Err(e) = service.waiting().await {
4154        tracing::warn!(error = %e, "stdio session ended with error");
4155    }
4156    tracing::info!("stdio session ended");
4157    Ok(())
4158}
4159
4160#[allow(
4161    deprecated,
4162    reason = "builder methods are the sanctioned transition layer for deprecated public fields"
4163)]
4164impl McpServerConfig {
4165    /// Replace the TLS certificate/key paths exactly, including `None`
4166    /// values. Configuration bridges use this to preserve partial TLS
4167    /// configuration so validation reports the missing half.
4168    #[must_use]
4169    pub fn with_tls_paths(mut self, cert_path: Option<PathBuf>, key_path: Option<PathBuf>) -> Self {
4170        self.tls_cert_path = cert_path;
4171        self.tls_key_path = key_path;
4172        self
4173    }
4174
4175    /// Set only the TLS certificate path. Intended for configuration
4176    /// bridges that must preserve partial TLS configuration so validation
4177    /// can report the missing key path.
4178    #[must_use]
4179    pub fn with_tls_cert_path(mut self, cert_path: impl Into<PathBuf>) -> Self {
4180        self.tls_cert_path = Some(cert_path.into());
4181        self
4182    }
4183
4184    /// Set only the TLS private-key path. Intended for configuration
4185    /// bridges that must preserve partial TLS configuration so validation
4186    /// can report the missing certificate path.
4187    #[must_use]
4188    pub fn with_tls_key_path(mut self, key_path: impl Into<PathBuf>) -> Self {
4189        self.tls_key_path = Some(key_path.into());
4190        self
4191    }
4192
4193    /// Replace the optional authentication configuration exactly.
4194    #[must_use]
4195    pub fn with_optional_auth(mut self, auth: Option<AuthConfig>) -> Self {
4196        self.auth = auth;
4197        self
4198    }
4199
4200    /// Choose the configured session-binding secret, or `None` for the default process secret.
4201    #[must_use]
4202    pub fn with_optional_session_binding_secret(mut self, secret: Option<SecretString>) -> Self {
4203        self.session_binding_secret = secret;
4204        self
4205    }
4206
4207    /// Replace the optional tool rate limit exactly.
4208    #[must_use]
4209    pub fn with_optional_tool_rate_limit(mut self, per_minute: Option<u32>) -> Self {
4210        self.tool_rate_limit = per_minute;
4211        self
4212    }
4213
4214    /// Replace the optional tool rate-limit burst exactly.
4215    #[must_use]
4216    pub fn with_optional_tool_rate_limit_burst(mut self, burst: Option<u32>) -> Self {
4217        self.tool_rate_limit_burst = burst;
4218        self
4219    }
4220
4221    /// Replace the optional extra-route rate limit exactly.
4222    #[must_use]
4223    pub fn with_optional_extra_route_rate_limit(mut self, per_minute: Option<u32>) -> Self {
4224        self.extra_route_rate_limit = per_minute;
4225        self
4226    }
4227
4228    /// Replace the optional extra-route rate-limit burst exactly.
4229    #[must_use]
4230    pub fn with_optional_extra_route_rate_limit_burst(mut self, burst: Option<u32>) -> Self {
4231        self.extra_route_rate_limit_burst = burst;
4232        self
4233    }
4234
4235    /// Replace the optional forwarded-header mode exactly.
4236    #[must_use]
4237    pub fn with_optional_forwarded_header(mut self, mode: Option<ForwardedHeaderMode>) -> Self {
4238        self.forwarded_header = mode;
4239        self
4240    }
4241
4242    /// Replace the optional public URL exactly.
4243    #[must_use]
4244    pub fn with_optional_public_url(mut self, url: Option<String>) -> Self {
4245        self.public_url = url;
4246        self
4247    }
4248
4249    /// Override the compression minimum response size without enabling
4250    /// compression. This keeps configuration bridges able to carry the
4251    /// inert threshold independently from the enable flag.
4252    #[must_use]
4253    pub fn with_compression_min_size(mut self, min_size: u16) -> Self {
4254        self.compression_min_size = min_size;
4255        self
4256    }
4257
4258    /// Replace the compression enabled flag exactly.
4259    #[must_use]
4260    pub fn with_compression_enabled(mut self, enabled: bool) -> Self {
4261        self.compression_enabled = enabled;
4262        self
4263    }
4264
4265    /// Replace the optional global in-flight request cap exactly.
4266    #[must_use]
4267    pub fn with_optional_max_concurrent_requests(mut self, limit: Option<usize>) -> Self {
4268        self.max_concurrent_requests = limit;
4269        self
4270    }
4271
4272    /// Replace the admin endpoint enabled flag exactly.
4273    #[must_use]
4274    pub fn with_admin_enabled(mut self, enabled: bool) -> Self {
4275        self.admin_enabled = enabled;
4276        self
4277    }
4278
4279    /// Override the RBAC role required by admin-gated endpoints without
4280    /// enabling `/admin/*` diagnostics.
4281    #[must_use]
4282    pub fn with_admin_role(mut self, role: impl Into<String>) -> Self {
4283        self.admin_role = role.into();
4284        self
4285    }
4286
4287    /// Replace the build-metadata exposure flag exactly.
4288    #[must_use]
4289    pub fn with_expose_build_metadata(mut self, enabled: bool) -> Self {
4290        self.expose_build_metadata = enabled;
4291        self
4292    }
4293}
4294
4295fn warn_security_header_overrides(cfg: &SecurityHeadersConfig) {
4296    for (field, value) in security_header_overrides(cfg) {
4297        let action = if value.is_empty() {
4298            "omitted"
4299        } else {
4300            "overridden"
4301        };
4302        tracing::warn!(
4303            security_header = field,
4304            action,
4305            "security header configured; inspect server.security_headers.<security_header>"
4306        );
4307    }
4308}
4309
4310fn security_header_overrides(
4311    cfg: &SecurityHeadersConfig,
4312) -> impl Iterator<Item = (&'static str, &str)> {
4313    [
4314        (
4315            "x_content_type_options",
4316            cfg.x_content_type_options.as_deref(),
4317        ),
4318        ("x_frame_options", cfg.x_frame_options.as_deref()),
4319        ("cache_control", cfg.cache_control.as_deref()),
4320        ("referrer_policy", cfg.referrer_policy.as_deref()),
4321        (
4322            "cross_origin_opener_policy",
4323            cfg.cross_origin_opener_policy.as_deref(),
4324        ),
4325        (
4326            "cross_origin_resource_policy",
4327            cfg.cross_origin_resource_policy.as_deref(),
4328        ),
4329        (
4330            "cross_origin_embedder_policy",
4331            cfg.cross_origin_embedder_policy.as_deref(),
4332        ),
4333        ("permissions_policy", cfg.permissions_policy.as_deref()),
4334        (
4335            "x_permitted_cross_domain_policies",
4336            cfg.x_permitted_cross_domain_policies.as_deref(),
4337        ),
4338        (
4339            "content_security_policy",
4340            cfg.content_security_policy.as_deref(),
4341        ),
4342        (
4343            "x_dns_prefetch_control",
4344            cfg.x_dns_prefetch_control.as_deref(),
4345        ),
4346        (
4347            "strict_transport_security",
4348            cfg.strict_transport_security.as_deref(),
4349        ),
4350    ]
4351    .into_iter()
4352    .filter_map(|(field, value)| value.map(|v| (field, v)))
4353}
4354
4355fn check_auth_capacity_knobs(auth: Option<&AuthConfig>) -> Result<(), RmcpServerKitError> {
4356    if let Some(auth_cfg) = auth {
4357        if let Some(rl) = &auth_cfg.rate_limit {
4358            (rl.max_attempts_per_minute != 0).ok_or_else(|| {
4359                RmcpServerKitError::Config(
4360                    "auth.rate_limit.max_attempts_per_minute must be nonzero".into(),
4361                )
4362            })?;
4363            // `0` here does not mean "unlimited" -- `build_pre_auth_limiter`
4364            // falls back to DEFAULT_PRE_AUTH_RATE, so a typo silently *raises*
4365            // the pre-auth quota (e.g. 1/min + 0 yields 300/min, not 10/min)
4366            // and weakens the gate that shields Argon2 from CPU-spray.
4367            (rl.pre_auth_max_per_minute != Some(0)).ok_or_else(|| {
4368                RmcpServerKitError::Config(
4369                    "auth.rate_limit.pre_auth_max_per_minute must be nonzero when set".into(),
4370                )
4371            })?;
4372        }
4373        if let Some(mtls) = &auth_cfg.mtls {
4374            check_mtls_capacity_knobs(mtls)?;
4375        }
4376        auth_cfg.check_oauth_feature()?;
4377    }
4378    Ok(())
4379}
4380
4381fn check_mtls_capacity_knobs(mtls: &MtlsConfig) -> Result<(), RmcpServerKitError> {
4382    (mtls.crl_max_concurrent_fetches != 0).ok_or_else(|| {
4383        RmcpServerKitError::Config("auth.mtls.crl_max_concurrent_fetches must be nonzero".into())
4384    })?;
4385    (mtls.crl_discovery_rate_per_min != 0).ok_or_else(|| {
4386        RmcpServerKitError::Config("auth.mtls.crl_discovery_rate_per_min must be nonzero".into())
4387    })?;
4388    (mtls.crl_max_host_semaphores != 0).ok_or_else(|| {
4389        RmcpServerKitError::Config("auth.mtls.crl_max_host_semaphores must be nonzero".into())
4390    })?;
4391    (mtls.crl_max_seen_urls != 0).ok_or_else(|| {
4392        RmcpServerKitError::Config("auth.mtls.crl_max_seen_urls must be nonzero".into())
4393    })?;
4394    (mtls.crl_max_cache_entries != 0).ok_or_else(|| {
4395        RmcpServerKitError::Config("auth.mtls.crl_max_cache_entries must be nonzero".into())
4396    })?;
4397    // `0` rejects every non-empty CRL body at the streaming cap, so CRL
4398    // fetching never succeeds. Under the default `crl_deny_on_unavailable
4399    // = true` that fails every CDP-bearing handshake rather than loudly
4400    // reporting the misconfiguration.
4401    (mtls.crl_max_response_bytes != 0).ok_or_else(|| {
4402        RmcpServerKitError::Config("auth.mtls.crl_max_response_bytes must be nonzero".into())
4403    })?;
4404    Ok(())
4405}
4406
4407#[cfg(test)]
4408mod tests {
4409    #![allow(
4410        clippy::unwrap_used,
4411        clippy::expect_used,
4412        clippy::panic,
4413        clippy::indexing_slicing,
4414        clippy::unwrap_in_result,
4415        clippy::print_stdout,
4416        clippy::print_stderr,
4417        deprecated,
4418        reason = "internal unit tests legitimately read/write the deprecated `pub` fields they were designed to verify"
4419    )]
4420    use std::{sync::Arc, time::Duration};
4421
4422    use axum::{
4423        body::Body,
4424        http::{Request, StatusCode, header},
4425        response::IntoResponse,
4426    };
4427    use http_body_util::BodyExt;
4428    use tower::ServiceExt as _;
4429
4430    use super::*;
4431
4432    // -- startup task lifecycle --
4433
4434    #[tokio::test]
4435    async fn external_shutdown_bridge_exits_when_internal_token_cancels() {
4436        let external = CancellationToken::new();
4437        let internal = CancellationToken::new();
4438        let bridge = spawn_external_shutdown_bridge(external.clone(), internal.clone());
4439
4440        // The caller's token is never cancelled; only the server-internal one
4441        // is, as happens when startup fails after the bridge is spawned.
4442        internal.cancel();
4443
4444        let joined = tokio::time::timeout(Duration::from_secs(2), bridge).await;
4445        assert!(
4446            joined.is_ok(),
4447            "bridge task must exit once the internal token is cancelled, \
4448             otherwise it leaks for the lifetime of the process"
4449        );
4450    }
4451
4452    #[tokio::test]
4453    async fn external_shutdown_bridge_still_forwards_external_cancel() {
4454        let external = CancellationToken::new();
4455        let internal = CancellationToken::new();
4456        let bridge = spawn_external_shutdown_bridge(external.clone(), internal.clone());
4457
4458        external.cancel();
4459
4460        let joined = tokio::time::timeout(Duration::from_secs(2), bridge).await;
4461        assert!(joined.is_ok(), "bridge task must exit on external cancel");
4462        assert!(
4463            internal.is_cancelled(),
4464            "external cancellation must still propagate to the internal token"
4465        );
4466    }
4467
4468    #[test]
4469    fn cancel_on_drop_cancels_its_token() {
4470        let ct = CancellationToken::new();
4471        {
4472            let _guard = CancelOnDrop(ct.clone());
4473            assert!(!ct.is_cancelled());
4474        }
4475        assert!(
4476            ct.is_cancelled(),
4477            "dropping the guard must cancel background startup tasks"
4478        );
4479    }
4480
4481    #[test]
4482    fn validate_rejects_mtls_without_tls() {
4483        for (cert, key) in [
4484            (None, None),
4485            (Some("cert.pem"), None),
4486            (None, Some("key.pem")),
4487        ] {
4488            let mut auth = AuthConfig::with_keys(vec![]);
4489            auth.mtls = Some(valid_mtls_config());
4490            let mut cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
4491            cfg.tls_cert_path = cert.map(Into::into);
4492            cfg.tls_key_path = key.map(Into::into);
4493
4494            let err = cfg
4495                .validate()
4496                .expect_err("mTLS without both TLS paths must be rejected");
4497            let msg = err.to_string();
4498            assert!(
4499                msg.contains("tls_cert_path") && msg.contains("tls_key_path"),
4500                "cert={cert:?} key={key:?}: {msg}"
4501            );
4502        }
4503    }
4504
4505    #[test]
4506    fn validate_accepts_mtls_with_tls() {
4507        let mut auth = AuthConfig::with_keys(vec![]);
4508        auth.mtls = Some(valid_mtls_config());
4509        let mut cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
4510        cfg.tls_cert_path = Some("cert.pem".into());
4511        cfg.tls_key_path = Some("key.pem".into());
4512
4513        assert!(cfg.validate().is_ok(), "mTLS with both TLS paths is valid");
4514    }
4515
4516    #[test]
4517    fn validate_rejects_blank_api_key_name() {
4518        let blank = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(
4519            AuthConfig::with_keys(vec![crate::auth::ApiKeyEntry::new("", "hash", "viewer")]),
4520        );
4521        let err = blank
4522            .validate()
4523            .expect_err("blank API-key name must be rejected");
4524        assert!(err.to_string().contains("api_keys[0]"), "{err}");
4525
4526        let whitespace = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(
4527            AuthConfig::with_keys(vec![crate::auth::ApiKeyEntry::new("   ", "hash", "viewer")]),
4528        );
4529        assert!(whitespace.validate().is_err());
4530
4531        let ok = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(
4532            AuthConfig::with_keys(vec![crate::auth::ApiKeyEntry::new(
4533                "viewer-key",
4534                "hash",
4535                "viewer",
4536            )]),
4537        );
4538        assert!(ok.validate().is_ok(), "a normal name must still validate");
4539    }
4540
4541    fn reload_test_state(name: &str) -> (Arc<AuthState>, String) {
4542        let (token, hash) = crate::auth::generate_api_key().unwrap();
4543        let state = Arc::new(AuthState {
4544            api_keys: ArcSwap::from_pointee(vec![crate::auth::ApiKeyEntry::new(name, hash, "ops")]),
4545            rate_limiter: None,
4546            pre_auth_limiter: None,
4547            #[cfg(feature = "oauth")]
4548            jwks_cache: None,
4549            seen_identities: crate::auth::SeenIdentitySet::new(),
4550            counters: crate::auth::AuthCounters::default(),
4551            resource_metadata_url: None,
4552        });
4553        (state, token)
4554    }
4555
4556    #[test]
4557    fn try_reload_auth_keys_rejects_blank_name() {
4558        let (state, _token) = reload_test_state("prev-key");
4559        let handle = ReloadHandle {
4560            auth: Some(state),
4561            rbac: None,
4562            crl_set: None,
4563        };
4564        let err = handle
4565            .try_reload_auth_keys(vec![crate::auth::ApiKeyEntry::new("", "h", "ops")])
4566            .expect_err("blank API-key name must be rejected on reload");
4567        assert!(err.to_string().contains("api_keys[0]"), "{err}");
4568    }
4569
4570    #[test]
4571    fn reload_auth_keys_blank_name_leaves_previous_keys() {
4572        let (state, token) = reload_test_state("prev-key");
4573        let handle = ReloadHandle {
4574            auth: Some(Arc::clone(&state)),
4575            rbac: None,
4576            crl_set: None,
4577        };
4578        handle.reload_auth_keys(vec![crate::auth::ApiKeyEntry::new("  ", "h", "ops")]);
4579
4580        let installed = state.api_keys.load();
4581        assert!(
4582            crate::auth::verify_bearer_token(&token, &installed).is_some(),
4583            "the previous key must still authenticate after a rejected reload"
4584        );
4585    }
4586
4587    // -- McpServerConfig --
4588
4589    #[test]
4590    fn server_config_new_defaults() {
4591        let cfg = McpServerConfig::new("0.0.0.0:8443", "test-server", "1.0.0");
4592        assert_eq!(cfg.bind_addr, "0.0.0.0:8443");
4593        assert_eq!(cfg.name, "test-server");
4594        assert_eq!(cfg.version, "1.0.0");
4595        assert!(cfg.tls_cert_path.is_none());
4596        assert!(cfg.tls_key_path.is_none());
4597        assert!(cfg.auth.is_none());
4598        assert!(cfg.rbac.is_none());
4599        assert!(cfg.allowed_origins.is_empty());
4600        assert!(cfg.tool_rate_limit.is_none());
4601        assert!(cfg.readiness_check.is_none());
4602        assert_eq!(cfg.max_request_body, 1024 * 1024);
4603        assert_eq!(cfg.request_timeout, Duration::from_mins(2));
4604        assert_eq!(cfg.shutdown_timeout, Duration::from_secs(30));
4605        assert!(!cfg.log_request_headers);
4606        assert_eq!(cfg.tls_handshake_timeout, Duration::from_secs(10));
4607        assert_eq!(cfg.max_concurrent_tls_handshakes, 256);
4608        assert!(cfg.session_store.is_none());
4609        assert!(cfg.session_binding_secret.is_none());
4610    }
4611
4612    #[derive(Default)]
4613    struct TestSessionStore;
4614
4615    #[async_trait::async_trait]
4616    impl SessionStore for TestSessionStore {
4617        async fn load(
4618            &self,
4619            _session_id: &str,
4620        ) -> Result<
4621            Option<rmcp::transport::streamable_http_server::session::SessionState>,
4622            rmcp::transport::streamable_http_server::session::SessionStoreError,
4623        > {
4624            Ok(None)
4625        }
4626
4627        async fn store(
4628            &self,
4629            _session_id: &str,
4630            _state: &rmcp::transport::streamable_http_server::session::SessionState,
4631        ) -> Result<(), rmcp::transport::streamable_http_server::session::SessionStoreError>
4632        {
4633            Ok(())
4634        }
4635
4636        async fn delete(
4637            &self,
4638            _session_id: &str,
4639        ) -> Result<(), rmcp::transport::streamable_http_server::session::SessionStoreError>
4640        {
4641            Ok(())
4642        }
4643    }
4644
4645    fn test_session_store() -> Arc<dyn SessionStore> {
4646        Arc::new(TestSessionStore)
4647    }
4648
4649    fn shared_session_binding_secret() -> SecretString {
4650        SecretString::from("0123456789abcdef0123456789abcdef")
4651    }
4652
4653    #[test]
4654    fn session_store_defaults_to_none() {
4655        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
4656
4657        assert!(cfg.session_store.is_none());
4658    }
4659
4660    #[test]
4661    fn event_store_defaults_to_none() {
4662        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
4663
4664        assert!(cfg.event_store.is_none());
4665    }
4666
4667    #[test]
4668    fn validate_rejects_session_store_without_binding_secret() {
4669        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4670            .with_auth(AuthConfig::with_keys(vec![]))
4671            .with_session_store(test_session_store());
4672
4673        let err = cfg
4674            .validate()
4675            .expect_err("authenticated shared-store binding needs a shared secret");
4676        let msg = err.to_string();
4677        assert!(msg.contains("session_store"), "{msg}");
4678        assert!(msg.contains("session_binding"), "{msg}");
4679        assert!(msg.contains("shared secret"), "{msg}");
4680    }
4681
4682    #[test]
4683    fn validate_allows_session_store_with_binding_secret() {
4684        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4685            .with_auth(AuthConfig::with_keys(vec![]))
4686            .with_session_store(test_session_store())
4687            .with_session_binding_secret(shared_session_binding_secret());
4688
4689        assert!(cfg.validate().is_ok());
4690    }
4691
4692    #[test]
4693    fn validate_allows_session_store_when_binding_disabled() {
4694        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4695            .with_auth(AuthConfig::with_keys(vec![]))
4696            .with_session_binding(false)
4697            .with_session_store(test_session_store());
4698
4699        assert!(cfg.validate().is_ok());
4700    }
4701
4702    #[test]
4703    fn validate_allows_binding_secret_without_session_store() {
4704        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4705            .with_auth(AuthConfig::with_keys(vec![]))
4706            .with_session_binding_secret(shared_session_binding_secret());
4707
4708        assert!(cfg.validate().is_ok());
4709    }
4710
4711    #[test]
4712    fn tls_handshake_builders_set_fields() {
4713        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4714            .with_tls_handshake_timeout(Duration::from_secs(3))
4715            .with_max_concurrent_tls_handshakes(64);
4716        assert_eq!(cfg.tls_handshake_timeout, Duration::from_secs(3));
4717        assert_eq!(cfg.max_concurrent_tls_handshakes, 64);
4718    }
4719
4720    #[test]
4721    fn validate_rejects_zero_tls_handshake_timeout() {
4722        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4723            .with_tls_handshake_timeout(Duration::ZERO);
4724        let err = cfg.validate().expect_err("zero handshake timeout");
4725        assert!(err.to_string().contains("tls_handshake_timeout"));
4726    }
4727
4728    #[test]
4729    fn validate_rejects_zero_max_concurrent_tls_handshakes() {
4730        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
4731            .with_max_concurrent_tls_handshakes(0);
4732        let err = cfg.validate().expect_err("zero handshake concurrency");
4733        assert!(err.to_string().contains("max_concurrent_tls_handshakes"));
4734    }
4735
4736    #[test]
4737    fn validate_consumes_and_proves() {
4738        // Valid config -> Validated wrapper, original is consumed.
4739        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
4740        let validated = cfg.validate().expect("valid config");
4741        // as_inner() gives read-only access to inner fields.
4742        assert_eq!(validated.as_inner().name, "test-server");
4743        // into_inner recovers the raw value.
4744        let raw = validated.into_inner();
4745        assert_eq!(raw.name, "test-server");
4746
4747        // Invalid config (zero max_request_body) -> Err.
4748        let mut bad = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0");
4749        bad.max_request_body = 0;
4750        assert!(bad.validate().is_err(), "zero body cap must fail validate");
4751    }
4752
4753    #[test]
4754    fn validate_rejects_zero_max_concurrent_requests() {
4755        let cfg =
4756            McpServerConfig::new("127.0.0.1:8080", "test", "1.0.0").with_max_concurrent_requests(0);
4757        let err = cfg.validate().expect_err("zero concurrency cap must fail");
4758        assert!(
4759            format!("{err}").contains("max_concurrent_requests"),
4760            "error should mention max_concurrent_requests, got: {err}"
4761        );
4762    }
4763
4764    #[test]
4765    fn validate_rejects_zero_max_tracked_keys() {
4766        // Defaults mirror auth::default_max_attempts / default_idle_eviction
4767        // (module-private in auth.rs); spelled out here for review clarity.
4768        let rl = crate::auth::RateLimitConfig {
4769            max_attempts_per_minute: 30,
4770            pre_auth_max_per_minute: None,
4771            max_tracked_keys: 0,
4772            idle_eviction: Duration::from_secs(15 * 60),
4773            burst: None,
4774            pre_auth_burst: None,
4775            key_eviction_policy: KeyEvictionPolicy::default(),
4776        };
4777        let auth_cfg = AuthConfig {
4778            enabled: true,
4779            api_keys: Vec::new(),
4780            mtls: None,
4781            rate_limit: Some(rl),
4782            #[cfg(feature = "oauth")]
4783            oauth: None,
4784            #[cfg(not(feature = "oauth"))]
4785            oauth: None,
4786        };
4787        let cfg = McpServerConfig::new("127.0.0.1:8080", "test", "1.0.0").with_auth(auth_cfg);
4788        let err = cfg.validate().expect_err("zero max_tracked_keys must fail");
4789        assert!(
4790            format!("{err}").contains("max_tracked_keys"),
4791            "error should mention max_tracked_keys, got: {err}"
4792        );
4793    }
4794
4795    #[test]
4796    fn derive_allowed_hosts_includes_public_host() {
4797        let hosts = derive_allowed_hosts("0.0.0.0:8080", Some("https://mcp.example.com/mcp"));
4798        assert!(
4799            hosts.iter().any(|h| h == "mcp.example.com"),
4800            "public_url host must be allowed"
4801        );
4802    }
4803
4804    #[test]
4805    fn derive_allowed_hosts_includes_bind_authority() {
4806        let hosts = derive_allowed_hosts("127.0.0.1:8080", None);
4807        assert!(
4808            hosts.iter().any(|h| h == "127.0.0.1"),
4809            "bind host must be allowed"
4810        );
4811        assert!(
4812            hosts.iter().any(|h| h == "127.0.0.1:8080"),
4813            "bind authority must be allowed"
4814        );
4815    }
4816
4817    // -- healthz --
4818
4819    #[tokio::test]
4820    async fn healthz_returns_ok_json() {
4821        let resp = healthz().await.into_response();
4822        assert_eq!(resp.status(), StatusCode::OK);
4823        let body = resp.into_body().collect().await.unwrap().to_bytes();
4824        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4825        assert_eq!(json["status"], "ok");
4826        assert!(
4827            json.get("name").is_none(),
4828            "healthz must not expose server name"
4829        );
4830        assert!(
4831            json.get("version").is_none(),
4832            "healthz must not expose version"
4833        );
4834    }
4835
4836    // -- readyz --
4837
4838    #[tokio::test]
4839    async fn readyz_returns_ok_when_ready() {
4840        let check: ReadinessCheck =
4841            Arc::new(|| Box::pin(async { serde_json::json!({"ready": true, "db": "connected"}) }));
4842        let resp = readyz(check).await.into_response();
4843        assert_eq!(resp.status(), StatusCode::OK);
4844        let body = resp.into_body().collect().await.unwrap().to_bytes();
4845        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
4846        assert_eq!(json["ready"], true);
4847        assert!(
4848            json.get("name").is_none(),
4849            "readyz must not expose server name"
4850        );
4851        assert!(
4852            json.get("version").is_none(),
4853            "readyz must not expose version"
4854        );
4855        assert_eq!(json["db"], "connected");
4856    }
4857
4858    #[tokio::test]
4859    async fn readyz_returns_503_when_not_ready() {
4860        let check: ReadinessCheck =
4861            Arc::new(|| Box::pin(async { serde_json::json!({"ready": false}) }));
4862        let resp = readyz(check).await.into_response();
4863        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
4864    }
4865
4866    #[tokio::test]
4867    async fn readyz_returns_503_when_ready_missing() {
4868        let check: ReadinessCheck =
4869            Arc::new(|| Box::pin(async { serde_json::json!({"status": "starting"}) }));
4870        let resp = readyz(check).await.into_response();
4871        // Missing "ready" field defaults to false -> 503
4872        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
4873    }
4874
4875    // -- normalize_peer_addr_middleware / PeerAddr --
4876
4877    /// Build a test router that reports the request's peer-address
4878    /// extensions as `"<ConnectInfo>|<PeerAddr>"` (empty when absent).
4879    fn peer_probe_router() -> axum::Router {
4880        async fn probe(req: Request<Body>) -> String {
4881            let ci = req
4882                .extensions()
4883                .get::<ConnectInfo<SocketAddr>>()
4884                .map(|c| c.0.to_string())
4885                .unwrap_or_default();
4886            let pa = req
4887                .extensions()
4888                .get::<PeerAddr>()
4889                .map(|p| p.addr.to_string())
4890                .unwrap_or_default();
4891            format!("{ci}|{pa}")
4892        }
4893        axum::Router::new()
4894            .route("/probe", axum::routing::get(probe))
4895            .layer(axum::middleware::from_fn(|req, next| {
4896                normalize_peer_addr_middleware(None, req, next)
4897            }))
4898    }
4899
4900    async fn body_string(resp: axum::response::Response) -> String {
4901        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
4902        String::from_utf8(bytes.to_vec()).unwrap()
4903    }
4904
4905    #[tokio::test]
4906    async fn normalize_preserves_existing_connect_info_and_mirrors_peer_addr() {
4907        // Precedence proof: when both extensions exist with DIFFERENT
4908        // addresses, ConnectInfo<SocketAddr> wins and is never overwritten.
4909        let plain: SocketAddr = "10.0.0.1:1111".parse().unwrap();
4910        let tls: SocketAddr = "10.0.0.2:2222".parse().unwrap();
4911        let req = Request::builder()
4912            .uri("/probe")
4913            .extension(ConnectInfo(plain))
4914            .extension(ConnectInfo(TlsConnInfo::new(tls, None)))
4915            .body(Body::empty())
4916            .unwrap();
4917        let resp = peer_probe_router().oneshot(req).await.unwrap();
4918        assert_eq!(resp.status(), StatusCode::OK);
4919        assert_eq!(body_string(resp).await, format!("{plain}|{plain}"));
4920    }
4921
4922    #[tokio::test]
4923    async fn normalize_inserts_connect_info_and_peer_addr_from_tls() {
4924        let tls: SocketAddr = "192.168.1.7:50443".parse().unwrap();
4925        let req = Request::builder()
4926            .uri("/probe")
4927            .extension(ConnectInfo(TlsConnInfo::new(tls, None)))
4928            .body(Body::empty())
4929            .unwrap();
4930        let resp = peer_probe_router().oneshot(req).await.unwrap();
4931        assert_eq!(resp.status(), StatusCode::OK);
4932        assert_eq!(body_string(resp).await, format!("{tls}|{tls}"));
4933    }
4934
4935    #[tokio::test]
4936    async fn normalize_no_op_without_any_connect_info() {
4937        let req = Request::builder()
4938            .uri("/probe")
4939            .body(Body::empty())
4940            .unwrap();
4941        let resp = peer_probe_router().oneshot(req).await.unwrap();
4942        assert_eq!(resp.status(), StatusCode::OK);
4943        assert_eq!(body_string(resp).await, "|");
4944    }
4945
4946    #[tokio::test]
4947    async fn peer_addr_extractor_rejects_when_absent() {
4948        async fn h(peer: PeerAddr) -> String {
4949            peer.addr.to_string()
4950        }
4951        let app = axum::Router::new().route("/p", axum::routing::get(h));
4952        let req = Request::builder().uri("/p").body(Body::empty()).unwrap();
4953        let resp = app.oneshot(req).await.unwrap();
4954        assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
4955    }
4956
4957    #[tokio::test]
4958    async fn peer_addr_extractor_returns_value_when_present() {
4959        async fn h(peer: PeerAddr) -> String {
4960            peer.addr.to_string()
4961        }
4962        let addr: SocketAddr = "127.0.0.1:9999".parse().unwrap();
4963        let app = axum::Router::new().route("/p", axum::routing::get(h));
4964        let req = Request::builder()
4965            .uri("/p")
4966            .extension(PeerAddr::new(addr))
4967            .body(Body::empty())
4968            .unwrap();
4969        let resp = app.oneshot(req).await.unwrap();
4970        assert_eq!(resp.status(), StatusCode::OK);
4971        assert_eq!(body_string(resp).await, addr.to_string());
4972    }
4973
4974    #[tokio::test]
4975    async fn peer_addr_via_extension_extractor() {
4976        async fn h(axum::Extension(peer): axum::Extension<PeerAddr>) -> String {
4977            peer.addr.to_string()
4978        }
4979        let addr: SocketAddr = "127.0.0.1:4242".parse().unwrap();
4980        let app = axum::Router::new().route("/p", axum::routing::get(h));
4981        let req = Request::builder()
4982            .uri("/p")
4983            .extension(PeerAddr::new(addr))
4984            .body(Body::empty())
4985            .unwrap();
4986        let resp = app.oneshot(req).await.unwrap();
4987        assert_eq!(resp.status(), StatusCode::OK);
4988        assert_eq!(body_string(resp).await, addr.to_string());
4989    }
4990
4991    // -- extra_route_rate_limit_middleware --
4992
4993    /// Probe router with the extra-route limiter installed, mirroring
4994    /// the layer-before-merge wiring in `build_app_router`.
4995    fn limited_router(per_minute: u32) -> axum::Router {
4996        limited_router_with_burst(per_minute, None)
4997    }
4998
4999    /// Probe router with an explicit burst capacity.
5000    fn limited_router_with_burst(per_minute: u32, burst: Option<u32>) -> axum::Router {
5001        limited_router_full(per_minute, burst, &[])
5002    }
5003
5004    /// Probe router with explicit burst and exempt paths. `/limited`
5005    /// and `/exempt` are both registered so exemption interplay can be
5006    /// asserted on one limiter instance.
5007    fn limited_router_full(
5008        per_minute: u32,
5009        burst: Option<u32>,
5010        exempt_paths: &[&str],
5011    ) -> axum::Router {
5012        let limiter = build_extra_route_rate_limiter_with_policy(
5013            per_minute,
5014            burst,
5015            KeyEvictionPolicy::default(),
5016            NonZeroUsize::new(EXTRA_ROUTE_MAX_TRACKED_KEYS).unwrap_or(NonZeroUsize::MIN),
5017        );
5018        let exempt: Arc<std::collections::HashSet<String>> =
5019            Arc::new(exempt_paths.iter().map(|s| (*s).to_owned()).collect());
5020        axum::Router::new()
5021            .route("/limited", axum::routing::get(|| async { "ok" }))
5022            .route("/exempt", axum::routing::get(|| async { "ok" }))
5023            .layer(axum::middleware::from_fn(move |req, next| {
5024                let l = Arc::clone(&limiter);
5025                let e = Arc::clone(&exempt);
5026                extra_route_rate_limit_middleware(l, e, req, next)
5027            }))
5028    }
5029
5030    fn limited_req(ip: &str) -> Request<Body> {
5031        limited_req_to(ip, "/limited")
5032    }
5033
5034    fn limited_req_to(ip: &str, path: &str) -> Request<Body> {
5035        let addr: SocketAddr = format!("{ip}:40000").parse().unwrap();
5036        Request::builder()
5037            .uri(path)
5038            .extension(ConnectInfo(addr))
5039            .body(Body::empty())
5040            .unwrap()
5041    }
5042
5043    #[tokio::test]
5044    async fn extra_route_limiter_denies_over_quota() {
5045        let app = limited_router(2);
5046        for i in 0..2 {
5047            let resp = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
5048            assert_eq!(resp.status(), StatusCode::OK, "request {i} should pass");
5049        }
5050        let resp = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
5051        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
5052        let body = body_string(resp).await;
5053        assert!(
5054            body.contains("too many requests to application routes"),
5055            "deny body should match the limiter message, got: {body}"
5056        );
5057    }
5058
5059    fn one_tracked_key() -> NonZeroUsize {
5060        NonZeroUsize::new(1).unwrap_or(NonZeroUsize::MIN)
5061    }
5062
5063    #[tokio::test]
5064    async fn extra_route_limiter_capacity_full_returns_503_without_retry_after() {
5065        let limiter = build_extra_route_rate_limiter_with_policy(
5066            10,
5067            None,
5068            KeyEvictionPolicy::RejectNew,
5069            one_tracked_key(),
5070        );
5071        let exempt = Arc::new(std::collections::HashSet::new());
5072        let app = axum::Router::new()
5073            .route("/limited", axum::routing::get(|| async { "ok" }))
5074            .layer(axum::middleware::from_fn(move |req, next| {
5075                let l = Arc::clone(&limiter);
5076                let e = Arc::clone(&exempt);
5077                extra_route_rate_limit_middleware(l, e, req, next)
5078            }));
5079        let established = app.clone().oneshot(limited_req("10.1.1.1")).await.unwrap();
5080        assert_eq!(established.status(), StatusCode::OK);
5081
5082        let denied = app.clone().oneshot(limited_req("10.1.1.2")).await.unwrap();
5083
5084        assert_eq!(denied.status(), StatusCode::SERVICE_UNAVAILABLE);
5085        assert!(denied.headers().get(header::RETRY_AFTER).is_none());
5086    }
5087
5088    #[tokio::test]
5089    async fn extra_route_limiter_isolates_keys() {
5090        let app = limited_router(2);
5091        for _ in 0..2 {
5092            let resp = app.clone().oneshot(limited_req("10.2.2.2")).await.unwrap();
5093            assert_eq!(resp.status(), StatusCode::OK);
5094        }
5095        let exhausted = app.clone().oneshot(limited_req("10.2.2.2")).await.unwrap();
5096        assert_eq!(exhausted.status(), StatusCode::TOO_MANY_REQUESTS);
5097        // A different source IP still has a fresh bucket.
5098        let other = app.clone().oneshot(limited_req("10.3.3.3")).await.unwrap();
5099        assert_eq!(other.status(), StatusCode::OK);
5100    }
5101
5102    #[tokio::test]
5103    async fn extra_route_limiter_bounds_requests_without_peer() {
5104        // Was `fails_open_without_peer`. A request whose source address
5105        // cannot be resolved must NOT be exempt from rate limiting; such
5106        // requests share one bounded `Unattributed` bucket.
5107        let app = limited_router(1);
5108        let mk = || {
5109            Request::builder()
5110                .uri("/limited")
5111                .body(Body::empty())
5112                .unwrap()
5113        };
5114        let first = app.clone().oneshot(mk()).await.unwrap();
5115        assert_eq!(
5116            first.status(),
5117            StatusCode::OK,
5118            "first request consumes quota"
5119        );
5120        let second = app.clone().oneshot(mk()).await.unwrap();
5121        assert_eq!(
5122            second.status(),
5123            StatusCode::TOO_MANY_REQUESTS,
5124            "unattributable requests must share a bounded bucket, not bypass the limiter"
5125        );
5126    }
5127
5128    #[test]
5129    fn limiter_client_key_falls_back_to_unattributed() {
5130        let empty = axum::http::Extensions::new();
5131        assert_eq!(limiter_client_key(&empty), RateLimitKey::Unattributed);
5132    }
5133
5134    #[test]
5135    fn unattributed_key_is_distinct_from_unspecified_ip() {
5136        // Regression guard: a sentinel `0.0.0.0` would collide here,
5137        // because trusted-forwarder mode derives ClientIp from a header
5138        // and `crate::forwarded` does not filter unspecified addresses.
5139        let unspecified = RateLimitKey::Ip("0.0.0.0".parse::<IpAddr>().unwrap());
5140        assert_ne!(unspecified, RateLimitKey::Unattributed);
5141
5142        let mut set = std::collections::HashSet::new();
5143        set.insert(unspecified);
5144        set.insert(RateLimitKey::Unattributed);
5145        assert_eq!(set.len(), 2, "the two keys must hash to distinct buckets");
5146    }
5147
5148    #[test]
5149    fn rate_limit_key_display_does_not_fabricate_an_ip() {
5150        assert_eq!(
5151            RateLimitKey::Ip("10.1.2.3".parse::<IpAddr>().unwrap()).to_string(),
5152            "10.1.2.3"
5153        );
5154        assert_eq!(RateLimitKey::Unattributed.to_string(), "unattributed");
5155    }
5156
5157    #[tokio::test]
5158    async fn extra_route_limiter_extracts_tls_conn_info() {
5159        let app = limited_router(2);
5160        let mk = || {
5161            let addr: SocketAddr = "192.168.9.9:55555".parse().unwrap();
5162            Request::builder()
5163                .uri("/limited")
5164                .extension(ConnectInfo(TlsConnInfo::new(addr, None)))
5165                .body(Body::empty())
5166                .unwrap()
5167        };
5168        for _ in 0..2 {
5169            assert_eq!(
5170                app.clone().oneshot(mk()).await.unwrap().status(),
5171                StatusCode::OK
5172            );
5173        }
5174        let resp = app.clone().oneshot(mk()).await.unwrap();
5175        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
5176    }
5177
5178    #[tokio::test]
5179    async fn extra_route_limiter_exempt_path_bypasses_quota() {
5180        // rate=1: a single non-exempt request exhausts the bucket, yet
5181        // repeated exempt-path requests all pass and consume no budget.
5182        let app = limited_router_full(1, None, &["/exempt"]);
5183        for i in 0..5 {
5184            let resp = app
5185                .clone()
5186                .oneshot(limited_req_to("10.6.6.6", "/exempt"))
5187                .await
5188                .unwrap();
5189            assert_eq!(resp.status(), StatusCode::OK, "exempt request {i}");
5190        }
5191        // Budget untouched by exempt traffic: first limited request OK…
5192        let resp = app.clone().oneshot(limited_req("10.6.6.6")).await.unwrap();
5193        assert_eq!(resp.status(), StatusCode::OK);
5194        // …second is denied (exemption did not leak onto /limited).
5195        let resp = app.clone().oneshot(limited_req("10.6.6.6")).await.unwrap();
5196        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
5197    }
5198
5199    #[tokio::test]
5200    async fn extra_route_limiter_exemption_is_raw_exact_match() {
5201        // Trailing-slash and case variants are NOT exempt (fail-closed:
5202        // a mismatch keeps the request limited, never the reverse).
5203        let app = limited_router_full(1, None, &["/exempt"]);
5204        let ok = app
5205            .clone()
5206            .oneshot(limited_req_to("10.7.7.7", "/exempt/"))
5207            .await
5208            .unwrap();
5209        assert_eq!(
5210            ok.status(),
5211            StatusCode::NOT_FOUND,
5212            "variant path routes 404"
5213        );
5214        // The variant consumed limiter budget (it was not exempt):
5215        let denied = app
5216            .clone()
5217            .oneshot(limited_req_to("10.7.7.7", "/limited"))
5218            .await
5219            .unwrap();
5220        assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
5221    }
5222
5223    #[cfg(feature = "metrics")]
5224    #[tokio::test]
5225    async fn extra_route_limiter_deny_increments_counter_exempt_does_not() {
5226        let metrics = Arc::new(crate::metrics::McpMetrics::new().unwrap());
5227        let app = limited_router_full(1, None, &["/exempt"]);
5228        let mk = |path: &str| {
5229            let addr: SocketAddr = "10.8.8.8:40000".parse().unwrap();
5230            Request::builder()
5231                .uri(path)
5232                .extension(ConnectInfo(addr))
5233                .extension(Arc::clone(&metrics))
5234                .body(Body::empty())
5235                .unwrap()
5236        };
5237        let counter = || {
5238            metrics
5239                .rate_limited_total
5240                .with_label_values(&["extra_route"])
5241                .get()
5242        };
5243        // Exempt traffic: no budget, no counter.
5244        for _ in 0..3 {
5245            assert_eq!(
5246                app.clone().oneshot(mk("/exempt")).await.unwrap().status(),
5247                StatusCode::OK
5248            );
5249        }
5250        assert_eq!(counter(), 0, "exempt requests must not count as denies");
5251        // Exhaust then deny: counter increments exactly on the deny.
5252        assert_eq!(
5253            app.clone().oneshot(mk("/limited")).await.unwrap().status(),
5254            StatusCode::OK
5255        );
5256        assert_eq!(counter(), 0);
5257        assert_eq!(
5258            app.clone().oneshot(mk("/limited")).await.unwrap().status(),
5259            StatusCode::TOO_MANY_REQUESTS
5260        );
5261        assert_eq!(counter(), 1, "deny must increment the extra_route label");
5262    }
5263
5264    #[test]
5265    fn validate_rejects_exempt_paths_without_base_knob() {
5266        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5267            .with_extra_route_rate_limit_exempt_paths(["/ok"]);
5268        let err = cfg.validate().expect_err("exempt paths without rate limit");
5269        assert!(err.to_string().contains("requires extra_route_rate_limit"));
5270    }
5271
5272    #[test]
5273    fn validate_rejects_malformed_exempt_paths() {
5274        for bad in ["", "no-slash"] {
5275            let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5276                .with_extra_route_rate_limit(10)
5277                .with_extra_route_rate_limit_exempt_paths([bad]);
5278            let err = cfg.validate().expect_err("malformed exempt path");
5279            assert!(
5280                err.to_string()
5281                    .contains("must be non-empty and start with '/'"),
5282                "entry {bad:?}: {err}"
5283            );
5284        }
5285    }
5286
5287    #[test]
5288    fn validate_accepts_wellformed_exempt_paths() {
5289        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5290            .with_extra_route_rate_limit(10)
5291            .with_extra_route_rate_limit_exempt_paths(["/.well-known/oauth-authorization-server"]);
5292        assert!(cfg.validate().is_ok());
5293    }
5294
5295    #[test]
5296    fn validate_rejects_zero_extra_route_rate_limit() {
5297        let cfg = McpServerConfig::new("127.0.0.1:8080", "test-server", "1.0.0")
5298            .with_extra_route_rate_limit(0);
5299        let err = cfg.validate().expect_err("zero extra route rate limit");
5300        assert!(err.to_string().contains("extra_route_rate_limit"));
5301    }
5302
5303    #[tokio::test]
5304    async fn extra_route_limiter_burst_allows_initial_spike() {
5305        let app = limited_router_with_burst(1, Some(3));
5306        for i in 0..3 {
5307            let resp = app.clone().oneshot(limited_req("10.4.4.4")).await.unwrap();
5308            assert_eq!(resp.status(), StatusCode::OK, "burst request {i}");
5309        }
5310        let resp = app.clone().oneshot(limited_req("10.4.4.4")).await.unwrap();
5311        assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS);
5312    }
5313
5314    #[tokio::test]
5315    async fn extra_route_limiter_deny_sets_retry_after() {
5316        let app = limited_router(1);
5317        let ok = app.clone().oneshot(limited_req("10.5.5.5")).await.unwrap();
5318        assert_eq!(ok.status(), StatusCode::OK);
5319        let denied = app.clone().oneshot(limited_req("10.5.5.5")).await.unwrap();
5320        assert_eq!(denied.status(), StatusCode::TOO_MANY_REQUESTS);
5321        let retry_after = denied
5322            .headers()
5323            .get(header::RETRY_AFTER)
5324            .expect("Retry-After present")
5325            .to_str()
5326            .unwrap()
5327            .parse::<u64>()
5328            .unwrap();
5329        assert!(retry_after >= 1, "delta-seconds must be >= 1");
5330    }
5331
5332    #[test]
5333    fn validate_rejects_zero_burst_knobs() {
5334        let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5335            .with_tool_rate_limit(10)
5336            .with_tool_rate_limit_burst(0)
5337            .validate()
5338            .expect_err("zero tool burst");
5339        assert!(err.to_string().contains("tool_rate_limit_burst"));
5340
5341        let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5342            .with_extra_route_rate_limit(10)
5343            .with_extra_route_rate_limit_burst(0)
5344            .validate()
5345            .expect_err("zero extra route burst");
5346        assert!(err.to_string().contains("extra_route_rate_limit_burst"));
5347    }
5348
5349    #[test]
5350    fn validate_rejects_orphan_burst_knobs() {
5351        let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5352            .with_tool_rate_limit_burst(5)
5353            .validate()
5354            .expect_err("orphan tool burst");
5355        assert!(err.to_string().contains("requires tool_rate_limit"));
5356
5357        let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5358            .with_extra_route_rate_limit_burst(5)
5359            .validate()
5360            .expect_err("orphan extra route burst");
5361        assert!(err.to_string().contains("requires extra_route_rate_limit"));
5362    }
5363
5364    #[test]
5365    fn validate_rejects_zero_auth_bursts() {
5366        let auth = AuthConfig::with_keys(vec![])
5367            .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_burst(0));
5368        let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5369            .with_auth(auth)
5370            .validate()
5371            .expect_err("zero auth burst");
5372        assert!(err.to_string().contains("rate_limit.burst"));
5373
5374        let auth = AuthConfig::with_keys(vec![])
5375            .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(0));
5376        let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5377            .with_auth(auth)
5378            .validate()
5379            .expect_err("zero pre-auth burst");
5380        assert!(err.to_string().contains("pre_auth_burst"));
5381    }
5382
5383    #[test]
5384    fn validate_rejects_zero_pre_auth_max_per_minute() {
5385        let auth = AuthConfig::with_keys(vec![])
5386            .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_max_per_minute(0));
5387        let err = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5388            .with_auth(auth)
5389            .validate()
5390            .expect_err("zero pre-auth rate");
5391        assert!(err.to_string().contains("pre_auth_max_per_minute"));
5392    }
5393
5394    fn valid_mtls_config() -> MtlsConfig {
5395        MtlsConfig {
5396            ca_cert_path: "memory://ca.pem".into(),
5397            required: true,
5398            default_role: "viewer".into(),
5399            crl_enabled: true,
5400            crl_refresh_interval: None,
5401            crl_fetch_timeout: Duration::from_secs(30),
5402            crl_stale_grace: Duration::from_secs(24 * 60 * 60),
5403            crl_deny_on_unavailable: false,
5404            crl_end_entity_only: false,
5405            crl_allow_http: true,
5406            crl_enforce_expiration: true,
5407            crl_max_concurrent_fetches: 4,
5408            crl_max_response_bytes: 5 * 1024 * 1024,
5409            crl_discovery_rate_per_min: 60,
5410            crl_max_host_semaphores: 1024,
5411            crl_max_seen_urls: 4096,
5412            crl_max_cache_entries: 1024,
5413        }
5414    }
5415
5416    #[test]
5417    fn validate_rejects_zero_crl_max_response_bytes() {
5418        let mut mtls = valid_mtls_config();
5419        mtls.crl_max_response_bytes = 0;
5420        let mut auth = AuthConfig::with_keys(vec![]);
5421        auth.mtls = Some(mtls);
5422
5423        // TLS paths are required alongside mTLS, else validation reports that
5424        // pairing error first and never reaches the capacity knobs.
5425        let mut cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
5426        cfg.tls_cert_path = Some("cert.pem".into());
5427        cfg.tls_key_path = Some("key.pem".into());
5428
5429        let err = cfg.validate().expect_err("zero CRL response cap");
5430        assert!(err.to_string().contains("crl_max_response_bytes"));
5431    }
5432
5433    /// `pre_auth_burst` without `pre_auth_max_per_minute` is LEGAL: the
5434    /// pre-auth base rate always resolves (max_attempts_per_minute x 10).
5435    #[test]
5436    fn validate_accepts_pre_auth_burst_without_explicit_pre_auth_rate() {
5437        let auth = AuthConfig::with_keys(vec![])
5438            .with_rate_limit(crate::auth::RateLimitConfig::new(10).with_pre_auth_burst(50));
5439        let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_auth(auth);
5440        assert!(cfg.validate().is_ok(), "pre_auth_burst has no orphan rule");
5441    }
5442
5443    // -- trusted-forwarder mode (ClientIp / ForwardedHeaderMode) --
5444
5445    #[test]
5446    fn trusted_forwarder_max_entries_bounds_are_enforced() {
5447        let cfg = |n: usize| {
5448            McpServerConfig::new("127.0.0.1:8080", "t", "0")
5449                .with_trusted_forwarder_max_entries(n)
5450                .validate()
5451        };
5452        assert!(cfg(0).is_err(), "0 would pin every client to the proxy");
5453        assert!(
5454            cfg(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES + 1).is_err(),
5455            "above the ceiling would re-open the header-bomb vector"
5456        );
5457        assert!(cfg(1).is_ok());
5458        assert!(cfg(crate::forwarded::MAX_SCANNED_ENTRIES).is_ok());
5459        assert!(cfg(crate::forwarded::MAX_CONFIGURABLE_SCANNED_ENTRIES).is_ok());
5460    }
5461
5462    #[test]
5463    fn trusted_forwarder_max_entries_defaults_to_the_module_constant() {
5464        let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "0");
5465        assert_eq!(
5466            cfg.trusted_forwarder_max_entries,
5467            crate::forwarded::MAX_SCANNED_ENTRIES
5468        );
5469    }
5470
5471    fn forward_resolver(trusted: &[&str], mode: ForwardedHeaderMode) -> Arc<ForwardResolver> {
5472        Arc::new(ForwardResolver {
5473            trusted: trusted.iter().map(|s| s.parse().unwrap()).collect(),
5474            mode,
5475            max_scanned_entries: crate::forwarded::MAX_SCANNED_ENTRIES,
5476        })
5477    }
5478
5479    /// Probe router reporting `"<PeerAddr ip>|<ClientIp>"`.
5480    fn forwarded_probe_router(resolver: Option<Arc<ForwardResolver>>) -> axum::Router {
5481        async fn probe(req: Request<Body>) -> String {
5482            let pa = req
5483                .extensions()
5484                .get::<PeerAddr>()
5485                .map(|p| p.addr.ip().to_string())
5486                .unwrap_or_default();
5487            let ci = req
5488                .extensions()
5489                .get::<ClientIp>()
5490                .map(|c| c.ip.to_string())
5491                .unwrap_or_default();
5492            format!("{pa}|{ci}")
5493        }
5494        axum::Router::new()
5495            .route("/probe", axum::routing::get(probe))
5496            .layer(axum::middleware::from_fn(move |req, next| {
5497                let r = resolver.clone();
5498                normalize_peer_addr_middleware(r, req, next)
5499            }))
5500    }
5501
5502    fn probe_req(peer: &str, header: Option<(&str, &str)>) -> Request<Body> {
5503        let addr: SocketAddr = peer.parse().unwrap();
5504        let mut builder = Request::builder()
5505            .uri("/probe")
5506            .extension(ConnectInfo(addr));
5507        if let Some((name, value)) = header {
5508            builder = builder.header(name, value);
5509        }
5510        builder.body(Body::empty()).unwrap()
5511    }
5512
5513    #[tokio::test]
5514    async fn client_ip_equals_direct_without_resolver() {
5515        let app = forwarded_probe_router(None);
5516        let resp = app
5517            .oneshot(probe_req(
5518                "10.1.2.3:4444",
5519                Some(("x-forwarded-for", "203.0.113.7")),
5520            ))
5521            .await
5522            .unwrap();
5523        assert_eq!(
5524            body_string(resp).await,
5525            "10.1.2.3|10.1.2.3",
5526            "feature off: header ignored, ClientIp == direct"
5527        );
5528    }
5529
5530    #[tokio::test]
5531    async fn client_ip_resolved_for_trusted_peer() {
5532        let app = forwarded_probe_router(Some(forward_resolver(
5533            &["10.0.0.0/8"],
5534            ForwardedHeaderMode::XForwardedFor,
5535        )));
5536        let resp = app
5537            .oneshot(probe_req(
5538                "10.0.0.1:9999",
5539                Some(("x-forwarded-for", "203.0.113.7")),
5540            ))
5541            .await
5542            .unwrap();
5543        assert_eq!(
5544            body_string(resp).await,
5545            "10.0.0.1|203.0.113.7",
5546            "PeerAddr stays direct while ClientIp resolves"
5547        );
5548    }
5549
5550    #[tokio::test]
5551    async fn client_ip_falls_back_to_direct_on_malformed_header() {
5552        let app = forwarded_probe_router(Some(forward_resolver(
5553            &["10.0.0.0/8"],
5554            ForwardedHeaderMode::XForwardedFor,
5555        )));
5556        let resp = app
5557            .oneshot(probe_req(
5558                "10.0.0.1:9999",
5559                Some(("x-forwarded-for", "not-an-ip")),
5560            ))
5561            .await
5562            .unwrap();
5563        assert_eq!(
5564            body_string(resp).await,
5565            "10.0.0.1|10.0.0.1",
5566            "malformed chain falls back to the direct peer"
5567        );
5568    }
5569
5570    #[test]
5571    fn forwarded_header_mode_deserializes_kebab_case() {
5572        #[derive(serde::Deserialize)]
5573        struct Wrapper {
5574            mode: ForwardedHeaderMode,
5575        }
5576        let w: Wrapper = toml::from_str(r#"mode = "x-forwarded-for""#).unwrap();
5577        assert_eq!(w.mode, ForwardedHeaderMode::XForwardedFor);
5578        let w: Wrapper = toml::from_str(r#"mode = "forwarded""#).unwrap();
5579        assert_eq!(w.mode, ForwardedHeaderMode::Forwarded);
5580        assert!(
5581            toml::from_str::<Wrapper>(r#"mode = "XForwardedFor""#).is_err(),
5582            "PascalCase wire value must be rejected"
5583        );
5584    }
5585
5586    #[test]
5587    fn validate_rejects_bad_trusted_proxy_entry() {
5588        let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5589            .with_trusted_proxies(["not-a-cidr"]);
5590        let err = cfg.validate().expect_err("bad CIDR");
5591        assert!(err.to_string().contains("trusted_proxies"));
5592    }
5593
5594    #[test]
5595    fn validate_rejects_zero_prefix_trusted_proxy() {
5596        for entry in ["0.0.0.0/0", "::/0"] {
5597            let cfg =
5598                McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_trusted_proxies([entry]);
5599            let err = cfg.validate().expect_err("zero-prefix CIDR");
5600            assert!(
5601                err.to_string().contains("prefix length 0"),
5602                "entry {entry}: {err}"
5603            );
5604        }
5605    }
5606
5607    #[test]
5608    fn validate_accepts_cidr_and_bare_ip_proxy_entries() {
5609        let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0").with_trusted_proxies([
5610            "10.0.0.0/8",
5611            "192.0.2.1",
5612            "2001:db8::1",
5613        ]);
5614        assert!(cfg.validate().is_ok(), "CIDRs and bare IPs are accepted");
5615    }
5616
5617    #[test]
5618    fn validate_rejects_forwarded_header_without_proxies() {
5619        let cfg = McpServerConfig::new("127.0.0.1:8080", "t", "1.0.0")
5620            .with_forwarded_header(ForwardedHeaderMode::Forwarded);
5621        let err = cfg.validate().expect_err("mode without proxies");
5622        assert!(err.to_string().contains("requires trusted_proxies"));
5623    }
5624
5625    // -- origin_check_middleware --
5626
5627    /// Build a test router with origin check middleware and a simple handler.
5628    fn origin_router(origins: Vec<String>, log_request_headers: bool) -> axum::Router {
5629        let allowed: Arc<[String]> = Arc::from(origins);
5630        axum::Router::new()
5631            .route("/test", axum::routing::get(|| async { "ok" }))
5632            .layer(axum::middleware::from_fn(move |req, next| {
5633                let a = Arc::clone(&allowed);
5634                origin_check_middleware(a, log_request_headers, req, next)
5635            }))
5636    }
5637
5638    #[tokio::test]
5639    async fn origin_allowed_passes() {
5640        let app = origin_router(vec!["http://localhost:3000".into()], false);
5641        let req = Request::builder()
5642            .uri("/test")
5643            .header(header::ORIGIN, "http://localhost:3000")
5644            .body(Body::empty())
5645            .unwrap();
5646        let resp = app.oneshot(req).await.unwrap();
5647        assert_eq!(resp.status(), StatusCode::OK);
5648    }
5649
5650    #[tokio::test]
5651    async fn origin_rejected_returns_403() {
5652        let app = origin_router(vec!["http://localhost:3000".into()], false);
5653        let req = Request::builder()
5654            .uri("/test")
5655            .header(header::ORIGIN, "http://evil.com")
5656            .body(Body::empty())
5657            .unwrap();
5658        let resp = app.oneshot(req).await.unwrap();
5659        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
5660    }
5661
5662    #[tokio::test]
5663    async fn no_origin_header_passes() {
5664        let app = origin_router(vec!["http://localhost:3000".into()], false);
5665        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
5666        let resp = app.oneshot(req).await.unwrap();
5667        assert_eq!(resp.status(), StatusCode::OK);
5668    }
5669
5670    #[tokio::test]
5671    async fn empty_allowlist_rejects_any_origin() {
5672        let app = origin_router(vec![], false);
5673        let req = Request::builder()
5674            .uri("/test")
5675            .header(header::ORIGIN, "http://anything.com")
5676            .body(Body::empty())
5677            .unwrap();
5678        let resp = app.oneshot(req).await.unwrap();
5679        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
5680    }
5681
5682    #[tokio::test]
5683    async fn empty_allowlist_passes_without_origin() {
5684        let app = origin_router(vec![], false);
5685        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
5686        let resp = app.oneshot(req).await.unwrap();
5687        assert_eq!(resp.status(), StatusCode::OK);
5688    }
5689
5690    #[test]
5691    fn format_request_headers_redacts_sensitive_values() {
5692        let mut headers = axum::http::HeaderMap::new();
5693        headers.insert("authorization", "Bearer secret-token".parse().unwrap());
5694        headers.insert("cookie", "sid=abc".parse().unwrap());
5695        headers.insert("x-request-id", "req-123".parse().unwrap());
5696
5697        let out = format_request_headers_for_log(&headers);
5698        assert!(out.contains("authorization: [REDACTED]"));
5699        assert!(out.contains("cookie: [REDACTED]"));
5700        assert!(out.contains("x-request-id: req-123"));
5701        assert!(!out.contains("secret-token"));
5702    }
5703
5704    #[test]
5705    fn format_request_headers_redacts_forwarding_headers() {
5706        let mut headers = axum::http::HeaderMap::new();
5707        headers.insert("forwarded", "for=203.0.113.9;by=10.1.2.3".parse().unwrap());
5708        headers.insert("x-forwarded-for", "203.0.113.9, 10.1.2.3".parse().unwrap());
5709        headers.insert("x-real-ip", "203.0.113.9".parse().unwrap());
5710        headers.insert("x-request-id", "req-123".parse().unwrap());
5711
5712        let out = format_request_headers_for_log(&headers);
5713        for name in ["forwarded", "x-forwarded-for", "x-real-ip"] {
5714            assert!(
5715                out.contains(&format!("{name}: [REDACTED]")),
5716                "{name} carries client IP / proxy topology and must not reach logs; got {out}"
5717            );
5718        }
5719        assert!(
5720            !out.contains("203.0.113.9") && !out.contains("10.1.2.3"),
5721            "no forwarded address may survive redaction; got {out}"
5722        );
5723        assert!(out.contains("x-request-id: req-123"));
5724    }
5725
5726    // -- security_headers_middleware --
5727
5728    fn security_router(is_tls: bool) -> axum::Router {
5729        security_router_with(is_tls, SecurityHeadersConfig::default())
5730    }
5731
5732    fn security_router_with(is_tls: bool, cfg: SecurityHeadersConfig) -> axum::Router {
5733        let cfg = Arc::new(cfg);
5734        axum::Router::new()
5735            .route("/test", axum::routing::get(|| async { "ok" }))
5736            .layer(axum::middleware::from_fn(move |req, next| {
5737                let c = Arc::clone(&cfg);
5738                security_headers_middleware(is_tls, c, req, next)
5739            }))
5740    }
5741
5742    #[tokio::test]
5743    async fn security_headers_set_on_response() {
5744        let app = security_router(false);
5745        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
5746        let resp = app.oneshot(req).await.unwrap();
5747        assert_eq!(resp.status(), StatusCode::OK);
5748
5749        let h = resp.headers();
5750        assert_eq!(h.get("x-content-type-options").unwrap(), "nosniff");
5751        assert_eq!(h.get("x-frame-options").unwrap(), "deny");
5752        assert_eq!(h.get("cache-control").unwrap(), "no-store, max-age=0");
5753        assert_eq!(h.get("referrer-policy").unwrap(), "no-referrer");
5754        assert_eq!(h.get("cross-origin-opener-policy").unwrap(), "same-origin");
5755        assert_eq!(
5756            h.get("cross-origin-resource-policy").unwrap(),
5757            "same-origin"
5758        );
5759        assert_eq!(
5760            h.get("cross-origin-embedder-policy").unwrap(),
5761            "require-corp"
5762        );
5763        assert_eq!(h.get("x-permitted-cross-domain-policies").unwrap(), "none");
5764        assert!(
5765            h.get("permissions-policy")
5766                .unwrap()
5767                .to_str()
5768                .unwrap()
5769                .contains("camera=()"),
5770            "permissions-policy must restrict browser features"
5771        );
5772        assert_eq!(
5773            h.get("content-security-policy").unwrap(),
5774            "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
5775        );
5776        assert_eq!(h.get("x-dns-prefetch-control").unwrap(), "off");
5777        // No HSTS when TLS is off.
5778        assert!(h.get("strict-transport-security").is_none());
5779    }
5780
5781    #[tokio::test]
5782    async fn hsts_set_when_tls_enabled() {
5783        let app = security_router(true);
5784        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
5785        let resp = app.oneshot(req).await.unwrap();
5786
5787        let hsts = resp.headers().get("strict-transport-security").unwrap();
5788        assert!(
5789            hsts.to_str().unwrap().contains("max-age=63072000"),
5790            "HSTS must set 2-year max-age"
5791        );
5792    }
5793
5794    #[tokio::test]
5795    async fn default_csp_matches_guideline() {
5796        let app = security_router(false);
5797        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
5798        let resp = app.oneshot(req).await.unwrap();
5799        assert_eq!(
5800            resp.headers().get("content-security-policy").unwrap(),
5801            "default-src 'none'; form-action 'self'; object-src 'none'; frame-ancestors 'none'; upgrade-insecure-requests"
5802        );
5803    }
5804
5805    #[tokio::test]
5806    async fn operator_csp_override_still_wins() {
5807        let cfg = SecurityHeadersConfig {
5808            content_security_policy: Some("default-src 'self'".into()),
5809            ..SecurityHeadersConfig::default()
5810        };
5811        let app = security_router_with(false, cfg);
5812        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
5813        let resp = app.oneshot(req).await.unwrap();
5814        assert_eq!(
5815            resp.headers().get("content-security-policy").unwrap(),
5816            "default-src 'self'"
5817        );
5818    }
5819
5820    // -- SecurityHeadersConfig validation + override semantics --
5821
5822    /// Build a minimal config with a custom SecurityHeadersConfig and
5823    /// drive it through `check()`. Returns the result so individual
5824    /// tests can assert on success or specific error messages.
5825    fn check_with_security_headers(
5826        headers: SecurityHeadersConfig,
5827    ) -> Result<(), RmcpServerKitError> {
5828        let cfg =
5829            McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0").with_security_headers(headers);
5830        cfg.check()
5831    }
5832
5833    #[test]
5834    fn security_headers_config_default_validates() {
5835        check_with_security_headers(SecurityHeadersConfig::default())
5836            .expect("default SecurityHeadersConfig must validate");
5837    }
5838
5839    #[test]
5840    fn security_headers_config_validate_accepts_empty_string() {
5841        // All twelve fields explicitly set to "" -> omit-everything mode.
5842        let h = SecurityHeadersConfig {
5843            x_content_type_options: Some(String::new()),
5844            x_frame_options: Some(String::new()),
5845            cache_control: Some(String::new()),
5846            referrer_policy: Some(String::new()),
5847            cross_origin_opener_policy: Some(String::new()),
5848            cross_origin_resource_policy: Some(String::new()),
5849            cross_origin_embedder_policy: Some(String::new()),
5850            permissions_policy: Some(String::new()),
5851            x_permitted_cross_domain_policies: Some(String::new()),
5852            content_security_policy: Some(String::new()),
5853            x_dns_prefetch_control: Some(String::new()),
5854            strict_transport_security: Some(String::new()),
5855        };
5856        check_with_security_headers(h).expect("Some(\"\") on every field must validate (omit-all)");
5857    }
5858
5859    #[test]
5860    fn security_headers_config_validate_rejects_bad_value() {
5861        // 0x07 (BEL) is not a valid HTTP header value char.
5862        let h = SecurityHeadersConfig {
5863            referrer_policy: Some("\u{0007}".into()),
5864            ..SecurityHeadersConfig::default()
5865        };
5866        let err = check_with_security_headers(h)
5867            .expect_err("control char in referrer_policy must reject");
5868        let msg = err.to_string();
5869        assert!(
5870            msg.contains("referrer_policy"),
5871            "error must name the offending field, got: {msg}"
5872        );
5873    }
5874
5875    #[test]
5876    fn security_headers_config_validate_rejects_hsts_preload() {
5877        let h = SecurityHeadersConfig {
5878            strict_transport_security: Some("max-age=63072000; includeSubDomains; preload".into()),
5879            ..SecurityHeadersConfig::default()
5880        };
5881        let err = check_with_security_headers(h).expect_err("HSTS with preload must reject");
5882        let msg = err.to_string();
5883        assert!(
5884            msg.contains("strict_transport_security"),
5885            "error must name the field, got: {msg}"
5886        );
5887        assert!(
5888            msg.to_lowercase().contains("preload"),
5889            "error must mention `preload`, got: {msg}"
5890        );
5891    }
5892
5893    #[test]
5894    fn security_headers_config_validate_rejects_hsts_preload_uppercase() {
5895        // Case-insensitive match.
5896        let h = SecurityHeadersConfig {
5897            strict_transport_security: Some("max-age=600; PRELOAD".into()),
5898            ..SecurityHeadersConfig::default()
5899        };
5900        check_with_security_headers(h).expect_err("HSTS preload check must be case-insensitive");
5901    }
5902
5903    #[tokio::test]
5904    async fn security_headers_override_honored() {
5905        // Override X-Frame-Options to SAMEORIGIN.
5906        let h = SecurityHeadersConfig {
5907            x_frame_options: Some("SAMEORIGIN".into()),
5908            ..SecurityHeadersConfig::default()
5909        };
5910        let app = security_router_with(false, h);
5911        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
5912        let resp = app.oneshot(req).await.unwrap();
5913        assert_eq!(resp.status(), StatusCode::OK);
5914
5915        let xfo = resp.headers().get("x-frame-options").unwrap();
5916        assert_eq!(xfo, "SAMEORIGIN");
5917    }
5918
5919    #[tokio::test]
5920    async fn security_headers_empty_string_omits() {
5921        // Empty string on referrer-policy -> header absent.
5922        let h = SecurityHeadersConfig {
5923            referrer_policy: Some(String::new()),
5924            ..SecurityHeadersConfig::default()
5925        };
5926        let app = security_router_with(false, h);
5927        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
5928        let resp = app.oneshot(req).await.unwrap();
5929        assert_eq!(resp.status(), StatusCode::OK);
5930
5931        assert!(
5932            resp.headers().get("referrer-policy").is_none(),
5933            "Some(\"\") must omit the header"
5934        );
5935        // Other defaults should still be present.
5936        assert_eq!(
5937            resp.headers().get("x-content-type-options").unwrap(),
5938            "nosniff"
5939        );
5940    }
5941
5942    #[tokio::test]
5943    async fn security_headers_hsts_only_when_tls() {
5944        // HSTS override is irrelevant when TLS is off.
5945        let h = SecurityHeadersConfig {
5946            strict_transport_security: Some("max-age=600".into()),
5947            ..SecurityHeadersConfig::default()
5948        };
5949        let app = security_router_with(false, h);
5950        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
5951        let resp = app.oneshot(req).await.unwrap();
5952        assert!(
5953            resp.headers().get("strict-transport-security").is_none(),
5954            "HSTS must remain absent on plaintext deployments even with override"
5955        );
5956    }
5957
5958    // -- oauth_token_cache_headers_middleware --
5959
5960    #[cfg(feature = "oauth")]
5961    #[tokio::test]
5962    async fn oauth_token_cache_headers_set_pragma_and_vary() {
5963        let app = axum::Router::new()
5964            .route("/token", axum::routing::post(|| async { "{}" }))
5965            .layer(axum::middleware::from_fn(
5966                oauth_token_cache_headers_middleware,
5967            ));
5968        let req = Request::builder()
5969            .method("POST")
5970            .uri("/token")
5971            .body(Body::from("{}"))
5972            .unwrap();
5973        let resp = app.oneshot(req).await.unwrap();
5974        assert_eq!(resp.status(), StatusCode::OK);
5975
5976        let h = resp.headers();
5977        assert_eq!(
5978            h.get("pragma").unwrap(),
5979            "no-cache",
5980            "RFC 6749 §5.1: token responses must set Pragma: no-cache"
5981        );
5982        let vary_values: Vec<String> = h
5983            .get_all("vary")
5984            .iter()
5985            .filter_map(|v| v.to_str().ok().map(str::to_owned))
5986            .collect();
5987        assert!(
5988            vary_values
5989                .iter()
5990                .any(|v| v.eq_ignore_ascii_case("Authorization")),
5991            "RFC 6750 §5.4: Vary must include Authorization, got {vary_values:?}"
5992        );
5993    }
5994
5995    #[cfg(feature = "oauth")]
5996    #[tokio::test]
5997    async fn oauth_token_cache_headers_preserve_existing_vary() {
5998        // Simulates a handler/layer that already set `Vary: Accept-Encoding`
5999        // (e.g. compression). Our middleware must APPEND, not REPLACE.
6000        let app = axum::Router::new()
6001            .route(
6002                "/token",
6003                axum::routing::post(|| async {
6004                    axum::response::Response::builder()
6005                        .header("vary", "Accept-Encoding")
6006                        .body(Body::from("{}"))
6007                        .unwrap()
6008                }),
6009            )
6010            .layer(axum::middleware::from_fn(
6011                oauth_token_cache_headers_middleware,
6012            ));
6013        let req = Request::builder()
6014            .method("POST")
6015            .uri("/token")
6016            .body(Body::empty())
6017            .unwrap();
6018        let resp = app.oneshot(req).await.unwrap();
6019
6020        let vary: Vec<String> = resp
6021            .headers()
6022            .get_all("vary")
6023            .iter()
6024            .filter_map(|v| v.to_str().ok().map(str::to_owned))
6025            .collect();
6026        assert!(
6027            vary.iter().any(|v| v.contains("Accept-Encoding")),
6028            "must preserve pre-existing Vary value, got {vary:?}"
6029        );
6030        assert!(
6031            vary.iter().any(|v| v.contains("Authorization")),
6032            "must append Authorization to Vary, got {vary:?}"
6033        );
6034    }
6035
6036    // -- version endpoint --
6037
6038    #[test]
6039    fn version_omits_build_fingerprint_by_default() {
6040        let v = version_payload("my-server", "1.2.3", false);
6041        assert_eq!(v["name"], "my-server");
6042        assert_eq!(v["version"], "1.2.3");
6043        assert!(v["rmcp_server_kit_version"].is_string());
6044        assert!(
6045            v.get("build_git_sha").is_none(),
6046            "build sha must be hidden by default"
6047        );
6048        assert!(v.get("build_timestamp").is_none());
6049        assert!(v.get("rust_version").is_none());
6050    }
6051
6052    #[test]
6053    fn version_exposes_all_when_enabled() {
6054        let v = version_payload("my-server", "1.2.3", true);
6055        assert!(v["build_git_sha"].is_string());
6056        assert!(v["build_timestamp"].is_string());
6057        assert!(v["rust_version"].is_string());
6058        assert!(v["rmcp_server_kit_version"].is_string());
6059    }
6060
6061    // -- concurrency limit layer --
6062
6063    #[tokio::test]
6064    async fn concurrency_limit_layer_composes_and_serves() {
6065        // We only assert the layer stack compiles and a single request
6066        // below the cap still succeeds. True back-pressure behaviour
6067        // requires a live HTTP server and is covered by integration tests.
6068        let app = axum::Router::new()
6069            .route("/ok", axum::routing::get(|| async { "ok" }))
6070            .layer(
6071                tower::ServiceBuilder::new()
6072                    .layer(axum::error_handling::HandleErrorLayer::new(
6073                        |_err: tower::BoxError| async { StatusCode::SERVICE_UNAVAILABLE },
6074                    ))
6075                    .layer(tower::load_shed::LoadShedLayer::new())
6076                    .layer(tower::limit::ConcurrencyLimitLayer::new(4)),
6077            );
6078        let resp = app
6079            .oneshot(Request::builder().uri("/ok").body(Body::empty()).unwrap())
6080            .await
6081            .unwrap();
6082        assert_eq!(resp.status(), StatusCode::OK);
6083    }
6084
6085    // -- compression layer --
6086
6087    #[tokio::test]
6088    async fn compression_layer_gzip_encodes_response() {
6089        use tower_http::compression::Predicate as _;
6090
6091        let big_body = "a".repeat(4096);
6092        let app = axum::Router::new()
6093            .route(
6094                "/big",
6095                axum::routing::get(move || {
6096                    let body = big_body.clone();
6097                    async move { body }
6098                }),
6099            )
6100            .layer(
6101                tower_http::compression::CompressionLayer::new()
6102                    .gzip(true)
6103                    .br(true)
6104                    .compress_when(
6105                        tower_http::compression::DefaultPredicate::new()
6106                            .and(tower_http::compression::predicate::SizeAbove::new(1024)),
6107                    ),
6108            );
6109
6110        let req = Request::builder()
6111            .uri("/big")
6112            .header(header::ACCEPT_ENCODING, "gzip")
6113            .body(Body::empty())
6114            .unwrap();
6115        let resp = app.oneshot(req).await.unwrap();
6116        assert_eq!(resp.status(), StatusCode::OK);
6117        assert_eq!(
6118            resp.headers().get(header::CONTENT_ENCODING).unwrap(),
6119            "gzip"
6120        );
6121    }
6122
6123    // -- TlsListener handshake timeout --
6124
6125    #[tokio::test]
6126    async fn tls_handshake_timeout_reaps_idle_connections() {
6127        use tokio::io::AsyncReadExt as _;
6128
6129        let _ = rustls::crypto::ring::default_provider().install_default();
6130
6131        // Self-signed cert material on disk (TlsListener::new takes paths).
6132        let key = rcgen::KeyPair::generate().expect("generate key");
6133        let cert = rcgen::CertificateParams::new(vec!["localhost".to_owned()])
6134            .expect("cert params")
6135            .self_signed(&key)
6136            .expect("self-signed cert");
6137        let dir = std::env::temp_dir().join(format!(
6138            "rmcp-server-kit-hs-timeout-{}",
6139            std::time::SystemTime::now()
6140                .duration_since(std::time::UNIX_EPOCH)
6141                .expect("clock after epoch")
6142                .as_nanos()
6143        ));
6144        tokio::fs::create_dir_all(&dir).await.expect("temp dir");
6145        let cert_path = dir.join("server.crt");
6146        let key_path = dir.join("server.key");
6147        tokio::fs::write(&cert_path, cert.pem())
6148            .await
6149            .expect("write cert");
6150        tokio::fs::write(&key_path, key.serialize_pem())
6151            .await
6152            .expect("write key");
6153
6154        let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
6155        let tls = TlsListener::new(
6156            listener,
6157            &cert_path,
6158            &key_path,
6159            None,
6160            None,
6161            Duration::from_millis(200),
6162            8, // custom concurrency cap: proves the plumbing end-to-end
6163        )
6164        .expect("tls listener");
6165        let addr = axum::serve::Listener::local_addr(&tls).expect("local addr");
6166
6167        // Connect and send NOTHING: the handshake worker must time out
6168        // after 200ms and drop the stream, which the client observes as
6169        // EOF or a reset well within the 2s deadline.
6170        let mut idle = tokio::net::TcpStream::connect(addr).await.expect("connect");
6171        let mut buf = [0_u8; 16];
6172        let read = tokio::time::timeout(Duration::from_secs(2), idle.read(&mut buf))
6173            .await
6174            .expect("server must reap the idle handshake within its timeout");
6175        match read {
6176            Ok(0) | Err(_) => {} // EOF or reset: connection was dropped.
6177            Ok(n) => panic!("unexpected {n} bytes from server during reaped handshake"),
6178        }
6179
6180        drop(tls);
6181    }
6182
6183    // -- M5: OWASP security headers reach early / fallback responses --
6184
6185    fn assert_owasp_headers(resp: &axum::response::Response, ctx: &str) {
6186        let h = resp.headers();
6187        assert!(
6188            h.contains_key("x-content-type-options"),
6189            "{ctx}: missing X-Content-Type-Options"
6190        );
6191        assert!(
6192            h.contains_key("x-frame-options"),
6193            "{ctx}: missing X-Frame-Options"
6194        );
6195        assert!(
6196            h.contains_key("strict-transport-security"),
6197            "{ctx}: missing Strict-Transport-Security"
6198        );
6199        assert!(
6200            h.contains_key(header::CONTENT_SECURITY_POLICY),
6201            "{ctx}: missing Content-Security-Policy"
6202        );
6203    }
6204
6205    fn m5_router(configure: impl FnOnce(&mut McpServerConfig)) -> axum::Router {
6206        #[derive(Clone)]
6207        struct H;
6208        impl ServerHandler for H {}
6209        // TLS paths make `is_tls` true so HSTS is emitted. The paths are never
6210        // read: these tests drive only the axum router via `oneshot`, not the
6211        // TLS listener.
6212        let mut config = McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0")
6213            .with_allowed_origins(["http://good.example"])
6214            .with_tls("unused.crt", "unused.key");
6215        configure(&mut config);
6216        let (router, _params) = build_app_router(config, || H).expect("build_app_router");
6217        router
6218    }
6219
6220    /// An `extra_router` route that exactly overlaps a framework route makes
6221    /// `axum::Router::merge` panic during `build_app_router`. This pins that
6222    /// upstream behaviour so the documented contract on `with_extra_router`
6223    /// cannot silently stop holding.
6224    #[test]
6225    #[should_panic(expected = "Overlapping method route")]
6226    fn extra_router_exact_overlap_with_framework_route_panics() {
6227        #[derive(Clone)]
6228        struct H;
6229        impl ServerHandler for H {}
6230        let config = McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0").with_extra_router(
6231            axum::Router::new().route("/healthz", axum::routing::get(|| async { "mine" })),
6232        );
6233        let _ = build_app_router(config, || H);
6234    }
6235
6236    /// The complement: a path *under* a framework prefix that does not exactly
6237    /// overlap an existing route is accepted without complaint. Documented as
6238    /// the caller's responsibility on `with_extra_router`.
6239    #[test]
6240    fn extra_router_non_overlapping_path_under_framework_prefix_is_accepted() {
6241        #[derive(Clone)]
6242        struct H;
6243        impl ServerHandler for H {}
6244        let config = McpServerConfig::new("127.0.0.1:8080", "test", "0.0.0").with_extra_router(
6245            axum::Router::new().route("/admin/custom", axum::routing::get(|| async { "mine" })),
6246        );
6247        assert!(
6248            build_app_router(config, || H).is_ok(),
6249            "non-overlapping path under a framework prefix must merge cleanly"
6250        );
6251    }
6252
6253    #[tokio::test]
6254    async fn headers_on_rejected_origin_403() {
6255        let app = m5_router(|_| {});
6256        let req = Request::builder()
6257            .uri("/healthz")
6258            .header(header::ORIGIN, "http://evil.example")
6259            .body(Body::empty())
6260            .unwrap();
6261        let resp = app.oneshot(req).await.unwrap();
6262        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
6263        assert_owasp_headers(&resp, "origin-403");
6264    }
6265
6266    #[tokio::test]
6267    async fn headers_on_cors_preflight() {
6268        let app = m5_router(|_| {});
6269        let req = Request::builder()
6270            .method(axum::http::Method::OPTIONS)
6271            .uri("/mcp")
6272            .header(header::ORIGIN, "http://good.example")
6273            .header(header::ACCESS_CONTROL_REQUEST_METHOD, "POST")
6274            .body(Body::empty())
6275            .unwrap();
6276        let resp = app.oneshot(req).await.unwrap();
6277        assert_owasp_headers(&resp, "cors-preflight");
6278    }
6279
6280    #[tokio::test]
6281    async fn headers_on_404_fallback() {
6282        let app = m5_router(|_| {});
6283        let req = Request::builder()
6284            .uri("/no-such-route")
6285            .body(Body::empty())
6286            .unwrap();
6287        let resp = app.oneshot(req).await.unwrap();
6288        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
6289        assert_owasp_headers(&resp, "404-fallback");
6290    }
6291
6292    #[tokio::test]
6293    async fn headers_on_overload_503() {
6294        // A zero-permit concurrency cap sheds every request, so a single
6295        // oneshot deterministically surfaces the overload 503.
6296        let app = m5_router(|c| c.max_concurrent_requests = Some(0));
6297        let req = Request::builder()
6298            .uri("/healthz")
6299            .body(Body::empty())
6300            .unwrap();
6301        let resp = app.oneshot(req).await.unwrap();
6302        assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
6303        assert_owasp_headers(&resp, "overload-503");
6304    }
6305
6306    // -- M6: OAuth proxy admin endpoints enforce the admin role --
6307
6308    #[cfg(feature = "oauth")]
6309    fn m6_auth_state() -> (Arc<AuthState>, String, String) {
6310        let (admin_token, admin_hash) = crate::auth::generate_api_key().unwrap();
6311        let (viewer_token, viewer_hash) = crate::auth::generate_api_key().unwrap();
6312        let state = Arc::new(AuthState {
6313            api_keys: ArcSwap::from_pointee(vec![
6314                crate::auth::ApiKeyEntry::new("admin-key", admin_hash, "admin"),
6315                crate::auth::ApiKeyEntry::new("viewer-key", viewer_hash, "viewer"),
6316            ]),
6317            rate_limiter: None,
6318            pre_auth_limiter: None,
6319            jwks_cache: None,
6320            seen_identities: crate::auth::SeenIdentitySet::new(),
6321            counters: crate::auth::AuthCounters::default(),
6322            resource_metadata_url: None,
6323        });
6324        (state, admin_token, viewer_token)
6325    }
6326
6327    #[cfg(feature = "oauth")]
6328    fn m6_admin_router(state: &Arc<AuthState>) -> axum::Router {
6329        let proxy = crate::oauth::OAuthProxyConfig::builder(
6330            "https://idp.example/authorize",
6331            "https://idp.example/token",
6332            "client",
6333        )
6334        .introspection_url("http://127.0.0.1:1/introspect")
6335        .revocation_url("http://127.0.0.1:1/revoke")
6336        .expose_admin_endpoints(true)
6337        .require_auth_on_admin_endpoints(true)
6338        .build();
6339        let http = crate::oauth::OauthHttpClient::new().expect("oauth http client");
6340        build_oauth_admin_router(&proxy, http, Some(state), "admin").expect("admin router")
6341    }
6342
6343    #[cfg(feature = "oauth")]
6344    fn m6_req(path: &str, token: &str) -> Request<Body> {
6345        Request::builder()
6346            .method(axum::http::Method::POST)
6347            .uri(path)
6348            .header(header::AUTHORIZATION, format!("Bearer {token}"))
6349            .body(Body::from("token=abc"))
6350            .unwrap()
6351    }
6352
6353    #[cfg(feature = "oauth")]
6354    #[tokio::test]
6355    async fn oauth_proxy_admin_requires_admin_role() {
6356        let (state, _admin, viewer) = m6_auth_state();
6357        for path in ["/introspect", "/revoke"] {
6358            let app = m6_admin_router(&state);
6359            let resp = app.oneshot(m6_req(path, &viewer)).await.unwrap();
6360            assert_eq!(
6361                resp.status(),
6362                StatusCode::FORBIDDEN,
6363                "an authenticated viewer must be rejected with 403 on {path}"
6364            );
6365        }
6366    }
6367
6368    #[cfg(feature = "oauth")]
6369    #[tokio::test]
6370    async fn oauth_proxy_admin_allows_admin_role() {
6371        let (state, admin, _viewer) = m6_auth_state();
6372        for path in ["/introspect", "/revoke"] {
6373            let app = m6_admin_router(&state);
6374            let resp = app.oneshot(m6_req(path, &admin)).await.unwrap();
6375            // The admin identity clears both the auth and role gates; the
6376            // downstream introspection call then fails closed (no upstream),
6377            // so the only guarantee asserted is that it is neither 401 nor 403.
6378            assert_ne!(
6379                resp.status(),
6380                StatusCode::FORBIDDEN,
6381                "an authenticated admin must pass the role gate on {path}"
6382            );
6383            assert_ne!(
6384                resp.status(),
6385                StatusCode::UNAUTHORIZED,
6386                "an authenticated admin must pass the auth gate on {path}"
6387            );
6388        }
6389    }
6390
6391    // -- F3 regression: unbounded Prometheus label cardinality --
6392    //
6393    // `metrics_middleware` runs outside the auth layer, so it observes
6394    // unauthenticated traffic. Labelling with the raw URI path and raw HTTP
6395    // method let any client mint a permanent time series per request, growing
6396    // in-process metric state until OOM. Both labels must now come from a
6397    // closed set.
6398    #[cfg(feature = "metrics")]
6399    mod metrics_labels_bounded {
6400        use super::*;
6401
6402        fn labels_for(method: &str, uri: &str) -> (&'static str, String) {
6403            let req = Request::builder()
6404                .method(method)
6405                .uri(uri)
6406                .body(Body::empty())
6407                .unwrap();
6408            metrics_labels(&req)
6409        }
6410
6411        #[test]
6412        fn many_unmatched_paths_collapse_to_one_label() {
6413            let mut seen = std::collections::HashSet::new();
6414            for i in 0..500 {
6415                let (_, path) = labels_for("GET", &format!("/nonexistent-{i}"));
6416                seen.insert(path);
6417            }
6418            assert_eq!(
6419                seen.len(),
6420                1,
6421                "unmatched paths must collapse to a single label, got {seen:?}"
6422            );
6423            assert!(seen.contains("<unmatched>"));
6424        }
6425
6426        #[test]
6427        fn nested_mcp_paths_collapse_to_the_mount_point() {
6428            let mut seen = std::collections::HashSet::new();
6429            for i in 0..200 {
6430                let (_, path) = labels_for("POST", &format!("/mcp/{i}"));
6431                seen.insert(path);
6432            }
6433            let (_, root) = labels_for("POST", "/mcp");
6434            seen.insert(root);
6435            assert_eq!(
6436                seen.len(),
6437                1,
6438                "nested /mcp paths must collapse to one label, got {seen:?}"
6439            );
6440            assert!(seen.contains("/mcp"));
6441        }
6442
6443        #[test]
6444        fn unusual_methods_collapse_to_one_bucket() {
6445            let mut seen = std::collections::HashSet::new();
6446            for verb in ["FROBNICATE", "WIBBLE", "QUUX", "M-SEARCH"] {
6447                let (method, _) = labels_for(verb, "/healthz");
6448                seen.insert(method);
6449            }
6450            assert_eq!(seen, std::collections::HashSet::from(["OTHER"]));
6451        }
6452
6453        #[test]
6454        fn known_methods_keep_their_identity() {
6455            for verb in ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] {
6456                let (method, _) = labels_for(verb, "/healthz");
6457                assert_eq!(method, verb);
6458            }
6459        }
6460
6461        #[test]
6462        fn raw_path_never_leaks_into_a_label() {
6463            let (_, path) = labels_for("GET", "/secret-token-abc123");
6464            assert!(
6465                !path.contains("secret-token"),
6466                "raw request path must never become a label value: {path}"
6467            );
6468        }
6469    }
6470}