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;