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