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