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