Skip to main content

rmcp_server_kit/
transport.rs

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