Skip to main content

protocol_droid/adapters/primary/transport/http/
router.rs

1//! The Streamable HTTP route table: `POST`, `GET` and `DELETE` on one endpoint,
2//! and the middleware every one of them answers through.
3//!
4//! # What this module decides, and what it refuses to decide
5//!
6//! This is the transport's boundary and nothing past it. It decides four
7//! things, in this order, and delegates everything else:
8//!
9//! 1. **Who is calling.** [`RequestHandler`] never sees a request that has not
10//!    been through [`AuthPolicy::refuse`], and the gate runs before the session
11//!    store is touched at all.
12//! 2. **How large the body is.** Bounded by [`ServerConfig::max_body`] while
13//!    it is read, with the over-limit case answered as
14//!    [`Refusal::BodyTooLarge`] rather than as a framework `413` with an empty
15//!    body.
16//! 3. **Which session this is.** The `Mcp-Session-Id` header is *validated*,
17//!    never *parsed into existence*: a well-formed id the store never issued is
18//!    [`Refusal::UnknownSession`], a `404`, and never a session minted on the
19//!    spot.
20//! 4. **Nothing else.** Which method a body carries, what it means and what the
21//!    answer is belong to the protocol layer. This file names no method, no
22//!    revision and no capability; [`RequestHandler`] is the seam, and A2A
23//!    (T27) or ACP (T29) drop in behind it as a different `H`.
24//!
25//! # The two rules that are structural rather than reviewed
26//!
27//! Both are easy to state and easy to break in a later commit, so they are
28//! enforced by where the code lives rather than by a reviewer remembering.
29//!
30//! **A refusal is never a `200`.** [`Refusal::status`] and [`Refusal::body`]
31//! are the only mapping in the adapter, and every failure path in every
32//! handler ends in `refuse`. There is no error arm a handler can turn into a
33//! successful-looking response.
34//!
35//! **Every response carries the security headers, including the ones no handler
36//! wrote.** They are applied by [`with_security`] — a layer around the *whole*
37//! router, not a call inside a handler — so a refusal, an error from the
38//! protocol layer, and axum's own `405` for an unmapped method and `404` for an
39//! unmapped path all carry them. A refusal that leaks no body but drops
40//! `Cache-Control` is still a response an intermediary is entitled to store.
41//!
42//! # Why authentication precedes the session lookup
43//!
44//! Because the store answers two different questions — "does this id exist?"
45//! and "no such id" — and the second takes measurably longer than the first
46//! only for a *hit*. Lookup before authentication turns the store into an
47//! oracle: an unauthenticated caller walks a plausible id space and reads the
48//! latency, learns which ids are live, and can then aim a `DELETE` at one. The
49//! gate is first on every route, so that difference is never observable by a
50//! caller that has proved nothing.
51//!
52//! The same reasoning fixes where
53//! `RequestHandler::establishes_session` is asked: only when the header is
54//! *absent*, and therefore before the store is consulted at all. A handler that
55//! answered from session state would reintroduce the oracle one layer in.
56//!
57//! # What is not here, and who owns it
58//!
59//! - **Session expiry.** A session leaves the store only when `DELETE`
60//!   terminates it. A client whose connection drops without a `DELETE` leaves
61//!   one behind forever, and this router does not reap it: a half-reaper that
62//!   evicts by count turns a load spike into silent destruction of live state,
63//!   which is the failure `SessionStore` documents at length. What is missing
64//!   is an idle-or-absolute deadline owned by the store and driven by an
65//!   injected `Clock`.
66//! - **The `MCP-Protocol-Version` check.** A codec concern, and therefore the
67//!   protocol layer's; this router does not know a version exists.
68//! - **The `405` the specification allows for `GET`.** [`Refusal`] carries it
69//!   for a server with no server-initiated stream. This server always has a
70//!   tail to serve, so the arm is never raised here.
71//! - **A `DELETE` tombstone.** See `terminate_session`.
72
73use std::sync::Arc;
74
75use axum::Router as AxumRouter;
76use axum::body::Body;
77use axum::extract::State;
78
79use axum::http::{HeaderMap, HeaderName, HeaderValue, Request, StatusCode, header};
80use axum::middleware::Next;
81use axum::response::{IntoResponse, Response};
82use axum::routing::MethodRouter;
83use serde_json::Value;
84
85use super::config::Streaming;
86use super::hardening::{self, enforce_deadline, enforce_host, enforce_origin, enforce_rate_limit};
87use super::limits::RateLimiter;
88use super::precondition::{authenticate, read_body};
89use super::{
90    Anonymous, AuthPolicy, Authenticator, EventTail, JSON_MEDIA_TYPE, Refusal, SecurityHeaders,
91    ServerConfig, SessionStore,
92};
93use crate::adapters::primary::transport::origin::OriginPolicy;
94use crate::domain::ProtocolVersion;
95use crate::domain::jsonrpc::{Frame, WireId, decode};
96use crate::domain::mcp::McpSessionId;
97use crate::domain::registry::AuthContext;
98
99/// The header naming a session, introduced with the Streamable HTTP transport
100/// in revision **2025-03-26** — the revision that replaced the 2024-11-05
101/// HTTP+SSE pair. Earlier revisions have no equivalent, because they had no
102/// server-issued session at all.
103///
104/// This is the one MCP-specific header the adapter names, and it is a constant
105/// rather than a string at each call site so that a later revision with a
106/// different name is a one-line change here instead of a search.
107const SESSION_HEADER: HeaderName = HeaderName::from_static("mcp-session-id");
108
109/// The standard SSE resume header. Not MCP-specific: it comes from the HTML
110/// `EventSource` contract every SSE implementation inherits.
111const LAST_EVENT_ID: HeaderName = HeaderName::from_static("last-event-id");
112
113/// The header a client asserts its session's revision with. Mandatory from
114/// **2025-06-18** (PR #548) and absent before it, which is why its absence is
115/// not an error here and its presence is a parsed value.
116///
117/// The adapter reads it but does not judge it. `check_protocol_header` compares
118/// a header against the revision a session *negotiated*, and only the protocol
119/// layer knows that; what the transport owes is to have parsed the bytes once,
120/// so nothing downstream ever sees a raw `HeaderValue`.
121const PROTOCOL_HEADER: HeaderName = HeaderName::from_static("mcp-protocol-version");
122
123/// The three security headers, parsed once at construction.
124///
125/// [`SecurityHeaders`] is the configuration; this is the parsed form the
126/// middleware needs on *every* response. Re-validating three strings per
127/// response would put an allocation on the path that is guaranteed to be taken,
128/// refusals included.
129#[derive(Debug, Clone)]
130struct SecurityLayer {
131    content_security_policy: HeaderValue,
132    strict_transport_security: HeaderValue,
133    x_frame_options: HeaderValue,
134}
135
136impl SecurityLayer {
137    /// Parses the configured values, failing construction rather than a request.
138    ///
139    /// These values come from a deployment, not from a peer, so one this server
140    /// cannot write is a configuration error and belongs at the call site that
141    /// supplied it — not as a `500` on every request afterwards.
142    fn parse(headers: &SecurityHeaders) -> Result<Self, InvalidHeaderValue> {
143        Ok(Self {
144            content_security_policy: HeaderValue::from_str(headers.content_security_policy())?,
145            strict_transport_security: HeaderValue::from_str(headers.strict_transport_security())?,
146            x_frame_options: HeaderValue::from_str(headers.x_frame_options())?,
147        })
148    }
149}
150
151/// The configuration supplied a security header this server cannot send.
152#[derive(Debug, thiserror::Error)]
153#[error("a configured security header is not a valid HTTP header value: {0}")]
154pub struct InvalidHeaderValue(axum::http::header::InvalidHeaderValue);
155
156impl From<axum::http::header::InvalidHeaderValue> for InvalidHeaderValue {
157    fn from(source: axum::http::header::InvalidHeaderValue) -> Self {
158        Self(source)
159    }
160}
161
162/// Everything a request needs, assembled once and shared by every route.
163pub(crate) struct RouterState<S, H> {
164    config: ServerConfig,
165    sessions: Arc<SessionStore<S>>,
166    handler: H,
167    policy: AuthPolicy,
168    authenticator: Arc<dyn Authenticator + Send + Sync>,
169    origin: OriginPolicy,
170    rate_limiter: RateLimiter,
171}
172
173impl<S, H> RouterState<S, H> {
174    /// The `Origin` allow-list this server answers for.
175    pub(crate) const fn origin(&self) -> &OriginPolicy {
176        &self.origin
177    }
178
179    /// The per-subject request accounting.
180    /// The configuration this router was built with.
181    pub(crate) const fn config(&self) -> &ServerConfig {
182        &self.config
183    }
184
185    pub(crate) const fn rate_limiter(&self) -> &RateLimiter {
186        &self.rate_limiter
187    }
188}
189
190/// What a header said, distinguishing "not there" from "there and not text".
191///
192/// The three-way split is the point. A header whose bytes are not a legal
193/// field-value has not been *absent*: treating it as absent would hand a peer
194/// that cannot spell the header correctly a fresh session instead of a `400`.
195#[derive(Debug, Clone, Copy)]
196enum Header<'h> {
197    /// The header was not sent.
198    Absent,
199    /// The header was sent and is visible ASCII.
200    Value(&'h str),
201    /// The header was sent and is not readable as a field-value.
202    Unreadable,
203}
204
205impl<'h> Header<'h> {
206    /// Reads `name` out of `headers`.
207    fn read(headers: &'h HeaderMap, name: &HeaderName) -> Self {
208        let Some(raw) = headers.get(name) else {
209            return Self::Absent;
210        };
211        raw.to_str().map_or(Self::Unreadable, Self::Value)
212    }
213}
214
215/// What a session has to be able to do for the `GET` route to open a stream on
216/// it.
217///
218/// Declared here, beside the only consumer, because a port belongs to the
219/// consumer. One method, because that is all the stream route asks: *what* to
220/// replay is [`EventTail::plan`]'s decision, and *whether* to open a stream at
221/// all is the protocol layer's.
222pub trait EventSource: Send + Sync + 'static {
223    /// The tail this session's server-initiated messages are published on.
224    fn tail(&self) -> &EventTail<Value>;
225}
226
227/// A tail is its own event source, which is the shape T14's server uses: one
228/// session, one tail. A server keeping more state per session holds the tail as
229/// a field and implements this for the session itself.
230impl EventSource for EventTail<Value> {
231    fn tail(&self) -> &EventTail<Value> {
232        self
233    }
234}
235
236/// The session a request resolved to, as the protocol layer sees it.
237#[derive(Debug)]
238pub enum SessionHandle<S> {
239    /// The body opens a session, so there was none to name.
240    ///
241    /// Distinct from a request that named nothing and needed to: the router
242    /// answers the second with a `400` and never reaches the handler, so this
243    /// arm can only mean the first.
244    Establishing,
245    /// The body is a complete stateless request, and the profile for it comes
246    /// from the body rather than from a stored session.
247    ///
248    /// The **2026-07-28** lifecycle, and the reason this enum has three arms
249    /// rather than two. A stateless request never becomes a session, so
250    /// modelling it as `Establishing` would have the router hand the
251    /// protocol layer an id to issue, and modelling it as
252    /// [`SessionHandle::Live`] would mean inventing a session that does not
253    /// exist. Either way the `_meta` version the request carries — the only
254    /// signal a stateless request has — gets ignored.
255    Stateless,
256    /// The header named a session this server holds.
257    Live {
258        /// The id, as the server issued it.
259        id: McpSessionId,
260        /// The stored session.
261        session: Arc<S>,
262    },
263}
264
265/// What a body means when the request named no session.
266///
267/// The protocol layer answers this because the router is protocol-neutral: it
268/// cannot know that `initialize` opens a session, nor that a **2026-07-28**
269/// request carrying a version in `_meta` stands on its own.
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
271pub enum Unsessioned {
272    /// The body opens a session.
273    Establishing,
274    /// The body is self-describing and is served against its own profile.
275    Stateless,
276    /// The body belongs to a session and the header named none. The router
277    /// answers this with a `400` and the handler is never asked.
278    NeedsSession,
279}
280
281/// A request the router has resolved and hands to the protocol layer.
282#[derive(Debug)]
283pub struct Inbound<'a, S> {
284    /// What the caller proved it is, checked against the endpoint's policy.
285    pub context: &'a AuthContext,
286    /// The session this request belongs to, or that it opens.
287    pub session: SessionHandle<S>,
288    /// The body, already bounded by [`ServerConfig::max_body`].
289    pub body: &'a [u8],
290    /// The `MCP-Protocol-Version` header, parsed once here or refused as
291    /// malformed, and `None` when the client sent none.
292    ///
293    /// Absence is not a fault: the header became mandatory in **2025-06-18**
294    /// and a pre-existing client legitimately omits it. The handler compares
295    /// it against the revision the session negotiated, through
296    /// [`check_protocol_header`](crate::domain::check_protocol_header), which
297    /// is the only place that knows the answer.
298    pub protocol: Option<ProtocolVersion>,
299    /// Every request header, for the checks only the protocol layer can make.
300    ///
301    /// A `2026-07-28` request is validated against its own headers —
302    /// `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` must agree with the
303    /// body — and what "agree" means is a protocol question this router does
304    /// not answer.
305    pub headers: &'a HeaderMap,
306    /// How this server was configured to frame a reply.
307    ///
308    /// Carried rather than read from config inside the handler, for the same
309    /// reason `protocol` is: the value is decided here, where the config
310    /// lives, and the handler applies it. It was previously not carried at
311    /// all, which is how `Streaming::Json` came to mean a bare document for
312    /// `initialize` and an event stream for everything after it — the default
313    /// was honoured on one path and ignored on the other.
314    pub streaming: Streaming,
315}
316
317/// The `POST` route's port: everything this module deliberately does not know.
318///
319/// Two methods. The first answers a question only the protocol layer can — does
320/// this body open a session? — and the second does the work. The trait is
321/// `async` and the router is generic over `H`, so there is no `dyn` here and no
322/// boxed future per request: this is the hot path, and it is monomorphised.
323///
324/// # Examples
325///
326/// A handler that opens a session for any body and answers nothing yet — the
327/// shape a real server starts as, with `dispatch` filled in by the protocol
328/// layer:
329///
330/// ```
331/// use std::sync::Arc;
332///
333/// use axum::body::Body;
334/// use axum::http::Response;
335/// use protocol_droid::adapters::primary::transport::http::{
336///     EventSource, EventTail, Inbound, McpRouter, Refusal, RequestHandler, ServerConfig,
337///     SessionStore, Unsessioned,
338/// };
339/// use protocol_droid::application::session::IdSource;
340/// use protocol_droid::domain::session::state::SessionId;
341/// use serde_json::Value;
342///
343/// /// Mints the same id every time, which is fine for a single example server.
344/// struct OneId;
345/// impl IdSource for OneId {
346///     fn next_session_id(&self) -> SessionId {
347///         SessionId::new(1)
348///     }
349/// }
350///
351/// struct Server;
352///
353/// impl<S: EventSource> RequestHandler<S> for Server {
354///     fn unsessioned(&self, _body: &[u8], _headers: &axum::http::HeaderMap) -> Unsessioned {
355///         Unsessioned::Establishing
356///     }
357///
358///     async fn dispatch(
359///         &self,
360///         call: Inbound<'_, S>,
361///     ) -> Result<Response<Body>, Refusal> {
362///         let _ = (call.context, call.session, call.body, call.protocol, call.headers);
363///         Err(Refusal::Handler("this example serves no methods".to_owned()))
364///     }
365/// }
366///
367/// let config = ServerConfig::new()?;
368/// let sessions = Arc::new(SessionStore::<EventTail<Value>>::new(Arc::new(OneId)));
369/// // The router is stateful, so it keeps the handler alive; hand it to a
370/// // server when one exists, or test it through `tower` in the meantime.
371/// let router = McpRouter::new(config, sessions, Server).into_router()?;
372/// let _ = router;
373/// # Ok::<(), Box<dyn std::error::Error>>(())
374/// ```
375pub trait RequestHandler<S>: Send + Sync + 'static {
376    /// What a request body means when it named no session.
377    ///
378    /// Asked for every `POST` whose header is absent, and never for one whose
379    /// header is present — so a body that establishes a session but arrives
380    /// with a bad id is still a `400`.
381    ///
382    /// Three answers rather than a bool, because a boolean cannot express the
383    /// case the **2026-07-28** stateless lifecycle adds: a request that is
384    /// complete on its own, carries its version in `_meta`, and is served
385    /// against a profile resolved per request. That is not "opens a session"
386    /// and it is not "needs one", and a two-valued question has to lie about
387    /// one of them.
388    ///
389    /// The headers are offered because a body alone cannot always say: a
390    /// `2026-07-28` request whose `_meta` is missing altogether is still a
391    /// stateless request — its `MCP-Protocol-Version` names a revision with no
392    /// sessions — and it is owed that revision's `-32602`, not a
393    /// missing-session refusal from a lifecycle it is not using.
394    fn unsessioned(&self, body: &[u8], headers: &HeaderMap) -> Unsessioned;
395
396    /// Runs the request.
397    ///
398    /// The body has arrived read and bounded; the context has passed the
399    /// endpoint's policy; and the session, when the header named one, is
400    /// [`SessionHandle::Live`], because a malformed or unknown id was refused
401    /// before this was called.
402    ///
403    /// # Errors
404    ///
405    /// Any [`Refusal`]. There is no other failure channel: an adapter that
406    /// cannot answer a request has exactly one honest way to say so, and it is
407    /// a status code with a JSON-RPC body rather than a transport exception.
408    ///
409    /// # Why this is `-> impl Future + Send` and not `async fn`
410    ///
411    /// Because a trait method written `async fn` returns an RPITIT future whose
412    /// auto-trait bounds are *not* visible to a generic caller, and this
413    /// adapter's caller is generic: `post_request` is generic over `H` and
414    /// axum requires a handler's future to be `Send`. Written this way the
415    /// bound is part of the contract, so a caller holding only `H:
416    /// RequestHandler<S>` can rely on it without knowing anything about `H`'s
417    /// body. An implementor writes a plain `async fn dispatch`, which satisfies
418    /// this declaration and keeps the future unboxed — the alternative,
419    /// `Pin<Box<dyn Future>>`, would put a heap allocation on the request path
420    /// to buy a bound the declaration already gives for free.
421    fn dispatch(
422        &self,
423        call: Inbound<'_, S>,
424    ) -> impl Future<Output = Result<Response, Refusal>> + Send;
425}
426
427/// The Streamable HTTP route table.
428///
429/// The error returned by [`McpRouter::into_router`].
430///
431/// Two failure modes, both at mount time rather than at the first request:
432/// a configured security header that is not a legal HTTP value
433/// ([`Header`](Self::Header)), and the silent-fail-open combination
434/// described in issue #11
435/// ([`UnprotectedAuthenticator`](Self::UnprotectedAuthenticator)). The
436/// router was previously typed `Result<_, InvalidHeaderValue>` and the
437/// new variant widens it; callers that used `?` continue to work because
438/// the existing `ServeError::Routes` wraps `InvalidHeaderValue` and
439/// `BridgeServeError::Router` does the same.
440#[derive(Debug, thiserror::Error)]
441pub enum RouterError {
442    /// A configured security header is not a legal HTTP header value.
443    ///
444    /// Surfaces a malformed `SecurityHeaders` at mount time rather than
445    /// at the first response. The alternative is a server that starts and
446    /// then answers every request with a header set it cannot write.
447    #[error("a configured security header is not a legal HTTP header value: {0}")]
448    Header(#[source] InvalidHeaderValue),
449
450    /// A real authenticator was wired but the policy is still the default
451    /// [`AuthPolicy::Public`].
452    ///
453    /// The combination is the exact fail-open the issue #11 review
454    /// described: `with_authenticator` is consulted, a valid token
455    /// produces a scoped `AuthContext`, but `AuthPolicy::refuse` answers
456    /// `None` on an empty `required()` and the endpoint is served to
457    /// anonymous callers. Failing closed here means a deployment that
458    /// wired a token authenticator but forgot the `with_policy` call
459    /// gets a startup error, not a permission boundary that does not
460    /// exist.
461    ///
462    /// The fix is to follow `with_authenticator` with
463    /// `with_policy(AuthPolicy::Require(scopes))` — or, for a deployment
464    /// that genuinely wants anonymous callers, drop the
465    /// `with_authenticator` call entirely.
466    #[error(
467        "with_authenticator was wired but the policy is still AuthPolicy::Public; \
468         a real authenticator paired with the default public policy fails open \
469         silently. Add .with_policy(AuthPolicy::Require(scopes)) after \
470         .with_authenticator(...), or drop the .with_authenticator(...) call if \
471         the endpoint is meant to be anonymous."
472    )]
473    UnprotectedAuthenticator,
474}
475
476impl From<InvalidHeaderValue> for RouterError {
477    fn from(value: InvalidHeaderValue) -> Self {
478        Self::Header(value)
479    }
480}
481
482/// Generic over the stored session `S` and the protocol layer `H`, so swapping
483/// either does not change the router's own type.
484pub struct McpRouter<S, H> {
485    config: ServerConfig,
486    sessions: Arc<SessionStore<S>>,
487    handler: H,
488    policy: AuthPolicy,
489    authenticator: Arc<dyn Authenticator + Send + Sync>,
490    origin: OriginPolicy,
491    rate_limiter: RateLimiter,
492    /// True iff [`with_authenticator`](Self::with_authenticator) was called.
493    ///
494    /// The struct's default `authenticator` is the unit [`Anonymous`], and
495    /// a caller that wires a real authenticator but leaves the policy at
496    /// the default [`AuthPolicy::Public`] is the exact configuration
497    /// described in GitHub issue #11: the server starts, the token check
498    /// is consulted, but `refuse` short-circuits on an empty `required()`
499    /// and the endpoint is served to anonymous callers. The field exists
500    /// so [`into_router`](Self::into_router) can fail closed on the
501    /// combination.
502    authenticator_was_explicit: bool,
503}
504
505/// Hand-written rather than derived, and for two reasons.
506///
507/// The derive would demand `S: Debug + H: Debug`, which is a bound a
508/// deployment's own protocol layer would have to satisfy for a `{:?}` nobody
509/// writes. And this is the one type in the adapter where a `{:?}` is a
510/// plausible accident: it holds the session store. [`SessionStore`]'s own
511/// `Debug` deliberately prints its length and not its keys, and a derived
512/// format would bypass that, so the field is left out entirely rather than
513/// summarised.
514impl<S, H> core::fmt::Debug for McpRouter<S, H> {
515    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
516        f.debug_struct("McpRouter")
517            .field("endpoint", &self.config.endpoint())
518            .field("streaming", &self.config.streaming())
519            .field("policy", &self.policy)
520            .finish_non_exhaustive()
521    }
522}
523
524impl<S, H> McpRouter<S, H>
525where
526    S: EventSource,
527    H: RequestHandler<S>,
528{
529    /// A router over `sessions`, dispatching through `handler`, authenticating
530    /// anonymously and permitting everything.
531    ///
532    /// [`with_authenticator`](Self::with_authenticator) and
533    /// [`with_policy`](Self::with_policy) are the two knobs, and both are
534    /// explicit: a deployment that needs a credential says so, rather than
535    /// inheriting a default that fails open or closed by accident.
536    #[must_use]
537    pub fn new(config: ServerConfig, sessions: Arc<SessionStore<S>>, handler: H) -> Self {
538        Self {
539            rate_limiter: RateLimiter::new(*config.rate_limit()),
540            origin: OriginPolicy::strict(None),
541            config,
542            sessions,
543            handler,
544            policy: AuthPolicy::Public,
545            authenticator: Arc::new(Anonymous),
546            authenticator_was_explicit: false,
547        }
548    }
549
550    /// Sets the `Origin` allow-list.
551    ///
552    /// T17. Streamable HTTP had no origin check of its own — the policy was
553    /// written for the legacy HTTP+SSE transport and left there — which meant
554    /// a browser could rebind onto a Streamable HTTP server and have the
555    /// request answered. The default is [`OriginPolicy::strict`] with no
556    /// origins, which refuses every `Origin` that is sent and allows a request
557    /// that sent none — see `OriginPolicy::check` for why absent is not the
558    /// same as foreign.
559    #[must_use]
560    pub fn with_origin_policy(mut self, origin: OriginPolicy) -> Self {
561        self.origin = origin;
562        self
563    }
564
565    /// Replaces the per-subject request allowance.
566    ///
567    /// Defaults to [`ServerConfig::rate_limit`], so a configuration that set
568    /// one does not have to set it twice.
569    #[must_use]
570    pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
571        self.rate_limiter = limiter;
572        self
573    }
574
575    /// Replaces the anonymous default with a real implementation.
576    ///
577    /// T16 supplies the OAuth one. It is a `dyn` because a deployment picks it
578    /// at start-up and the choice is not this router's to make; the trait is
579    /// object-safe precisely because its one method is synchronous.
580    #[must_use]
581    pub fn with_authenticator(
582        mut self,
583        authenticator: Arc<dyn Authenticator + Send + Sync>,
584    ) -> Self {
585        self.authenticator = authenticator;
586        self.authenticator_was_explicit = true;
587        self
588    }
589
590    /// States, out loud, whether this endpoint needs a credential.
591    #[must_use]
592    pub const fn with_policy(mut self, policy: AuthPolicy) -> Self {
593        self.policy = policy;
594        self
595    }
596
597    /// The mounted [`axum::Router`].
598    ///
599    /// # Errors
600    ///
601    /// [`RouterError::Header`] if a configured security header is not a
602    /// legal HTTP header value. Failing here is the point: the alternative
603    /// is a server that starts and then answers every request with a header
604    /// set it cannot write.
605    ///
606    /// [`RouterError::UnprotectedAuthenticator`] if a real authenticator was
607    /// wired with [`with_authenticator`](Self::with_authenticator) but the
608    /// policy is still the default [`AuthPolicy::Public`]. The combination
609    /// is the silent fail-open the issue #11 review described: the
610    /// authenticator is consulted, a valid token produces a scoped context,
611    /// but `refuse` short-circuits on an empty `required()` and the
612    /// endpoint is served to anonymous callers. Failing closed at mount
613    /// time turns the misconfiguration into a startup error the operator
614    /// sees, rather than a permission boundary that quietly does not exist.
615    pub fn into_router(self) -> Result<AxumRouter, RouterError> {
616        if self.authenticator_was_explicit && matches!(self.policy, AuthPolicy::Public) {
617            return Err(RouterError::UnprotectedAuthenticator);
618        }
619        let endpoint = self.config.endpoint().as_str().to_owned();
620        let security = self.config.security().clone();
621        let max_concurrency = self.config.max_concurrency();
622        let state = Arc::new(RouterState {
623            config: self.config,
624            sessions: Arc::clone(&self.sessions),
625            handler: self.handler,
626            policy: self.policy,
627            authenticator: Arc::clone(&self.authenticator),
628            origin: self.origin,
629            rate_limiter: self.rate_limiter,
630        });
631        let methods: MethodRouter<Arc<RouterState<S, H>>> = axum::routing::post(post_request)
632            .get(open_stream)
633            .delete(terminate_session);
634        let mut router = AxumRouter::new().route(&endpoint, methods);
635
636        // RFC 9728 §3.1's discovery document, mounted from the same config that
637        // made the `401` challenge name it. `with_resource_metadata` says this
638        // server publishes a document; leaving the caller to also remember to
639        // merge a router for it means the two halves can drift, and the drift
640        // is a `WWW-Authenticate` pointing at a `404`. The config is the single
641        // source of truth for both.
642        //
643        // Safe inside this router, which was the open question when this was
644        // written: `OriginPolicy::check` returns `Ok` for an absent `Origin`
645        // (a non-browser client, by design), and `check_host` only demands the
646        // `Host` a production deployment already had to configure for its MCP
647        // requests to be answered at all. So the discovery `GET` faces exactly
648        // the policy every other route on this server faces — no more.
649        if let Some(metadata) = state.config.resource_metadata() {
650            router = router.route(
651                &metadata.resource().metadata_path(),
652                axum::routing::get(serve_document),
653            );
654        }
655
656        let router = router
657            .layer(axum::middleware::from_fn_with_state(
658                Arc::clone(&state),
659                enforce_deadline,
660            ))
661            .layer(axum::middleware::from_fn_with_state(
662                Arc::clone(&state),
663                enforce_origin,
664            ))
665            .layer(axum::middleware::from_fn_with_state(
666                Arc::clone(&state),
667                enforce_host,
668            ))
669            .layer(axum::middleware::from_fn_with_state(
670                Arc::clone(&state),
671                enforce_rate_limit,
672            ))
673            .with_state(state);
674        // Security headers outermost, so they reach the responses no handler
675        // wrote; load shedding inside them, so a shed request still carries
676        // them. `apply_layers` is last because it is the only layer that
677        // decides not to run the inner ones at all.
678        Ok(with_security(
679            hardening::apply_layers(router, max_concurrency),
680            &security,
681        )?)
682    }
683}
684
685/// Wraps `router` so that **every** response carries `headers`.
686///
687/// Public and free-standing because T15's legacy HTTP+SSE transport mounts a
688/// different set of routes and needs the identical guarantee. A second
689/// implementation of "apply the three headers" is exactly how a transport ends
690/// up answering errors without them.
691///
692/// The layer wraps the router as a whole rather than each route, which is what
693/// puts the headers on responses no handler wrote: axum's `405` for an unmapped
694/// method, its `404` for an unmapped path, and an extractor's rejection.
695///
696/// # Errors
697///
698/// [`InvalidHeaderValue`] if a configured header is not a legal HTTP value.
699pub fn with_security(
700    router: AxumRouter,
701    headers: &SecurityHeaders,
702) -> Result<AxumRouter, InvalidHeaderValue> {
703    let layer = Arc::new(SecurityLayer::parse(headers)?);
704    Ok(router.layer(axum::middleware::from_fn_with_state(
705        layer,
706        apply_security_headers,
707    )))
708}
709
710/// Adds the three security headers to whatever the inner service answered.
711async fn apply_security_headers(
712    State(policy): State<Arc<SecurityLayer>>,
713    request: Request<Body>,
714    next: Next,
715) -> Response {
716    let mut response = next.run(request).await;
717    let out = response.headers_mut();
718    // `insert` rather than `append`: these are policy headers about the
719    // response as a whole, and a second copy of one is a header an
720    // intermediary has to guess between.
721    out.insert(
722        header::CONTENT_SECURITY_POLICY,
723        policy.content_security_policy.clone(),
724    );
725    out.insert(
726        header::STRICT_TRANSPORT_SECURITY,
727        policy.strict_transport_security.clone(),
728    );
729    out.insert(header::X_FRAME_OPTIONS, policy.x_frame_options.clone());
730    response
731}
732
733/// The `POST` route: one request, one answer, or one stream.
734async fn post_request<S, H>(
735    State(state): State<Arc<RouterState<S, H>>>,
736    headers: HeaderMap,
737    body: Body,
738) -> Response
739where
740    S: EventSource,
741    H: RequestHandler<S>,
742{
743    // Authentication first, before anything else. See the module docs for the
744    // oracle the ordering prevents.
745    let context = match authenticate(state.authenticator.as_ref(), &state.policy, &headers).await {
746        Ok(context) => context,
747        Err(refusal) => return refuse_with_challenge(state.config.challenge().as_deref(), refusal),
748    };
749
750    // The ceiling is the domain's, read once and converted once. A `u32` that
751    // does not fit a `usize` is a target whose `usize` is narrower than 32
752    // bits; refusing every body is the safe reading there, because the
753    // alternative is a limit that silently wraps.
754    let Ok(limit) = usize::try_from(state.config.max_body().bytes()) else {
755        // A `u32` that does not fit a `usize` is a target whose `usize` is
756        // narrower than 32 bits. Refusing every body is the safe reading
757        // there, because the alternative is a limit that silently wraps.
758        return refuse_with_challenge(
759            state.config.challenge().as_deref(),
760            Refusal::Handler("the configured body limit does not fit this platform".to_owned()),
761        );
762    };
763    let body = match read_body(body, limit).await {
764        Ok(body) => body,
765        Err(refusal) => return refuse_with_challenge(state.config.challenge().as_deref(), refusal),
766    };
767
768    // Read once, here, so every refusal *after* this point can answer the id
769    // the client used. A body that will not decode has no id to offer and
770    // falls back to `null`, which is what JSON-RPC asks for when the id was
771    // never detected.
772    let request_id = request_id_of(&body);
773
774    // The body is read before the header is resolved, because "what does this
775    // body mean without a session?" is a question about the body. It is asked
776    // only when the header is absent, and asking it is the same as not
777    // consulting the store — an absent header never reaches a lookup.
778    let session = match Header::read(&headers, &SESSION_HEADER) {
779        Header::Absent => match state.handler.unsessioned(&body, &headers) {
780            Unsessioned::Establishing => SessionHandle::Establishing,
781            Unsessioned::Stateless => SessionHandle::Stateless,
782            Unsessioned::NeedsSession => {
783                return refuse_with_id_and_challenge(
784                    state.config.challenge().as_deref(),
785                    Refusal::MissingSession,
786                    request_id.as_ref(),
787                );
788            }
789        },
790        Header::Value(_) | Header::Unreadable => match resolve_session(&state.sessions, &headers) {
791            Ok(session) => session,
792            Err(refusal) => {
793                return refuse_with_id_and_challenge(
794                    state.config.challenge().as_deref(),
795                    refusal,
796                    request_id.as_ref(),
797                );
798            }
799        },
800    };
801
802    // The version header is parsed before anything is dispatched, so a client
803    // that wrote a broken one is refused without the request running. What it
804    // *disagrees* with is not decided here: that is a comparison against the
805    // session's negotiated revision, and only the protocol layer knows it.
806    //
807    // A stateless request is the exception. Its header is compared with the
808    // version its own `_meta` names, and a version this build cannot parse is
809    // still a version the client asked for — answered with the revision's
810    // `UnsupportedProtocolVersionError` echoing it, which the protocol layer
811    // can only do if the request reaches it.
812    let protocol = match read_protocol(&headers) {
813        Ok(protocol) => protocol,
814        Err(_) if matches!(session, SessionHandle::Stateless) => None,
815        Err(refusal) => {
816            return refuse_with_id_and_challenge(
817                state.config.challenge().as_deref(),
818                refusal,
819                request_id.as_ref(),
820            );
821        }
822    };
823
824    let call = Inbound {
825        context: &context,
826        session,
827        body: &body,
828        protocol,
829        headers: &headers,
830        streaming: state.config.streaming(),
831    };
832    match state.handler.dispatch(call).await {
833        Ok(response) => stamp_media_type(response),
834        Err(refusal) => refuse_with_id_and_challenge(
835            state.config.challenge().as_deref(),
836            refusal,
837            request_id.as_ref(),
838        ),
839    }
840}
841
842/// Names the media type of a handler's answer.
843///
844/// The handler produced a *body*; saying how that body is framed is the
845/// transport's job, not the protocol layer's. Leaving it to the handler means
846/// every handler has to remember, and a handler that forgets answers `200`
847/// with no media type at all — which is a response any conformant client is
848/// entitled to refuse, so the bug shows up as a peer that "does not work"
849/// against this server and nowhere else.
850///
851/// Only a `2xx` is stamped. A refusal already carries its own
852/// `application/json` and its own status, and overwriting a refusal's media
853/// type would be a second, quieter version of the same mistake.
854///
855/// A body the handler framed itself is left alone: a handler that deliberately
856/// answers with a stream has set `text/event-stream`, and overwriting that
857/// with JSON would turn a working streaming response into a parse error on the
858/// client.
859fn stamp_media_type(response: Response) -> Response {
860    if !response.status().is_success() {
861        return response;
862    }
863    let already_framed = response
864        .headers()
865        .get(header::CONTENT_TYPE)
866        .is_some_and(|value| {
867            let raw = value.as_bytes();
868            raw.starts_with(b"text/event-stream") || raw.starts_with(b"application/json")
869        });
870    if already_framed {
871        return response;
872    }
873    let mut response = response;
874    response.headers_mut().insert(
875        header::CONTENT_TYPE,
876        header::HeaderValue::from_static(JSON_MEDIA_TYPE),
877    );
878    response
879}
880
881/// The `GET` route: open the session's stream, resuming from `Last-Event-ID`.
882/// Serves the RFC 9728 document this router was configured to publish.
883///
884/// Deliberately does **not** consult the authenticator or the policy. §3.1
885/// puts this document at a URL a client fetches *because* it could not
886/// authenticate, so running the same gate that produced the `401` would make
887/// the escape route require the credential it explains how to obtain. That is
888/// not a weakening of the policy: the document names authorization servers and
889/// scopes, and is exactly the content RFC 9728 §2 intends to be public.
890async fn serve_document<S, H>(State(state): State<Arc<RouterState<S, H>>>) -> Response
891where
892    S: EventSource,
893    H: RequestHandler<S>,
894{
895    let Some(metadata) = state.config.resource_metadata() else {
896        // The route is only mounted when there is a document, so this arm is
897        // unreachable through this router. Answering `404` rather than `500`
898        // keeps it that way: if the route and the config ever disagree, a
899        // client sees "no document here", which is what that is.
900        return StatusCode::NOT_FOUND.into_response();
901    };
902    // Every field is a string, an `Arc<str>` or a `Vec` of those, so a failure
903    // here is a bug in the derive rather than anything a caller configured. The
904    // client gets a bodiless `500` because the wire contract is the document.
905    let Ok(body) = serde_json::to_vec(metadata) else {
906        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
907    };
908    (
909        StatusCode::OK,
910        [(header::CONTENT_TYPE, "application/json")],
911        body,
912    )
913        .into_response()
914}
915
916async fn open_stream<S, H>(
917    State(state): State<Arc<RouterState<S, H>>>,
918    headers: HeaderMap,
919) -> Response
920where
921    S: EventSource,
922    H: RequestHandler<S>,
923{
924    if let Err(refusal) = authenticate(state.authenticator.as_ref(), &state.policy, &headers).await
925    {
926        return refuse_with_challenge(state.config.challenge().as_deref(), refusal);
927    }
928
929    // A `GET` is a stream for a session that already exists. There is no body
930    // to inspect, so the header is not optional here.
931    let session = match resolve_session(&state.sessions, &headers) {
932        Ok(session) => session,
933        Err(refusal) => return refuse_with_challenge(state.config.challenge().as_deref(), refusal),
934    };
935    let SessionHandle::Live { session, .. } = session else {
936        // Unreachable: `resolve_session` only ever yields `Live`. Written as a
937        // `let ... else` rather than a panic because this file's rules forbid
938        // both, and a refusal is the honest answer if it is ever reached.
939        return refuse_with_challenge(
940            state.config.challenge().as_deref(),
941            Refusal::Handler("the session lookup produced no session".to_owned()),
942        );
943    };
944
945    // The cursor is the header and nothing else. The replay decision — full
946    // replay, a gap to announce, a cursor ahead of the stream, or a cursor that
947    // is not a cursor at all — belongs to `EventTail::plan`, and re-deriving
948    // any of it here is how a resume starts agreeing with itself on the happy
949    // path and disagreeing on the two paths nobody exercises. A `Last-Event-ID`
950    // that is not readable as a field-value cannot be handed to `plan` at all,
951    // and reading it as "absent" would hand the client a stream it believes it
952    // has already seen.
953    let cursor = match Header::read(&headers, &LAST_EVENT_ID) {
954        Header::Absent => None,
955        Header::Value(text) => Some(text),
956        Header::Unreadable => {
957            return refuse_with_challenge(
958                state.config.challenge().as_deref(),
959                Refusal::MalformedCursor,
960            );
961        }
962    };
963
964    // `into_response` is the one conversion from a decision to a response: a
965    // refusal becomes its own status and JSON-RPC body through the same
966    // `Refusal::into_response` every other path here uses, and anything else
967    // becomes a `200 text/event-stream` carrying the replayed frames and then
968    // staying open. Its `Err` arm is response assembly rather than protocol,
969    // so it is a 500 and not a stream.
970    match session
971        .tail()
972        .plan(cursor)
973        .into_response(state.config.keep_alive())
974    {
975        Ok(response) => response,
976        Err(error) => refuse_with_challenge(
977            state.config.challenge().as_deref(),
978            Refusal::Handler(error.to_string()),
979        ),
980    }
981}
982
983/// The `DELETE` route: terminate the named session.
984///
985/// # Idempotence
986///
987/// Terminating a live session is `200`, and a repeated `DELETE` is a `404`
988/// because the store has no tombstone and cannot tell a repeat from an id it
989/// never issued. That is the specification's own answer to "this server has no
990/// such session", and it tells the client to start a new session — which is
991/// exactly what a client that just terminated one wants. Making the repeat a
992/// `200` would need the store to remember terminated ids, which is a retention
993/// policy and has to be a bounded one; until that exists a `404` is the honest
994/// answer, and this paragraph is the note that says so.
995async fn terminate_session<S, H>(
996    State(state): State<Arc<RouterState<S, H>>>,
997    headers: HeaderMap,
998) -> Response
999where
1000    S: EventSource,
1001    H: RequestHandler<S>,
1002{
1003    if let Err(refusal) = authenticate(state.authenticator.as_ref(), &state.policy, &headers).await
1004    {
1005        return refuse_with_challenge(state.config.challenge().as_deref(), refusal);
1006    }
1007
1008    let id = match session_id(&headers) {
1009        Ok(Some(id)) => id,
1010        Ok(None) => {
1011            return refuse_with_challenge(
1012                state.config.challenge().as_deref(),
1013                Refusal::MissingSession,
1014            );
1015        }
1016        Err(refusal) => return refuse_with_challenge(state.config.challenge().as_deref(), refusal),
1017    };
1018
1019    // The returned `Arc` is dropped at the end of this match arm, which is what
1020    // releases the session's tail and every waker parked on it. Nothing else in
1021    // the adapter drops a session, so this is the only path that frees one.
1022    match state.sessions.terminate(&id) {
1023        Ok(Some(_terminated)) => StatusCode::OK.into_response(),
1024        Ok(None) => {
1025            refuse_with_challenge(state.config.challenge().as_deref(), Refusal::UnknownSession)
1026        }
1027        Err(error) => refuse_with_challenge(
1028            state.config.challenge().as_deref(),
1029            Refusal::Handler(error.to_string()),
1030        ),
1031    }
1032}
1033
1034/// Resolves the caller, and refuses it if the endpoint's policy says so.
1035///
1036/// # Errors
1037///
1038/// Whatever [`AuthPolicy::refuse`] returns: `401` for a caller that presented
1039/// nothing usable, `403` for one whose credential proved an identity without
1040/// the scope this endpoint names.
1041/// The `Mcp-Session-Id` the request named, if it named one.
1042///
1043/// # Errors
1044///
1045/// [`Refusal::MalformedSessionId`] — a `400` — when the header is present but
1046/// is not a well-formed id. That is deliberately distinct from the `404` of
1047/// [`resolve_session`]: one means the client's header is wrong, the other
1048/// means its session is gone, and the fixes are different.
1049fn session_id(headers: &HeaderMap) -> Result<Option<McpSessionId>, Refusal> {
1050    match Header::read(headers, &SESSION_HEADER) {
1051        Header::Absent => Ok(None),
1052        Header::Unreadable => Err(Refusal::MalformedSessionId),
1053        Header::Value(text) => text
1054            .parse::<McpSessionId>()
1055            .map(Some)
1056            .map_err(|_| Refusal::MalformedSessionId),
1057    }
1058}
1059
1060/// Resolves the header into a live session.
1061///
1062/// # Errors
1063///
1064/// [`Refusal::MissingSession`] for an absent header,
1065/// [`Refusal::MalformedSessionId`] for one that is not an id,
1066/// [`Refusal::UnknownSession`] for an id this server never issued, and
1067/// [`Refusal::Handler`] if the store's lock is poisoned.
1068fn resolve_session<S: EventSource>(
1069    sessions: &SessionStore<S>,
1070    headers: &HeaderMap,
1071) -> Result<SessionHandle<S>, Refusal> {
1072    let Some(id) = session_id(headers)? else {
1073        return Err(Refusal::MissingSession);
1074    };
1075    let found = sessions
1076        .lookup(&id)
1077        .map_err(|error| Refusal::Handler(error.to_string()))?;
1078    // The `Arc` is cloned out of the store and the clone is what the protocol
1079    // layer holds for the rest of the request. A session another request
1080    // terminates mid-flight therefore stays alive until this one finishes,
1081    // which is the lifetime a handler can safely assume.
1082    found.map_or_else(
1083        || Err(Refusal::UnknownSession),
1084        |session| Ok(SessionHandle::Live { id, session }),
1085    )
1086}
1087
1088/// Collects the request body, refusing it the moment it exceeds `limit`.
1089///
1090/// Bounded *while* reading rather than after: a `Content-Length` is a claim, and
1091/// a body that arrives over a limit must not first be buffered in full in order
1092/// to find out. The two failure cases are kept apart because a client can act on
1093/// one and not the other — over the ceiling is the caller's mistake and is a
1094/// `413`; an unreadable body is ours and is a `500`.
1095///
1096/// # Errors
1097///
1098/// [`Refusal::BodyTooLarge`] or [`Refusal::Handler`].
1099/// Reads `MCP-Protocol-Version`, or `None` when the client sent none.
1100///
1101/// A header that is present but is not a date this build can parse is a
1102/// `400` here rather than something the handler has to notice. A date it
1103/// cannot *know* — a future revision — parses to
1104/// [`ProtocolVersion::Unknown`] and is carried through, because refusing it
1105/// here would make this server unusable the day a new revision ships, which is
1106/// the one outcome the negotiation rules exist to prevent.
1107fn read_protocol(headers: &HeaderMap) -> Result<Option<ProtocolVersion>, Refusal> {
1108    let raw = match Header::read(headers, &PROTOCOL_HEADER) {
1109        Header::Absent => return Ok(None),
1110        Header::Unreadable => {
1111            return Err(Refusal::ProtocolHeader {
1112                reason: String::from("the value is not visible ASCII"),
1113            });
1114        }
1115        Header::Value(value) => value,
1116    };
1117    raw.parse::<ProtocolVersion>()
1118        .map(Some)
1119        .map_err(|_| Refusal::ProtocolHeader {
1120            reason: raw.to_owned(),
1121        })
1122}
1123
1124/// The one way a refusal becomes a response.
1125///
1126/// Every failure path in every handler ends here. It is a function rather than a
1127/// habit repeated at each call site because the alternative is the shape of bug
1128/// this adapter is built not to have: a handler that answers a refusal with
1129/// `StatusCode::OK` because it was in a hurry, on a path no test walks.
1130/// Renders a refusal as the response it is.
1131///
1132/// A `401` additionally carries the `WWW-Authenticate` challenge, because a
1133/// `401` without one is a dead end for the client: it knows it needs a
1134/// credential but not where the authorization server is, which is the one thing
1135/// a client that has just been handed an expired token cannot work out for
1136/// itself. RFC 9728 §5.1.
1137///
1138/// The `403` arm gets nothing. `insufficient_scope` and `ForeignOrigin` are
1139/// both 403s, and RFC 6750 §3.1 attaches a challenge to `401`, not `403` — a
1140/// `403` says this credential is fine and will not do, which re-authenticating
1141/// cannot change. Sending a challenge there would tell a correct client to do
1142/// the one thing that will not help.
1143/// The JSON-RPC id a body carries, if it carries one at all.
1144///
1145/// Total where the rest of the pipeline is fallible: a body that is a batch, a
1146/// reply shape, or not JSON at all simply has no id, and every caller already
1147/// has to handle "no id" because the earliest refusals run before any body
1148/// exists. Returning `None` for those keeps one answer for both cases instead
1149/// of two.
1150fn request_id_of(body: &[u8]) -> Option<WireId<'static>> {
1151    let decoded = decode(body).ok()?;
1152    let Frame::Single(envelope) = decoded else {
1153        return None;
1154    };
1155    envelope.id().map(WireId::to_owned)
1156}
1157
1158fn refuse_with_challenge(challenge: Option<&str>, refusal: Refusal) -> Response {
1159    refuse_with_id_and_challenge(challenge, refusal, None)
1160}
1161
1162/// [`refuse_with_challenge`], echoing the JSON-RPC id of the refused request.
1163///
1164/// `id` is the id read off a body this handler has already read. Passing it is
1165/// what stops a refusal that happened *after* the body arrived from answering
1166/// `id: null` — see [`Refusal::body_with_id`]. Everything that runs before the
1167/// body exists has nothing to pass and keeps answering `null`, which is what
1168/// JSON-RPC asks for in that case.
1169fn refuse_with_id_and_challenge(
1170    challenge: Option<&str>,
1171    refusal: Refusal,
1172    id: Option<&WireId<'_>>,
1173) -> Response {
1174    let status = refusal.status();
1175    let mut response = refusal.into_response_with_id(id);
1176    if status == StatusCode::UNAUTHORIZED
1177        && let Some(challenge) = challenge
1178        && let Ok(value) = HeaderValue::from_str(challenge)
1179    {
1180        response
1181            .headers_mut()
1182            .insert(header::WWW_AUTHENTICATE, value);
1183    }
1184    response
1185}
1186
1187#[cfg(test)]
1188mod tests;