Skip to main content

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

1//! Serving MCP over Streamable HTTP, on a socket this crate bound.
2//!
3//! [`McpRouter`] builds an axum `Router`, and a `Router` is not a server. The
4//! gap between the two is where a consumer's HTTP support actually lives:
5//! somebody has to own a [`SessionStore`], mint ids for it, implement
6//! [`RequestHandler`] against the real [`Dispatcher`], bind a listener, and
7//! decide the bind address. Five decisions, of which one — the bind address —
8//! is genuinely the caller's.
9//!
10//! This module makes the other four. It is the HTTP counterpart of
11//! [`StdioBuilder`](crate::application::server::serve::StdioBuilder), and it
12//! mirrors it deliberately: both resolve a **ceiling** before the peer's first
13//! frame, both leave the ceiling overridable where the choice is real, and
14//! neither asks the caller to name a protocol version.
15//!
16//! The dispatch sequence is the stdio loop's, not a second one. Decoding,
17//! refusing a malformed frame, distinguishing `initialize` from everything
18//! else, and choosing a JSON-RPC code for a refusal are all decisions the
19//! crate makes elsewhere; a second copy here would be a second opinion on each
20//! of them, and a second opinion is wrong in exactly the cases nobody tests.
21//!
22//! # What it deliberately does not do
23//!
24//! It does not terminate TLS. The bind address defaults to loopback, and a
25//! loopback listener serving plain HTTP is a development shape rather than a
26//! deployment one. Binding `0.0.0.0` is permitted — the right answer depends on
27//! what terminates TLS in front of it, and that is not a fact this crate can
28//! see — but a non-loopback bind is this server's responsibility to get right,
29//! not something [`HttpBuilder::addr`] pretends to make safe.
30
31use core::num::NonZeroUsize;
32use std::net::SocketAddr;
33use std::sync::Arc;
34
35use serde_json::Value;
36
37use crate::adapters::primary::transport::http::config::ServerConfig;
38use crate::adapters::primary::transport::http::config::Streaming;
39use crate::adapters::primary::transport::http::documents::{
40    answer_of, id_as_i64, method_of, params_of, refusal_document, refusal_document_from_refusal,
41    result_document,
42};
43use crate::adapters::primary::transport::http::exchange::{ExchangeTable, HttpExchange};
44use crate::adapters::primary::transport::http::limits::RateLimiter;
45use crate::adapters::primary::transport::http::refusal::Refusal;
46use crate::adapters::primary::transport::http::router::{
47    EventSource, Inbound, McpRouter, RequestHandler, SessionHandle, Unsessioned,
48};
49use crate::adapters::primary::transport::http::session_store::SessionStore;
50use crate::adapters::primary::transport::http::stateless::{StatelessHeaders, StatelessRequest};
51use crate::adapters::primary::transport::http::stream::{EventTail, Finishing};
52use crate::adapters::primary::transport::http::{AuthPolicy, Authenticator};
53use crate::adapters::primary::transport::origin::OriginPolicy;
54use crate::application::mcp::request_meta::declares_version;
55use crate::application::server::Server;
56use crate::application::server::context::{PeerSession, RequestContext};
57use crate::application::server::dispatch::Dispatcher;
58use crate::application::server::{ListKind, ListSubscription};
59use crate::application::session::IdSource;
60use crate::application::session::frame_sink::{FrameSink, SinkError};
61use crate::domain::feature::resolve;
62use crate::domain::ir::JsonObject;
63use crate::domain::jsonrpc::{Frame, WireId, decode};
64use crate::domain::mcp::McpSessionId;
65use crate::domain::session::{CancellationToken, ProgressToken, SessionId};
66use crate::domain::{
67    CapabilitySet, EventId, Negotiation, ProtocolVersion, RequestId, SupportedVersions,
68    TransportKind,
69};
70
71/// The tail this session's server-initiated messages are published on.
72pub(super) type Tail = EventTail<Value>;
73
74/// Where an [`IdSource`] mints session ids from.
75///
76/// A named alias rather than a bare trait object repeated in two signatures, so
77/// that a reader sees the choice being made once.
78type Ids = Arc<dyn IdSource + Send + Sync>;
79
80/// How many undelivered frames one session's tail retains.
81///
82/// A `GET` that follows a `POST` resumes from this ring, so a client that
83/// disconnects and reconnects within the window gets the frames it missed.
84/// Large enough to cover a reconnect, small enough that an idle session is not
85/// holding a megabyte of `Value`s nobody will read.
86/// The keep-alive interval a draining stream uses.
87///
88/// Zero, because a `POST`'s stream ends as soon as the tail is drained and a
89/// keep-alive only matters to a stream that is staying open. A non-zero value
90/// here would emit `:ping` frames into the middle of a response that is about
91/// to close.
92pub(super) const KEEP_ALIVE: core::time::Duration = core::time::Duration::ZERO;
93
94pub(super) const TAIL_CAPACITY: NonZeroUsize = match NonZeroUsize::new(64) {
95    Some(capacity) => capacity,
96    // A const context cannot call `ok_or`, and `None` here would mean a zero,
97    // which is not a capacity. Unreachable for any value above zero.
98    None => NonZeroUsize::MIN,
99};
100
101/// What [`HttpBuilder::bind`] reports.
102///
103/// The address the OS chose rather than the one that was asked for, because
104/// `127.0.0.1:0` is the ordinary way to ask for a free port and the address a
105/// caller needs in order to connect is the one that was bound.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct ServeReport {
108    /// The address actually bound.
109    pub addr: SocketAddr,
110    /// The revision ceiling pre-handshake sessions were gated with.
111    ///
112    /// The *ceiling*, not a negotiated revision: no peer has connected yet.
113    pub ceiling: ProtocolVersion,
114}
115
116/// Why an HTTP server could not be brought up.
117#[derive(Debug, thiserror::Error)]
118pub enum ServeError {
119    /// The address could not be listened on.
120    #[error("could not bind {addr}: {source}")]
121    Bind {
122        /// What was asked for.
123        addr: SocketAddr,
124        /// What the OS said.
125        #[source]
126        source: std::io::Error,
127    },
128
129    /// The endpoint policy is not usable.
130    #[error("the endpoint policy is not usable: {0}")]
131    Endpoint(#[from] crate::adapters::primary::transport::http::config::EndpointError),
132
133    /// The accept loop failed.
134    #[error("the accept loop failed: {0}")]
135    Accept(#[source] std::io::Error),
136
137    /// The route table could not be built.
138    ///
139    /// In practice a configured security header that is not a legal HTTP
140    /// header value, or the issue #11 fail-open combination of a wired
141    /// authenticator and the default `AuthPolicy::Public` policy. Failing
142    /// here rather than at the first request is the point: the alternative
143    /// is a server that starts and then answers every request with a
144    /// permission boundary that does not exist.
145    #[error("the route table could not be built: {0}")]
146    Routes(#[from] crate::adapters::primary::transport::http::router::RouterError),
147
148    /// The legacy HTTP+SSE routes could not be mounted.
149    ///
150    /// A path that is not an absolute path on this origin, or a configured
151    /// security header that is not a legal value.
152    #[cfg(feature = "legacy-sse")]
153    #[error("the legacy HTTP+SSE routes could not be mounted: {0}")]
154    LegacyRoutes(#[from] crate::adapters::primary::transport::legacy_sse::RouteError),
155
156    /// No session-id source is available in this build.
157    #[error(
158        "this build has no id source: build with `random-ids`, or pass one to \
159         HttpBuilder::ids"
160    )]
161    NoIdSource,
162
163    /// The system entropy pool could not be read.
164    #[error("session ids could not be drawn: {reason}")]
165    IdSource {
166        /// What the entropy source said.
167        reason: String,
168    },
169}
170
171/// One session's state on an HTTP connection.
172///
173/// Holds the negotiated [`PeerSession`] beside the tail its server-initiated
174/// messages go on, because the router is generic over the stored type and a
175/// [`PeerSession`] alone cannot answer [`EventSource::tail`].
176///
177/// The peer starts at the ceiling profile and is replaced by
178/// [`Dispatcher::handshake`]'s return once `initialize` arrives. That ordering
179/// is not incidental: a [`RequestContext`] needs a `PeerSession` to exist before
180/// dispatch, and the handshake is the call that produces one.
181#[derive(Debug)]
182pub struct HttpSession {
183    /// The session's protocol state — the ceiling profile until `initialize`
184    /// replaces it, and the negotiated one forever after.
185    peer: PeerSession,
186    /// Where this session's server-initiated messages are published.
187    tail: Tail,
188    /// The server-initiated requests this session is waiting on.
189    ///
190    /// Held per session rather than globally because the id a server-initiated
191    /// request carries is minted per session: two sessions both asking the peer
192    /// to sample both mint id `1`, and a shared table would hand the second
193    /// answer to the first session's tool.
194    exchange: ExchangeTable,
195    /// This session's standing interest in list changes.
196    ///
197    /// Held here so the session and the subscription end together: the store
198    /// dropping an expired or deleted session drops this guard, and the
199    /// fan-out stops writing to a tail nobody reads. `None` on a session that
200    /// has not finished its handshake, because nothing has been negotiated yet
201    /// that could gate a notification.
202    _lists: Option<ListSubscription>,
203}
204
205impl EventSource for HttpSession {
206    fn tail(&self) -> &Tail {
207        &self.tail
208    }
209}
210
211impl HttpSession {
212    /// This session's peer, for a handler that reads it off a stored session.
213    fn peer_of(session: &Arc<Self>) -> PeerSession {
214        session.peer.clone()
215    }
216
217    /// This session's peer.
218    ///
219    /// The same state as `Self::peer_of` and for the same reason: a task
220    /// that outlives the request it was spawned for has to keep reading the
221    /// *session's* profile rather than a borrow of a body that is gone.
222    pub fn peer(&self) -> PeerSession {
223        self.peer.clone()
224    }
225}
226
227/// The handler that bridges [`McpRouter`] to the real [`Dispatcher`].
228///
229/// The port trait belongs to the router, so this type implements it and
230/// declares nothing. There is no second "how to answer a request" here — only
231/// [`Dispatcher`] with an HTTP envelope around it.
232pub struct HttpHandler {
233    /// The protocol layer. Shared, because the router is cheap to clone and the
234    /// dispatcher is not.
235    pub(super) dispatcher: Arc<Dispatcher>,
236    /// This server's sessions. The handler owns the store rather than being
237    /// handed one per request because *minting* is a protocol-layer decision:
238    /// the router can look a session up but cannot decide what a session is.
239    pub(super) sessions: Arc<SessionStore<HttpSession>>,
240    /// The revision to gate a session with before it has been negotiated.
241    ceiling: ProtocolVersion,
242}
243
244impl core::fmt::Debug for HttpHandler {
245    /// Prints the ceiling and nothing else.
246    ///
247    /// Handwritten because the store it holds is a table of session ids, and
248    /// this project does not put a full session id in a log line. A derived
249    /// `Debug` would make a `{:?}` in a log statement a way around that.
250    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
251        formatter
252            .debug_struct("HttpHandler")
253            .field("ceiling", &self.ceiling)
254            .finish_non_exhaustive()
255    }
256}
257
258impl HttpHandler {
259    /// A handler over `dispatcher` and `sessions`, gated at `ceiling`.
260    #[must_use]
261    pub const fn new(
262        dispatcher: Arc<Dispatcher>,
263        sessions: Arc<SessionStore<HttpSession>>,
264        ceiling: ProtocolVersion,
265    ) -> Self {
266        Self {
267            dispatcher,
268            sessions,
269            ceiling,
270        }
271    }
272
273    /// The revision a session is gated with before it is negotiated.
274    #[must_use]
275    pub const fn ceiling(&self) -> ProtocolVersion {
276        self.ceiling
277    }
278
279    /// The session a request arrives on, or a fresh one to handshake on.
280    ///
281    /// A fresh session is seeded at the ceiling profile rather than at the
282    /// peer's answer, because `initialize` is the request that *produces* the
283    /// negotiated peer: there is nothing to read until it has run. The same
284    /// ordering the stdio loop uses, and for the same reason.
285    ///
286    /// The principal is deliberately *not* applied here. It is the request's
287    /// rather than the session's, because the router resolved it from the
288    /// credentials this request presented and a bearer token can be refreshed,
289    /// replaced, or withdrawn between two requests on one session. Every
290    /// visibility gate reads `ctx.peer().auth()` — `Dispatcher` for the
291    /// listings, `RegistryServer::call` for the call path — so a peer that
292    /// never received the request's context is anonymous to all of them, and a
293    /// scope-gated primitive is invisible to the very caller who earned it.
294    fn peer_for(&self, stored: Option<&Arc<HttpSession>>) -> PeerSession {
295        stored.map_or_else(|| self.seed().peer, HttpSession::peer_of)
296    }
297
298    /// A session at the ceiling, for a connection that has not handshaken.
299    fn seed(&self) -> HttpSession {
300        Self::session_for(self.ceiling, CapabilitySet::empty())
301    }
302
303    /// A peer resolved for `version` and what the client said it supports.
304    ///
305    /// Shared by the two callers that need a session with no handshake behind
306    /// it: the ceiling seed for a request that has not negotiated, and the
307    /// stateless peer, which differs only in that it knows its revision and its
308    /// capabilities. Building both from one function is what keeps the
309    /// stateless path from quietly acquiring a difference of its own.
310    pub(super) fn session_for(version: ProtocolVersion, client_caps: CapabilitySet) -> HttpSession {
311        let profile = resolve(Negotiation {
312            version,
313            client_caps,
314            server_caps: CapabilitySet::all_for(version),
315            transport: TransportKind::StreamableHttp,
316        });
317        let tail = Tail::new(TAIL_CAPACITY);
318        let exchange = ExchangeTable::new();
319        let sink = Arc::new(TailSink { tail: tail.clone() });
320        HttpSession {
321            peer: PeerSession::new(
322                version,
323                profile,
324                SessionId::new(1),
325                TransportKind::StreamableHttp,
326                Arc::new(HttpExchange::new(exchange.clone(), tail.clone())),
327                sink,
328            ),
329            tail,
330            exchange,
331            _lists: None,
332        }
333    }
334    /// Answers a request beside a stream that is already open.
335    ///
336    /// A handler that publishes *and waits* — a tool that asks the peer to
337    /// sample or to elicit — cannot be answered the way a handler that only
338    /// publishes is. Awaiting it inline means the response is built after the
339    /// tool returns, and the tool returns after the peer answers, and the peer
340    /// answers after it reads a request sitting in a stream this method has
341    /// not built yet. A deadlock whose symptom is a client that waits forever
342    /// for a request it will never be sent.
343    ///
344    /// So the stream is built *first* and the handler runs beside it. The
345    /// tool's request is on the wire within milliseconds, the peer's answer
346    /// arrives on its own `POST`, and the handler finishes and signals, at
347    /// which point the stream ends.
348    ///
349    /// Separate from the caller's own dispatch because the two shapes do not
350    /// share a tail: this one needs the stream before the handler runs, and
351    /// the other needs the handler before the stream.
352    ///
353    /// # Errors
354    ///
355    /// [`Refusal::Handler`] if the stream cannot be assembled.
356    fn respond_beside_stream(
357        &self,
358        request_peer: &PeerSession,
359        session: &Arc<HttpSession>,
360        id: Option<&WireId<'static>>,
361        progress: Option<ProgressToken>,
362        method: &str,
363        params: &JsonObject,
364    ) -> Result<axum::response::Response, Refusal> {
365        let tail = session.tail.clone();
366        // Where the stream stood before this request ran, so it carries this
367        // request's frames and not the exchange that opened the session.
368        let since = session.tail.last().to_string();
369        let finished = Finishing::new();
370        let response = tail
371            .plan(Some(since.as_str()))
372            .into_finishable_response(finished.clone(), KEEP_ALIVE)
373            .map_err(|error| Refusal::Handler(error.to_string()))?;
374
375        let dispatcher = Arc::clone(&self.dispatcher);
376        // The whole peer is carried, not just the session's. The caller's
377        // principal lives on the *request's* peer — the router resolved it from
378        // the credentials this request presented — and rebuilding from the
379        // session would answer as though nobody had authenticated. Every
380        // visibility gate reads `ctx.peer().auth()`, so the cost of that is a
381        // scope-gated primitive invisible to the caller who earned it.
382        let owned_peer = request_peer.clone();
383        let owned_id = id.map(WireId::to_owned);
384        let owned_method = method.to_owned();
385        let owned_params = params.clone();
386        let tail_for_result = tail;
387        let finished_for_task = finished;
388        let task = tokio::spawn(async move {
389            let peer = owned_peer;
390            let context = RequestContext::for_request_with_state(
391                &peer,
392                RequestId::from_wire(owned_id.as_ref()),
393                CancellationToken::new(),
394                progress,
395                dispatcher.state().clone(),
396            );
397            let document = match dispatcher
398                .dispatch(&context, &owned_method, &owned_params)
399                .await
400            {
401                Ok(result) => match result_document(owned_id.as_ref(), &result) {
402                    Ok(document) => document,
403                    // A refusal here is this server's own data failing to
404                    // serialise, reported as a JSON-RPC error rather than
405                    // dropped: a `POST` whose handler has already run cannot
406                    // take an HTTP status for it any more.
407                    Err(refusal) => refusal_document_from_refusal(owned_id.as_ref(), &refusal),
408                },
409                Err(error) => refusal_document(owned_id.as_ref(), &error),
410            };
411            tail_for_result.push(document);
412            finished_for_task.fire();
413        });
414
415        // The stream owns the handler's lifetime from here: dropping the
416        // response drops the body, which drops the handle the task holds. The
417        // task is already running, so returning does not wait for it.
418        let _task = task;
419        Ok(response)
420    }
421
422    /// Routes a peer's answer to the tool waiting for it, and acknowledges.
423    ///
424    /// An answer is a JSON-RPC message with an id and no method. It is
425    /// recognised rather than dispatched because there is nothing to dispatch
426    /// it to: the tool that asked the question is parked on the answer, and
427    /// routing it down the request path fails to find a method, answers the
428    /// peer with an error, and leaves the tool waiting for an answer the server
429    /// has already decided does not exist.
430    ///
431    /// The acknowledgement is `202 Accepted` with no body, which is what the
432    /// specification asks of a response to a response — and the same answer is
433    /// given whether the answer found its waiter or not, because a reply to an
434    /// unknown request is discarded and inventing a request to attach the
435    /// discard to would be worse.
436    fn acknowledge_answer(
437        body: &[u8],
438        session: Option<&Arc<HttpSession>>,
439        id: Option<&WireId<'static>>,
440    ) -> Result<axum::response::Response, Refusal> {
441        if let (Some(session), Some(id)) = (session, id)
442            && let Some(numeric) = id_as_i64(id)
443        {
444            session.exchange.resolve(numeric, answer_of(body));
445        }
446        axum::response::Response::builder()
447            .status(axum::http::StatusCode::ACCEPTED)
448            .body(axum::body::Body::empty())
449            .map_err(|_| Refusal::Handler("the acknowledgement did not build".to_owned()))
450    }
451}
452
453impl RequestHandler<HttpSession> for HttpHandler {
454    /// What a body means when the request named no session.
455    ///
456    /// Three arms, and which one a body falls in is a wire-format question
457    /// this method can answer on its own:
458    ///
459    /// - `initialize` opens a session. It is the only method that does.
460    /// - A body whose `params._meta` names
461    ///   [`PROTOCOL_VERSION_KEY`](crate::application::mcp::request_meta::PROTOCOL_VERSION_KEY)
462    ///   is a SEP-2575 stateless request. It carries its own revision and its
463    ///   own capabilities, so it needs no session and is served against its
464    ///   own profile.
465    /// - Anything else is a stateful call missing its header, which the router
466    ///   answers with a `400` and never hands here — so returning
467    ///   [`Unsessioned::NeedsSession`] for it is not a guess, it is the arm
468    ///   that produces that `400`.
469    ///
470    /// The version key is what makes a body self-describing, and it is the
471    /// only member that can: capabilities without a revision would not say
472    /// which gates the answer is read against.
473    ///
474    /// This runs on every sessionless POST, so the check is a lookup and not a
475    /// parse — a full [`RequestMeta::read`] would decode the whole body only
476    /// for a caller with a session header to throw it away.
477    ///
478    /// [`RequestMeta::read`]: crate::application::mcp::request_meta::RequestMeta::read
479    fn unsessioned(&self, body: &[u8], headers: &axum::http::HeaderMap) -> Unsessioned {
480        // Stateless first. A body naming its own revision, or a request whose
481        // `MCP-Protocol-Version` is a revision with no sessions, is served by
482        // the stateless lifecycle — including an `initialize`, which that
483        // lifecycle answers with `-32601` rather than by opening a session the
484        // client's own revision says do not exist.
485        if declares_own_version(body) || StatelessHeaders::names_stateless_revision(headers) {
486            return Unsessioned::Stateless;
487        }
488        match method_of(body) {
489            Some(method) if method == "initialize" => Unsessioned::Establishing,
490            _ => Unsessioned::NeedsSession,
491        }
492    }
493
494    /// Runs one request, and answers it.
495    ///
496    /// The refusal channel is a [`Refusal`]: there is no other, and an adapter
497    /// that cannot answer a request has exactly one honest way to say so. Note
498    /// that a *protocol* refusal — an unknown method, a tool that does not
499    /// exist — is not one. Those are answered in the body with a JSON-RPC error
500    /// object, because the peer asked a well-formed question and the answer is
501    /// that no such thing exists.
502    fn dispatch(
503        &self,
504        call: Inbound<'_, HttpSession>,
505    ) -> impl core::future::Future<Output = Result<axum::response::Response, Refusal>> + Send {
506        // Everything the handler needs is copied out of `call` before the
507        // future, because `Inbound` borrows the body and the router's `Arc`,
508        // neither of which outlives this function.
509        let body = call.body.to_vec();
510        let protocol = call.protocol;
511        // Captured here because the framing decision below happens after the
512        // body is owned, and this is the only record of how the server was
513        // configured to answer. See [`Streaming`].
514        let streaming = call.streaming;
515        let opening = matches!(call.session, SessionHandle::Establishing);
516        let stateless = matches!(call.session, SessionHandle::Stateless);
517        let stored = match &call.session {
518            SessionHandle::Establishing | SessionHandle::Stateless => None,
519            SessionHandle::Live { session, .. } => Some(Arc::clone(session)),
520        };
521        // The principal is this request's, not the session's — see
522        // `peer_for`, where the reasoning lives.
523        //
524        // A stateless request's peer is *not* built here, because there is
525        // nothing yet to build it from: the revision and the capabilities both
526        // live in this request's `_meta`, and the body has not been decoded
527        // until the future below runs. The seed peer stands in for that case
528        // and is replaced before anything reads it.
529        let auth = call.context.clone();
530        let peer = self.peer_for(stored.as_ref()).with_auth(auth.clone());
531        // Copied now for the same reason as the body: the header map is the
532        // router's, and a stateless request is checked against it after the
533        // body has been decoded.
534        let stateless_headers = stateless.then(|| StatelessHeaders::read(call.headers));
535
536        async move {
537            // Decoded once. A body that is a batch, or a reply shape, or
538            // unreadable, is the router's business and is refused here rather
539            // than answered with a second opinion about what it meant.
540            let decoded = decode(&body).map_err(|error| Refusal::MalformedBody {
541                reason: error.to_string(),
542            })?;
543            let Frame::Single(envelope) = decoded else {
544                return Err(Refusal::MalformedBody {
545                    reason: "an HTTP request body carries one message".to_owned(),
546                });
547            };
548            // Where the session's event stream stands *before* this request
549            // runs. Everything the handler publishes from here on is this
550            // request's answer; everything before it is a previous exchange's,
551            // and replaying that here is how a `tools/list` answers with the
552            // `initialize` frame that opened the session.
553            let cursor: Option<EventId> = stored.as_ref().map(|session| session.tail.last());
554            // Owned before anything else, because a handler may outlive this
555            // frame: the request body is dropped when this function returns,
556            // and a task still running needs the id it was answering.
557            let id: Option<WireId<'static>> = envelope.id().map(WireId::to_owned);
558
559            // An answer to a request *this* server made: a JSON-RPC message
560            // with an id and no method. It is recognised here rather than
561            // dispatched, because there is nothing to dispatch it to.
562            if method_of(&body).is_none() {
563                return Self::acknowledge_answer(&body, stored.as_ref(), id.as_ref());
564            }
565
566            let params = params_of(&envelope)?;
567            let method = envelope.method_str().unwrap_or_default();
568
569            // SEP-2575: a stateless request is served from its own `_meta`, by
570            // a path that shares nothing with a session — see `stateless`.
571            if let Some(headers) = stateless_headers {
572                return self
573                    .serve_stateless(StatelessRequest {
574                        id,
575                        method: method.to_owned(),
576                        params,
577                        headers,
578                        auth,
579                        streaming,
580                    })
581                    .await;
582            }
583
584            // The progress token lives in `params._meta.progressToken`, which
585            // is where the specification puts it and not beside the request id.
586            // Not reading it is a quiet failure: `report_progress` answers
587            // `NoProgressToken` and the client that asked to be told waits for
588            // updates from a server that believed nobody had asked.
589            let progress: Option<ProgressToken> =
590                crate::application::mcp::request_meta::progress_token(&params);
591            let peer_for_now = peer;
592            // Held only as long as this body. The task below builds its own
593            // from the stored session, because it outlives both of them.
594            let context = RequestContext::for_request_with_state(
595                &peer_for_now,
596                RequestId::from_wire(id.as_ref()),
597                CancellationToken::new(),
598                progress.clone(),
599                Arc::clone(self.dispatcher.state()),
600            );
601
602            // `initialize` is the one method that changes the session rather
603            // than being answered from it: the handshake resolves a profile for
604            // the revision the client offered, and every gate after this point
605            // has to read *that* one. A peer that negotiated `2024-11-05` must
606            // not be served on a profile resolved for whatever the process was
607            // built with.
608            let (document, negotiated) = if method == "initialize" {
609                match self.dispatcher.handshake(&context, &params) {
610                    Ok((result, peer)) => (result_document(id.as_ref(), &result)?, Some(peer)),
611                    Err(error) => (refusal_document(id.as_ref(), &error), None),
612                }
613            } else if let Some(session) = stored.as_ref() {
614                // A sessioned request is answered beside an already-open
615                // stream, which is the only shape that survives a handler that
616                // publishes *and waits* — see [`Self::respond_beside_stream`].
617                //
618                // Only when this server was configured to frame a stream at
619                // all. Under `Streaming::Json` the documented default, the
620                // reply is one JSON document: the stream is built and drained
621                // into it before the response exists, which is why a tool that
622                // elicits cannot work here and says so rather than hanging —
623                // see [`ExchangeTable::is_readable`].
624                if streaming == Streaming::EventStream {
625                    return self.respond_beside_stream(
626                        &peer_for_now,
627                        session,
628                        id.as_ref(),
629                        progress,
630                        method,
631                        &params,
632                    );
633                }
634                match self.dispatcher.dispatch(&context, method, &params).await {
635                    Ok(result) => (result_document(id.as_ref(), &result)?, None),
636                    Err(error) => (refusal_document(id.as_ref(), &error), None),
637                }
638            } else {
639                match self.dispatcher.dispatch(&context, method, &params).await {
640                    Ok(result) => (result_document(id.as_ref(), &result)?, None),
641                    Err(error) => (refusal_document(id.as_ref(), &error), None),
642                }
643            };
644
645            // A notification wants no answer, and a notification cannot open a
646            // session either — there is no id to put in the response header.
647            if id.is_none() {
648                return Self::response(&Value::Null, None, None);
649            }
650
651            // The header is checked against the session's own revision, which
652            // is why this is asked of the peer rather than of the raw header.
653            if let Some(agreed) = negotiated.as_ref().map(PeerSession::negotiated) {
654                check_header(protocol, agreed)?;
655            }
656
657            if opening {
658                return self.open_session(negotiated, stored.as_deref(), streaming, &document);
659            }
660
661            // The tail is handed over only when this server was configured to
662            // frame a stream. Under `Streaming::Json` the reply is one
663            // document with nothing to drain into, and passing the tail would
664            // answer with an event stream regardless — which is exactly how the
665            // default came to mean a bare document on `initialize` and a stream
666            // on everything after it.
667            Self::response(
668                &document,
669                None,
670                if streaming == Streaming::EventStream {
671                    stored.map(|session| (session.tail.clone(), cursor))
672                } else {
673                    None
674                },
675            )
676        }
677    }
678}
679
680impl HttpHandler {
681    /// Mints the session an `initialize` establishes, and answers with its id.
682    ///
683    /// Split out of [`RequestHandler::dispatch`] because opening a session is
684    /// one concern with one rule in it — what this session may be asked, and
685    /// what may be published to it — and inlining that into the request loop
686    /// left the rule sitting behind forty lines of session bookkeeping.
687    ///
688    /// # Errors
689    ///
690    /// [`Refusal::Handler`] if `initialize` succeeded without producing a peer
691    /// to store, if the store refuses the session, or if the answer will not
692    /// serialise.
693    fn open_session(
694        &self,
695        negotiated: Option<PeerSession>,
696        stored: Option<&HttpSession>,
697        streaming: Streaming,
698        document: &Value,
699    ) -> Result<axum::response::Response, Refusal> {
700        // `handshake` narrows the context it was handed, so the peer it
701        // returns carries the *establishing request's* principal. Every
702        // request resolves its own principal above, so nothing downstream
703        // reads this one — which is exactly why it is worth dropping rather
704        // than storing: session state outlives the credential that produced
705        // it, and a principal recorded there has the wrong lifetime for a
706        // token that can be refreshed, replaced, or withdrawn mid-session.
707        let peer = negotiated
708            .ok_or_else(|| Refusal::Handler("a session was opened without a handshake".to_owned()))?
709            .without_auth();
710        // The tail the router streams to the client, and the sink the handler
711        // writes through, are the same ring. Binding the peer to a *different*
712        // one is the quiet failure this shape invites: the session's own tail
713        // stays empty forever while the peer dutifully publishes into a ring
714        // no reader ever drains, so a tool that logs looks like a tool that
715        // logs nothing.
716        //
717        // Whether anything drains that ring is the framing decision, so it is
718        // recorded on the table here rather than left for a tool to discover by
719        // hanging. A session opened under `Streaming::Json` is never drained,
720        // because every reply it gives is one document.
721        let readable = streaming == Streaming::EventStream;
722        let (tail, exchange) = stored.map_or_else(
723            || {
724                let exchange = if readable {
725                    ExchangeTable::readable()
726                } else {
727                    ExchangeTable::new()
728                };
729                (Tail::new(TAIL_CAPACITY), exchange)
730            },
731            |session| (session.tail.clone(), session.exchange.clone()),
732        );
733        // The store allocates the id below, so it is read back from the minted
734        // handle rather than allocated twice — and the peer is bound to it
735        // before anything can key on it.
736        let sink: Arc<dyn crate::application::session::DynFrameSink> =
737            Arc::new(TailSink { tail: tail.clone() });
738        // Every list notification the negotiated profile allows. A session
739        // that did not negotiate `listChanged` for a kind is filtered by the
740        // fan-out's own gate, so subscribing to all three is not a promise.
741        let lists = self.dispatcher.server().handle().subscribe(
742            &sink,
743            peer.profile(),
744            &ListKind::ALL,
745            None,
746        );
747        let peer = peer
748            .with_sink(sink)
749            // Bound to *this* session's table, for the same reason the sink is
750            // bound to this session's tail: the peer outlives the seed it was
751            // built from, and an exchange on the wrong table publishes a
752            // request nobody on this session is waiting to answer.
753            .with_exchange(Arc::new(HttpExchange::new(exchange.clone(), tail.clone())));
754        let id_source = self.sessions.next_id();
755        let peer = peer.with_session(id_source);
756        let minted = self
757            .sessions
758            .open_with_id(
759                Arc::new(HttpSession {
760                    peer,
761                    tail,
762                    exchange,
763                    _lists: Some(lists),
764                }),
765                id_source,
766            )
767            .map_err(|error| Refusal::Handler(format!("no session: {error}")))?;
768        Self::response(document, Some(&minted), None)
769    }
770
771    /// The answer document, with the session header when one was minted.
772    ///
773    /// # Errors
774    ///
775    /// [`Refusal::Handler`] if the document will not serialise or the minted id
776    /// will not become a header value. Both are this server's own data, which
777    /// is why they are not reported as the peer's fault.
778    fn response(
779        document: &Value,
780        id: Option<&McpSessionId>,
781        tail: Option<(Tail, Option<EventId>)>,
782    ) -> Result<axum::response::Response, Refusal> {
783        // A session that may have published is answered with its event stream,
784        // and the reply goes *into* that stream rather than beside it.
785        //
786        // The alternative — streaming the handler's notifications and putting
787        // the JSON-RPC result in the body — is a response with two answers in
788        // two places, and which one a client reads as the result depends on the
789        // content type. The same tool call would resolve or hang purely on how
790        // the peer asked. One stream, frames in the order they happened: the
791        // notifications the call emitted, then its result.
792        if let Some((tail, cursor)) = tail {
793            tail.push(document.clone());
794            // The cursor, as the `Last-Event-ID` spelling `plan` reads. Held in
795            // a binding rather than built inline because the reference has to
796            // outlive the call.
797            let since = cursor.map(|id| id.to_string());
798            return match tail
799                .plan(since.as_deref())
800                .into_draining_response(KEEP_ALIVE)
801            {
802                Ok(response) => {
803                    let mut response = response;
804                    if let Some(id) = id {
805                        let value = axum::http::HeaderValue::from_str(&id.to_string()).map_err(
806                            |error| Refusal::Handler(format!("the id was not a header: {error}")),
807                        )?;
808                        response.headers_mut().insert("mcp-session-id", value);
809                    }
810                    Ok(response)
811                }
812                Err(error) => Err(Refusal::Handler(error.to_string())),
813            };
814        }
815
816        let encoded = serde_json::to_vec(document)
817            .map_err(|error| Refusal::Handler(format!("the reply did not serialise: {error}")))?;
818        let mut response = axum::response::Response::new(axum::body::Body::from(encoded));
819        if let Some(id) = id {
820            let value = axum::http::HeaderValue::from_str(&id.to_string())
821                .map_err(|error| Refusal::Handler(format!("the id was not a header: {error}")))?;
822            response.headers_mut().insert("mcp-session-id", value);
823        }
824        Ok(response)
825    }
826}
827
828/// Checks the `MCP-Protocol-Version` header against what the session agreed.
829///
830/// Only after a handshake, because before one the session has agreed to nothing
831/// and there is no answer to compare against.
832fn check_header(
833    header: Option<ProtocolVersion>,
834    negotiated: ProtocolVersion,
835) -> Result<(), Refusal> {
836    let sent = header.map(ProtocolVersion::as_wire);
837    crate::domain::check_protocol_header(negotiated, sent.as_deref()).map_err(|error| {
838        Refusal::ProtocolHeader {
839            reason: error.to_string(),
840        }
841    })
842}
843
844/// Whether a body is a SEP-2575 stateless request.
845///
846/// The test is the presence of the version key in `params._meta`, which is
847/// what makes a request carry its own revision rather than borrow a session's.
848/// A batch, a reply, or a body that will not decode has no params to look in
849/// and is not a stateless request; the caller's own decode will say why.
850fn declares_own_version(body: &[u8]) -> bool {
851    let Ok(decoded) = decode(body) else {
852        return false;
853    };
854    let Frame::Single(envelope) = decoded else {
855        return false;
856    };
857    let Some(params) = envelope.params() else {
858        return false;
859    };
860    let Ok(parsed) = serde_json::from_str::<serde_json::Value>(params.get()) else {
861        return false;
862    };
863    declares_version(&parsed)
864}
865
866/// The sink a session's handler-initiated messages go out on.
867///
868/// Publishing to the session's tail is what makes a notification — a log
869/// record, a progress increment — reach the client: the router streams the
870/// tail as the `POST`'s response body, and anything a handler writes here
871/// arrives as an SSE frame on that same request.
872///
873/// A sink that discarded every frame would fail quietly and loudly elsewhere: a
874/// tool that logs correctly looks like a tool that logs nothing, and a client
875/// waiting for three log records sees zero and reports the server as broken
876/// rather than as unwired.
877#[derive(Debug, Clone)]
878pub(super) struct TailSink {
879    pub(super) tail: Tail,
880}
881
882impl TailSink {
883    /// Publishes one encoded frame, synchronously.
884    ///
885    /// # Errors
886    ///
887    /// [`SinkError`] when the frame is not JSON.
888    pub(super) fn push(&self, frame: &str) -> Result<(), SinkError> {
889        serde_json::from_str::<Value>(frame)
890            .map(|value| {
891                self.tail.push(value);
892            })
893            .map_err(|_| SinkError)
894    }
895}
896
897impl FrameSink for TailSink {
898    /// Publishes one frame to the session's tail.
899    ///
900    /// Parsed on the way in rather than trusted, because the tail holds
901    /// `Value`: a frame that is not JSON has nothing to stream, and refusing it
902    /// here leaves a caller that can still be told why.
903    ///
904    /// # Errors
905    ///
906    /// [`SinkError`] when the frame is not JSON.
907    fn send(
908        &self,
909        frame: &str,
910    ) -> impl core::future::Future<Output = Result<(), SinkError>> + Send {
911        core::future::ready(self.push(frame))
912    }
913}
914
915/// The builder behind the server transport.
916///
917/// Every method is optional and every default is safe. The one decision this
918/// builder does not make for the caller is which address to bind, and it
919/// defaults that to loopback rather than to every interface.
920pub struct HttpBuilder {
921    /// The server whose tools and resources are served.
922    server: Server,
923    /// The address to listen on.
924    addr: SocketAddr,
925    /// The revision pre-handshake sessions are gated with.
926    ceiling: ProtocolVersion,
927    /// The endpoint policy and framing limits.
928    ///
929    /// Not defaulted here: [`ServerConfig::new`] parses the specification's
930    /// endpoint and can fail, and a builder that cannot fail is worth more than
931    /// one that resolves a constant early. An absent value is the crate's own
932    /// defaults, resolved in [`serve`](Self::serve).
933    config: Option<ServerConfig>,
934    /// Where session ids come from.
935    ids: Option<Ids>,
936    /// The authenticator the router is built with.
937    ///
938    /// Absent means the router's default `Anonymous` authenticator; the
939    /// `with_authenticator` setter records the choice so the route builder
940    /// can pair it with the policy in the right order. See
941    /// `McpRouter::with_authenticator` for the issue #11 fail-closed
942    /// contract this is the consumer entry point for.
943    authenticator: Option<Arc<dyn Authenticator + Send + Sync>>,
944    /// The auth policy the router is built with.
945    policy: Option<AuthPolicy>,
946    /// The `Origin` allow-list the router enforces.
947    origin_policy: Option<OriginPolicy>,
948    /// The per-subject request allowance.
949    rate_limiter: Option<RateLimiter>,
950    /// Where the `2024-11-05` HTTP+SSE routes are mounted, when they are.
951    #[cfg(feature = "legacy-sse")]
952    legacy_sse: Option<LegacySsePaths>,
953}
954
955/// The two paths the legacy transport is mounted on.
956#[cfg(feature = "legacy-sse")]
957#[derive(Debug, Clone)]
958struct LegacySsePaths {
959    stream: String,
960    message: String,
961}
962
963impl core::fmt::Debug for HttpBuilder {
964    /// Prints what was configured and nothing else.
965    ///
966    /// Handwritten for the same reason as [`HttpHandler`]'s: a derived `Debug`
967    /// would walk the [`Server`] and the id source, and neither belongs in a
968    /// log line. The address and the ceiling are the two facts worth having
969    /// when a serve has gone wrong.
970    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
971        formatter
972            .debug_struct("HttpBuilder")
973            .field("addr", &self.addr)
974            .field("ceiling", &self.ceiling)
975            .field("config", &self.config)
976            .field("ids_supplied", &self.ids.is_some())
977            .finish_non_exhaustive()
978    }
979}
980
981impl HttpBuilder {
982    /// Serves `server`, on loopback unless told otherwise.
983    ///
984    /// The defaults are the crate's own strict ones rather than the permissive
985    /// ones, so a builder that sets nothing is already safe: loopback, the
986    /// newest handshake-capable revision, and a session id from the system
987    /// entropy pool.
988    #[must_use]
989    pub fn new(server: Server) -> Self {
990        Self {
991            server,
992            addr: SocketAddr::from(([127, 0, 0, 1], 3000)),
993            ceiling: default_ceiling(),
994            config: None,
995            ids: None,
996            authenticator: None,
997            policy: None,
998            origin_policy: None,
999            rate_limiter: None,
1000            #[cfg(feature = "legacy-sse")]
1001            legacy_sse: None,
1002        }
1003    }
1004
1005    /// Also serves the `2024-11-05` HTTP+SSE transport, beside Streamable HTTP.
1006    ///
1007    /// A client opens a stream with `GET /sse`, is told where to `POST` by the
1008    /// `endpoint` event, and speaks `2024-11-05` over the pair. The same
1009    /// tools, resources and prompts answer on both transports, and the
1010    /// authenticator, `Origin` policy and rate limit set on this builder apply
1011    /// to both. Use [`legacy_sse_at`](Self::legacy_sse_at) to move the paths.
1012    #[cfg(feature = "legacy-sse")]
1013    #[must_use]
1014    pub fn legacy_sse(self) -> Self {
1015        self.legacy_sse_at(
1016            crate::adapters::primary::transport::legacy_sse::DEFAULT_STREAM_PATH,
1017            crate::adapters::primary::transport::legacy_sse::DEFAULT_MESSAGE_PATH,
1018        )
1019    }
1020
1021    /// [`legacy_sse`](Self::legacy_sse), on `stream` and `message` rather than
1022    /// `/sse` and `/messages`.
1023    ///
1024    /// Both must be absolute paths with no query; [`serve`](Self::serve)
1025    /// refuses to start otherwise.
1026    #[cfg(feature = "legacy-sse")]
1027    #[must_use]
1028    pub fn legacy_sse_at(mut self, stream: impl Into<String>, message: impl Into<String>) -> Self {
1029        self.legacy_sse = Some(LegacySsePaths {
1030            stream: stream.into(),
1031            message: message.into(),
1032        });
1033        self
1034    }
1035
1036    /// The address to bind. Defaults to `127.0.0.1:3000`.
1037    ///
1038    /// A non-loopback address is permitted but not made safe: this server
1039    /// speaks plain HTTP, so whatever is in front of it has to terminate TLS
1040    /// for it to be exposed to a network rather than to a machine.
1041    #[must_use]
1042    pub const fn addr(mut self, addr: SocketAddr) -> Self {
1043        self.addr = addr;
1044        self
1045    }
1046
1047    /// The revision to gate pre-handshake sessions with.
1048    #[must_use]
1049    pub const fn version(mut self, version: ProtocolVersion) -> Self {
1050        self.ceiling = version;
1051        self
1052    }
1053
1054    /// The endpoint policy and framing limits.
1055    #[must_use]
1056    pub fn config(mut self, config: ServerConfig) -> Self {
1057        self.config = Some(config);
1058        self
1059    }
1060
1061    /// Where session ids come from.
1062    ///
1063    /// Defaults to a counter from this crate's own source. Supply
1064    /// [`RandomIds`](crate::adapters::secondary::id_source::RandomIds) — behind
1065    /// the `random-ids` feature — for an id a peer cannot guess, which is the
1066    /// right choice anywhere a session id authorises anything.
1067    #[must_use]
1068    pub fn ids(mut self, ids: Ids) -> Self {
1069        self.ids = Some(ids);
1070        self
1071    }
1072
1073    /// Replaces the anonymous default with a real implementation.
1074    ///
1075    /// Pair with [`with_policy`](Self::with_policy); a wired authenticator
1076    /// under the default `AuthPolicy::Public` is the silent fail-open the
1077    /// router's `into_router` refuses at mount time. The same contract
1078    /// applies here, surfaced one layer up: a deployment that sets this
1079    /// without `with_policy` will see the misconfiguration at
1080    /// [`serve`](Self::serve) rather than at the first request.
1081    #[must_use]
1082    pub fn with_authenticator(
1083        mut self,
1084        authenticator: Arc<dyn Authenticator + Send + Sync>,
1085    ) -> Self {
1086        self.authenticator = Some(authenticator);
1087        self
1088    }
1089
1090    /// Replaces the default `AuthPolicy::Public` policy.
1091    ///
1092    /// A deployment that wired an authenticator above must also set
1093    /// `Require(scopes)` here, or `into_router` will refuse to build the
1094    /// route table. See `McpRouter::with_policy` for the matching
1095    /// contract.
1096    #[must_use]
1097    pub const fn with_policy(mut self, policy: AuthPolicy) -> Self {
1098        self.policy = Some(policy);
1099        self
1100    }
1101
1102    /// Replaces the default `Origin` allow-list.
1103    ///
1104    /// The default is `OriginPolicy::strict(None)`, which refuses every
1105    /// `Origin` header sent and allows a request that sent none — see
1106    /// `OriginPolicy::check` for why absent is not the same as foreign.
1107    /// A deployment serving a browser over a non-loopback address will
1108    /// need to widen this to the page's origin.
1109    #[must_use]
1110    pub fn with_origin_policy(mut self, origin: OriginPolicy) -> Self {
1111        self.origin_policy = Some(origin);
1112        self
1113    }
1114
1115    /// Replaces the per-subject request allowance.
1116    ///
1117    /// Defaults to `ServerConfig::rate_limit()`, so a configuration that
1118    /// set one does not have to set it twice.
1119    #[must_use]
1120    pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
1121        self.rate_limiter = Some(limiter);
1122        self
1123    }
1124
1125    /// Binds the listener without serving it.
1126    ///
1127    /// Split from serving because a caller terminating TLS itself needs the
1128    /// listener before anything is served on it, and because binding before
1129    /// accepting removes the window in which a connection is refused for a port
1130    /// this process is about to take.
1131    ///
1132    /// # Errors
1133    ///
1134    /// [`ServeError::Bind`] when the address is taken or unusable.
1135    pub async fn bind(self) -> Result<(tokio::net::TcpListener, ServeReport), ServeError> {
1136        let (listener, bound) = bind(self.addr).await?;
1137        let report = ServeReport {
1138            addr: bound,
1139            ceiling: self.ceiling,
1140        };
1141        Ok((listener, report))
1142    }
1143
1144    /// Binds and serves until the accept loop fails.
1145    ///
1146    /// # Errors
1147    ///
1148    /// [`ServeError`] from building the route table, from binding, or from the
1149    /// accept loop itself. A protocol error is not one of those: a peer whose
1150    /// request this server cannot serve is answered on the wire rather than
1151    /// raised here.
1152    pub async fn serve(self) -> Result<ServeReport, ServeError> {
1153        let parts = routes(self)?;
1154        let (listener, bound) = bind(parts.addr).await?;
1155        axum::serve(listener, parts.router)
1156            .await
1157            .map_err(ServeError::Accept)?;
1158        Ok(ServeReport {
1159            addr: bound,
1160            ceiling: parts.ceiling,
1161        })
1162    }
1163
1164    /// Serves on a listener the caller already bound.
1165    ///
1166    /// The other half of [`bind`](Self::bind), and the shape a deployment with
1167    /// TLS in front needs: bind, wrap the listener in a TLS acceptor, and serve
1168    /// on whatever that acceptor produces. Binding inside `serve` would make
1169    /// this impossible, because there would be no moment at which the raw
1170    /// listener was in the caller's hands.
1171    ///
1172    /// # Errors
1173    ///
1174    /// `ServeError::Routes` when the route table cannot be built, and
1175    /// [`ServeError::Accept`] when the loop does.
1176    pub async fn serve_on(
1177        self,
1178        listener: tokio::net::TcpListener,
1179    ) -> Result<ServeReport, ServeError> {
1180        let parts = routes(self)?;
1181        let bound = listener.local_addr().map_err(|source| ServeError::Bind {
1182            addr: parts.addr,
1183            source,
1184        })?;
1185        axum::serve(listener, parts.router)
1186            .await
1187            .map_err(ServeError::Accept)?;
1188        Ok(ServeReport {
1189            addr: bound,
1190            ceiling: parts.ceiling,
1191        })
1192    }
1193}
1194
1195/// What `routes` returns: the assembled router and the two builder fields
1196/// the calling method still needs after the table is built.
1197#[derive(Debug)]
1198struct BuilderOutput {
1199    /// The mounted `axum::Router`.
1200    router: axum::Router,
1201    /// The address the caller is binding. Preserved for `ServeReport` and
1202    /// for the `Bind` error path.
1203    addr: SocketAddr,
1204    /// The pre-handshake revision. Preserved for `ServeReport`.
1205    ceiling: ProtocolVersion,
1206}
1207
1208/// The route table this builder describes, and nothing that binds a socket.
1209///
1210/// Free so that both [`HttpBuilder::serve`] and [`HttpBuilder::serve_on`] build
1211/// the same table from the same parts. A second implementation here would be a
1212/// second answer to "what routes does this server have", and the two would
1213/// differ the first time either was edited.
1214fn routes(builder: HttpBuilder) -> Result<BuilderOutput, ServeError> {
1215    let HttpBuilder {
1216        server,
1217        addr,
1218        ceiling,
1219        config,
1220        ids,
1221        authenticator,
1222        policy,
1223        origin_policy,
1224        rate_limiter,
1225        #[cfg(feature = "legacy-sse")]
1226        legacy_sse,
1227    } = builder;
1228    let ids = match ids {
1229        Some(ids) => ids,
1230        None => default_ids()?,
1231    };
1232    #[cfg(feature = "random-ids")]
1233    let server = {
1234        let mut server = server;
1235        server.ensure_request_state_key(crate::adapters::secondary::id_source::random_state_key);
1236        server
1237    };
1238    let config = match config {
1239        Some(config) => config,
1240        None => ServerConfig::new()?,
1241    };
1242    let dispatcher = Arc::new(Dispatcher::new(Arc::new(server)));
1243    let sessions = Arc::new(SessionStore::<HttpSession>::new(Arc::clone(&ids)));
1244    let handler = HttpHandler::new(Arc::clone(&dispatcher), Arc::clone(&sessions), ceiling);
1245    #[cfg(feature = "legacy-sse")]
1246    let legacy = legacy_sse
1247        .map(|paths| {
1248            let mut router = crate::adapters::primary::transport::legacy_sse::LegacyRouter::new(
1249                config.clone(),
1250                Arc::new(SessionStore::new(ids)),
1251                // The transport belongs to `2024-11-05`; a client offering a
1252                // later revision is answered with this one and decides for
1253                // itself whether to continue.
1254                crate::adapters::primary::transport::legacy_sse::ServerDispatcher::new(
1255                    Arc::clone(&dispatcher),
1256                    ProtocolVersion::V2024_11_05,
1257                ),
1258            )
1259            .with_stream_path(paths.stream)
1260            .with_message_path(paths.message);
1261            if let Some(origin) = origin_policy.clone() {
1262                router = router.with_origins(origin);
1263            }
1264            if let Some(limiter) = rate_limiter.clone() {
1265                router = router.with_rate_limiter(limiter);
1266            }
1267            if let (Some(policy), Some(authenticator)) = (policy, authenticator.clone()) {
1268                router = router.with_auth(policy, authenticator);
1269            }
1270            router.into_router()
1271        })
1272        .transpose()?;
1273    let mut router = McpRouter::new(config, sessions, handler);
1274    if let Some(authenticator) = authenticator {
1275        router = router.with_authenticator(authenticator);
1276    }
1277    if let Some(policy) = policy {
1278        router = router.with_policy(policy);
1279    }
1280    if let Some(origin) = origin_policy {
1281        router = router.with_origin_policy(origin);
1282    }
1283    if let Some(limiter) = rate_limiter {
1284        router = router.with_rate_limiter(limiter);
1285    }
1286    let router = router.into_router().map_err(ServeError::from)?;
1287    #[cfg(feature = "legacy-sse")]
1288    let router = match legacy {
1289        Some(legacy) => router.merge(legacy),
1290        None => router,
1291    };
1292    Ok(BuilderOutput {
1293        router,
1294        addr,
1295        ceiling,
1296    })
1297}
1298
1299/// Test-only pass-through that builds a `BuilderOutput` from the four
1300/// arguments the original `routes` took, so the existing tests keep their
1301/// one-line call site while the production path goes through the
1302/// builder.
1303///
1304/// Not `#[cfg(test)]` because the tests live in this module and need
1305/// `pub(crate)`, but the function is documented and named so a future
1306/// reader does not confuse it with the production entry point.
1307#[cfg(test)]
1308#[allow(
1309    clippy::redundant_pub_crate,
1310    reason = "the function is test-only; `pub(crate)` is needed in test builds for the tests to reach it, but in lib builds nothing outside the crate can see it, and the lint does not have a per-cfg answer."
1311)]
1312pub(crate) fn test_routes(
1313    server: Server,
1314    ceiling: ProtocolVersion,
1315    config: Option<ServerConfig>,
1316    ids: Option<Ids>,
1317) -> Result<axum::Router, ServeError> {
1318    routes(HttpBuilder {
1319        server,
1320        addr: SocketAddr::from(([127, 0, 0, 1], 3000)),
1321        ceiling,
1322        config,
1323        ids,
1324        authenticator: None,
1325        policy: None,
1326        origin_policy: None,
1327        rate_limiter: None,
1328        #[cfg(feature = "legacy-sse")]
1329        legacy_sse: None,
1330    })
1331    .map(|parts| parts.router)
1332}
1333
1334/// Listens on `addr`, and reports what the OS actually gave.
1335///
1336/// Free rather than a method because [`HttpBuilder::serve`] has already moved
1337/// its own fields out by the time it calls this, and re-reading them off a
1338/// partially-consumed builder is how the two copies drift apart.
1339async fn bind(addr: SocketAddr) -> Result<(tokio::net::TcpListener, SocketAddr), ServeError> {
1340    let listener = tokio::net::TcpListener::bind(addr)
1341        .await
1342        .map_err(|source| ServeError::Bind { addr, source })?;
1343    let bound = listener
1344        .local_addr()
1345        .map_err(|source| ServeError::Bind { addr, source })?;
1346    Ok((listener, bound))
1347}
1348
1349/// The id source a builder uses when none was supplied.
1350///
1351/// [`RandomIds`] behind the `random-ids` feature, and an error without it. A
1352/// counter would be the obvious fallback and is the wrong one: a session id is
1353/// presented by the peer on every subsequent request, so a guessable sequence
1354/// is a guessable session. Failing is the honest answer for a lean build, and
1355/// it fails once at startup rather than per request.
1356#[cfg(feature = "random-ids")]
1357fn default_ids() -> Result<Ids, ServeError> {
1358    use crate::adapters::secondary::id_source::RandomIds;
1359    RandomIds::new()
1360        .map(|ids| Arc::new(ids) as Ids)
1361        .map_err(|error| ServeError::IdSource {
1362            reason: error.to_string(),
1363        })
1364}
1365
1366/// No id source is available in this build, and none was supplied.
1367#[cfg(not(feature = "random-ids"))]
1368fn default_ids() -> Result<Ids, ServeError> {
1369    Err(ServeError::NoIdSource)
1370}
1371
1372/// The newest revision this build can complete a handshake on.
1373///
1374/// The same fallback as the stdio builder's, and for the same reason: an empty
1375/// supported set leaves the ceiling a question rather than a wrong answer. Not
1376/// `latest_known()`, which is `2026-07-28` — the revision SEP-2575 removed
1377/// `initialize` from, and a server gated on it could never finish talking to
1378/// anybody.
1379fn default_ceiling() -> ProtocolVersion {
1380    SupportedVersions::all().map_or_else(
1381        |_| ProtocolVersion::latest_known(),
1382        |supported| supported.latest_handshake(),
1383    )
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388    use core::pin::Pin;
1389    use std::future::Future;
1390    use std::net::SocketAddr;
1391    use std::sync::Arc;
1392
1393    use axum::body::Body;
1394    use axum::http::{Request, StatusCode};
1395    use serde_json::Value;
1396    use tower::ServiceExt;
1397
1398    use super::{HttpBuilder, ServeError, default_ceiling, routes};
1399    use crate::adapters::primary::transport::http::router::RouterError;
1400    use crate::adapters::primary::transport::http::{
1401        Anonymous, AuthPolicy, Authenticator, Credentials, ServerConfig, Streaming,
1402    };
1403    use crate::application::server::{Server, ToolHandler};
1404    use crate::domain::ir::JsonObject;
1405    use crate::domain::registry::auth::ScopeSet;
1406    use crate::domain::registry::{AuthContext, Primitive, Registered, Scope, Tool};
1407
1408    /// Anything a test here can fail at, gathered so a test can `?` through
1409    /// unrelated error types without unwrapping.
1410    #[derive(Debug)]
1411    enum Failure {
1412        /// A test assumption about the crate's own defaults did not hold.
1413        Expectation(&'static str),
1414        /// Two values that should have matched and did not.
1415        ///
1416        /// Separate from [`Self::Expectation`] because "the media type was
1417        /// this, the media type should have been that" is the assertion this
1418        /// module could not previously make at all — the two values are the
1419        /// whole point of the test, and folding them into one message would
1420        /// mean printing only half of it.
1421        Mismatch(String, String),
1422        /// The transport refused something a test did not expect it to.
1423        Serve(ServeError),
1424        /// A request never produced a response.
1425        NoResponse,
1426    }
1427
1428    impl core::fmt::Display for Failure {
1429        fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
1430            match self {
1431                Self::Expectation(what) => write!(formatter, "expected {what}"),
1432                Self::Mismatch(found, wanted) => {
1433                    write!(formatter, "expected {wanted}, got {found}")
1434                }
1435                Self::Serve(error) => write!(formatter, "the transport refused: {error}"),
1436                Self::NoResponse => formatter.write_str("the request produced no response"),
1437            }
1438        }
1439    }
1440
1441    impl std::error::Error for Failure {}
1442
1443    impl From<ServeError> for Failure {
1444        fn from(error: ServeError) -> Self {
1445            Self::Serve(error)
1446        }
1447    }
1448
1449    /// A server with nothing on it, which is the smallest thing a consumer
1450    /// would actually serve.
1451    fn server() -> Server {
1452        Server::builder("test").build()
1453    }
1454
1455    /// The JSON-RPC document a response body carries.
1456    ///
1457    /// A `POST` to a sessioned route is answered with an event stream, so the
1458    /// document a test wants is one `data:` frame inside it — and for a route
1459    /// with no session there is no stream at all and the body is the document.
1460    /// Both are the same answer on the wire; reading only one of them is how a
1461    /// test that meant "the server replied correctly" ends up reporting "the
1462    /// server replied as `application/json`" instead.
1463    fn document_in(body: &[u8]) -> Result<Value, Failure> {
1464        let text = std::str::from_utf8(body)
1465            .map_err(|_| Failure::Expectation("the reply body to be UTF-8"))?;
1466        if let Ok(document) = serde_json::from_str::<Value>(text) {
1467            return Ok(document);
1468        }
1469        // The *last* frame, not the first. A sessioned `POST` is answered with
1470        // an event stream, and the frames in it are ordered as they happened —
1471        // so a tool that logs or reports progress puts its notification ahead
1472        // of its result, and taking the first frame answers the question with
1473        // the notification. The result is what closes the exchange, and it is
1474        // matched on its id rather than taken positionally: a `POST` that also
1475        // carries the answer to a server-initiated request has two documents
1476        // in it, and only one of them is this request's answer.
1477        let wanted = request_id_hint(text);
1478        let mut found = None;
1479        for frame in text.split("\n\n") {
1480            let Some(payload) = frame.lines().find_map(|line| line.strip_prefix("data: ")) else {
1481                continue;
1482            };
1483            let Ok(value) = serde_json::from_str::<Value>(payload) else {
1484                continue;
1485            };
1486            let answers = value
1487                .get("id")
1488                .is_some_and(|id| wanted.as_ref().is_none_or(|wanted| id == wanted));
1489            if answers {
1490                found = Some(value);
1491            }
1492        }
1493        if found.is_none() {
1494            // Nothing matched the id, so fall back to the last document rather
1495            // than reporting no reply at all — a test that cared about the id
1496            // would have said so.
1497            for frame in text.split("\n\n") {
1498                let Some(payload) = frame.lines().find_map(|line| line.strip_prefix("data: "))
1499                else {
1500                    continue;
1501                };
1502                if let Ok(value) = serde_json::from_str::<Value>(payload) {
1503                    found = Some(value);
1504                }
1505            }
1506        }
1507        found.ok_or(Failure::Expectation(
1508            "the reply to carry a JSON-RPC document",
1509        ))
1510    }
1511
1512    /// The request id a reply answers, read off the frames themselves.
1513    ///
1514    /// There is no other way to tell a `POST`'s own answer from the answer to a
1515    /// server-initiated request travelling in the same stream, and taking the
1516    /// wrong one turns a test about listing tools into a test about whatever
1517    /// the server last asked.
1518    fn request_id_hint(text: &str) -> Option<serde_json::Value> {
1519        let mut last = None;
1520        for frame in text.split("\n\n") {
1521            let Some(payload) = frame.lines().find_map(|line| line.strip_prefix("data: ")) else {
1522                continue;
1523            };
1524            if let Ok(value) = serde_json::from_str::<Value>(payload)
1525                && (value.get("result").is_some() || value.get("error").is_some())
1526            {
1527                last = value.get("id").cloned();
1528            }
1529        }
1530        last
1531    }
1532
1533    /// Ids a test can predict, so an assertion about a minted session can name
1534    /// the value it expects.
1535    fn test_ids() -> std::sync::Arc<dyn crate::application::session::IdSource + Send + Sync> {
1536        #[derive(Debug)]
1537        struct Counting(core::sync::atomic::AtomicU64);
1538
1539        impl crate::application::session::IdSource for Counting {
1540            fn next_session_id(&self) -> crate::domain::session::SessionId {
1541                crate::domain::session::SessionId::new(
1542                    self.0.fetch_add(1, core::sync::atomic::Ordering::SeqCst),
1543                )
1544            }
1545        }
1546
1547        std::sync::Arc::new(Counting(core::sync::atomic::AtomicU64::new(1)))
1548    }
1549
1550    /// The default ceiling is a revision a handshake can complete on.
1551    ///
1552    /// `latest_known()` is `2026-07-28`, the revision SEP-2575 removed
1553    /// `initialize` from. A server gated on it could never finish talking to
1554    /// anybody, and every request it refused would name a feature rather than
1555    /// the real cause.
1556    #[test]
1557    fn the_default_ceiling_has_a_handshake() -> Result<(), Failure> {
1558        let ceiling = default_ceiling();
1559        if !crate::domain::handshake_defined_in(ceiling) {
1560            return Err(Failure::Expectation(
1561                "the default ceiling to be a revision with an initialize",
1562            ));
1563        }
1564        Ok(())
1565    }
1566
1567    /// Binding on port `0` reports the port the OS chose, not `0`.
1568    ///
1569    /// The whole reason [`HttpBuilder::bind`] is separate from serving: a
1570    /// caller that asked for a free port needs the real one back to tell anyone
1571    /// where to connect.
1572    #[tokio::test]
1573    async fn binding_reports_the_address_the_os_chose() -> Result<(), Failure> {
1574        let (_, report) = HttpBuilder::new(server())
1575            .addr(SocketAddr::from(([127, 0, 0, 1], 0)))
1576            .bind()
1577            .await?;
1578        if report.addr.port() == 0 {
1579            return Err(Failure::Expectation("a real port back"));
1580        }
1581        if !report.addr.ip().is_loopback() {
1582            return Err(Failure::Expectation("a loopback address by default"));
1583        }
1584        Ok(())
1585    }
1586
1587    /// Binding an address already in use fails rather than quietly moving.
1588    ///
1589    /// Silently moving is the failure that hurts: a caller prints the address,
1590    /// a client connects to whatever is actually there, and the two do not
1591    /// match.
1592    #[tokio::test]
1593    async fn binding_a_taken_address_fails() -> Result<(), Failure> {
1594        let (listener, report) = HttpBuilder::new(server())
1595            .addr(SocketAddr::from(([127, 0, 0, 1], 0)))
1596            .bind()
1597            .await?;
1598        let second = HttpBuilder::new(server()).addr(report.addr).bind().await;
1599        // The held `listener` is what makes the address taken; drop it only
1600        // after the attempt so the port is still busy.
1601        drop(listener);
1602        match second {
1603            Err(ServeError::Bind { addr, .. }) => {
1604                if addr != report.addr {
1605                    return Err(Failure::Expectation("the refused address reported back"));
1606                }
1607                Ok(())
1608            }
1609            Err(other) => Err(Failure::Serve(other)),
1610            Ok(_) => Err(Failure::Expectation("a refusal rather than a second bind")),
1611        }
1612    }
1613
1614    /// `Streaming::Json` — the default — has to mean `application/json` on a
1615    /// *sessioned* POST, and it did not.
1616    ///
1617    /// Issue #20: `initialize` answered a bare document while every request
1618    /// after it answered an event stream, because the framing was decided by
1619    /// whether a session existed rather than by how the server was configured.
1620    /// `streaming()` was read in exactly one place, a log field. A consumer
1621    /// that never touched `Streaming` got `text/event-stream` and an
1622    /// `id: N\ndata: {...}` body where it expected a document.
1623    ///
1624    /// Both modes are asserted here rather than only the default, because the
1625    /// previous test (`the_streaming_mode_never_changes_the_get_route`) covers
1626    /// the GET route and would not have caught this: `Streaming` has never
1627    /// changed what the GET route does.
1628    #[tokio::test]
1629    async fn the_streaming_mode_decides_how_a_sessioned_post_is_framed() -> Result<(), Failure> {
1630        for (streaming, expected) in [
1631            (Streaming::Json, "application/json"),
1632            (Streaming::EventStream, "text/event-stream"),
1633        ] {
1634            let config = ServerConfig::new()
1635                .map_err(|_| Failure::Expectation("the config did not parse"))?
1636                .with_streaming(streaming);
1637            let router = crate::adapters::primary::transport::http::serve::test_routes(
1638                server(),
1639                default_ceiling(),
1640                Some(config),
1641                Some(test_ids()),
1642            )?;
1643
1644            let initialize = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#;
1645            let (status, minted, _) = post(&router, initialize, None, None).await?;
1646            if status != StatusCode::OK {
1647                return Err(Failure::Expectation("initialize was refused"));
1648            }
1649            let Some(session) = minted else {
1650                return Err(Failure::Expectation("no session was minted"));
1651            };
1652
1653            let (status, _, _) = post(
1654                &router,
1655                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
1656                None,
1657                Some(&session),
1658            )
1659            .await?;
1660            if status != StatusCode::OK {
1661                return Err(Failure::Expectation("a sessioned tools/list was refused"));
1662            }
1663
1664            let response = raw_post(
1665                &router,
1666                r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
1667                Some(&session),
1668            )
1669            .await?;
1670            let media = response
1671                .headers()
1672                .get("content-type")
1673                .and_then(|value| value.to_str().ok())
1674                .unwrap_or_default()
1675                .to_owned();
1676            if !media.starts_with(expected) {
1677                return Err(Failure::Mismatch(media, expected.to_owned()));
1678            }
1679        }
1680        Ok(())
1681    }
1682
1683    /// A tool that asks the peer a question cannot do it on a server that
1684    /// answers with one document, and it has to find out rather than hang.
1685    ///
1686    /// The property under test is that the call *returns*. Before this change
1687    /// a `Streaming::Json` server pushed the tool's question into a ring no one
1688    /// drains and waited on a channel that only the end of the session could
1689    /// close: a hang with no error and no timeout. Whatever the refusal turns
1690    /// out to be, the assertion is that there is one.
1691    ///
1692    /// The refusal's wording is pinned by `an_unreadable_exchange_names_the_setting`
1693    /// in the `exchange` module, which can reach the exchange directly. Here
1694    /// the capability gate answers first — this server declares no elicitation
1695    /// capability — so what arrives here is that gate's refusal, not the
1696    /// exchange's.
1697    #[tokio::test]
1698    async fn a_tool_that_asks_the_peer_is_answered_rather_than_left_waiting() -> Result<(), Failure>
1699    {
1700        let config = ServerConfig::new()
1701            .map_err(|_| Failure::Expectation("the config did not parse"))?
1702            .with_streaming(Streaming::Json);
1703        let router = crate::adapters::primary::transport::http::serve::test_routes(
1704            eliciting_server()?,
1705            default_ceiling(),
1706            Some(config),
1707            Some(test_ids()),
1708        )?;
1709
1710        let initialize = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#;
1711        let (status, minted, _) = post(&router, initialize, None, None).await?;
1712        if status != StatusCode::OK {
1713            return Err(Failure::Expectation("initialize was refused"));
1714        }
1715        let Some(session) = minted else {
1716            return Err(Failure::Expectation("no session was minted"));
1717        };
1718
1719        let (status, _, document) = post(
1720            &router,
1721            r#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"elicit","arguments":{}}}"#,
1722            None,
1723            Some(&session),
1724        )
1725        .await?;
1726        if status != StatusCode::OK {
1727            return Err(Failure::Expectation("the call was refused outright"));
1728        }
1729        if document.to_string().contains("reason") {
1730            return Ok(());
1731        }
1732        Err(Failure::Mismatch(
1733            document.to_string(),
1734            "an answer naming why the question could not be asked".to_owned(),
1735        ))
1736    }
1737
1738    /// `initialize` is answered, and mints a session the client can name.
1739    ///
1740    /// The assertion that matters most is the header. Without it a client that
1741    /// completed a handshake still cannot make a second request, and the
1742    /// failure surfaces as a `400` on a call the server would have served
1743    /// happily — which reads as a broken server rather than a missing header.
1744    #[tokio::test]
1745    async fn initialize_answers_and_mints_a_session() -> Result<(), Failure> {
1746        let router = crate::adapters::primary::transport::http::serve::test_routes(
1747            server(),
1748            default_ceiling(),
1749            None,
1750            Some(test_ids()),
1751        )?;
1752        let body = br#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#;
1753        let response = router
1754            .oneshot(
1755                Request::builder()
1756                    .method("POST")
1757                    .uri("/mcp")
1758                    .header("host", "localhost")
1759                    .header("content-type", "application/json")
1760                    .header("accept", "application/json, text/event-stream")
1761                    .body(Body::from(body.to_vec()))
1762                    .map_err(|_| Failure::Expectation("the request built"))?,
1763            )
1764            .await
1765            .map_err(|_| Failure::NoResponse)?;
1766
1767        if response.status() != StatusCode::OK {
1768            return Err(Failure::Expectation("a 200 for a well-formed initialize"));
1769        }
1770        if response.headers().get("mcp-session-id").is_none() {
1771            return Err(Failure::Expectation(
1772                "an mcp-session-id header naming the new session",
1773            ));
1774        }
1775        let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
1776            .await
1777            .map_err(|_| Failure::Expectation("the reply body to be readable"))?;
1778        let document = document_in(&bytes)?;
1779        if document.get("result").is_none() {
1780            return Err(Failure::Expectation("a result, not an error"));
1781        }
1782        Ok(())
1783    }
1784
1785    /// A stateful call naming no session is refused with a `400`.
1786    ///
1787    /// The `400` is the router's, and it is the right one: the peer asked a
1788    /// question belonging to a session and did not say which. Answering it as
1789    /// a protocol error would answer a different question, and a client would
1790    /// retry it the same way forever.
1791    #[tokio::test]
1792    async fn a_stateful_call_with_no_session_is_refused() -> Result<(), Failure> {
1793        let router = crate::adapters::primary::transport::http::serve::test_routes(
1794            server(),
1795            default_ceiling(),
1796            None,
1797            Some(test_ids()),
1798        )?;
1799        let body = r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#;
1800        let response = router
1801            .oneshot(
1802                Request::builder()
1803                    .method("POST")
1804                    .uri("/mcp")
1805                    .header("host", "localhost")
1806                    .header("content-type", "application/json")
1807                    .header("accept", "application/json, text/event-stream")
1808                    .body(Body::from(body.as_bytes().to_vec()))
1809                    .map_err(|_| Failure::Expectation("the request built"))?,
1810            )
1811            .await
1812            .map_err(|_| Failure::NoResponse)?;
1813        if response.status() != StatusCode::BAD_REQUEST {
1814            return Err(Failure::Expectation(
1815                "a 400 for a stateful call with no session header",
1816            ));
1817        }
1818        Ok(())
1819    }
1820
1821    /// A body that is not a JSON-RPC message is refused, not dispatched.
1822    ///
1823    /// The distinction that matters: a peer sending nonsense is a `400`, while
1824    /// a peer sending a well-formed request this server cannot serve is a
1825    /// `200` carrying a JSON-RPC error. Collapsing the two makes a client
1826    /// retry a request that will never succeed.
1827    #[tokio::test]
1828    async fn a_body_that_is_not_jsonrpc_is_refused() -> Result<(), Failure> {
1829        let router = crate::adapters::primary::transport::http::serve::test_routes(
1830            server(),
1831            default_ceiling(),
1832            None,
1833            Some(test_ids()),
1834        )?;
1835        let response = router
1836            .oneshot(
1837                Request::builder()
1838                    .method("POST")
1839                    .uri("/mcp")
1840                    .header("host", "localhost")
1841                    .header("content-type", "application/json")
1842                    .header("accept", "application/json, text/event-stream")
1843                    .body(Body::from("{not json"))
1844                    .map_err(|_| Failure::Expectation("the request built"))?,
1845            )
1846            .await
1847            .map_err(|_| Failure::NoResponse)?;
1848        if response.status() != StatusCode::BAD_REQUEST {
1849            return Err(Failure::Expectation(
1850                "a 400 for a body that is not a message",
1851            ));
1852        }
1853        Ok(())
1854    }
1855
1856    // -----------------------------------------------------------------
1857    // Issue #10: the builder can express the documented builder path —
1858    // a deployment that wires an authenticator and a `Require` policy
1859    // does not have to drop `HttpBuilder` and hand-assemble the
1860    // router. The pass-throughs land in `routes` so the assembly path
1861    // is single, not two.
1862    // -----------------------------------------------------------------
1863
1864    /// The new builder methods do not have to be called — the default
1865    /// builder still builds. This is the path every consumer that
1866    /// has not wired auth takes, and changing the default would be a
1867    /// silent breaking change.
1868    #[tokio::test]
1869    async fn the_default_builder_still_mounts() -> Result<(), Failure> {
1870        let parts = routes(HttpBuilder::new(server()))?;
1871        // Mounted. The test passes if `routes` returns `Ok`.
1872        let _ = parts.router;
1873        Ok(())
1874    }
1875
1876    /// A builder with `with_authenticator` + `with_policy(Require)` is
1877    /// the safe combination (issue #11): a deployment that wants a
1878    /// credential can now say so on the builder, without dropping to
1879    /// the hand-assembled router.
1880    #[tokio::test]
1881    async fn builder_authenticator_with_require_policy_mounts() -> Result<(), Failure> {
1882        let parts = routes(
1883            HttpBuilder::new(server())
1884                .with_authenticator(Arc::new(Anonymous))
1885                .with_policy(AuthPolicy::Require(ScopeSet::all())),
1886        )?;
1887        let _ = parts.router;
1888        Ok(())
1889    }
1890
1891    /// The unsafe combination (issue #11) is now caught at the builder
1892    /// too: `with_authenticator` without `with_policy` returns
1893    /// `Routes` at build time rather than serving a publicly-readable
1894    /// endpoint.
1895    #[tokio::test]
1896    async fn builder_authenticator_without_policy_fails_closed() -> Result<(), Failure> {
1897        match routes(HttpBuilder::new(server()).with_authenticator(Arc::new(Anonymous))) {
1898            Err(ServeError::Routes(RouterError::UnprotectedAuthenticator)) => Ok(()),
1899            Err(other) => Err(Failure::Serve(other)),
1900            Ok(_) => Err(Failure::Expectation("the unsafe combination was accepted")),
1901        }
1902    }
1903
1904    // -----------------------------------------------------------------
1905    // Issue #19: the principal the router authenticated now reaches the
1906    // handler. Every visibility gate reads `ctx.peer().auth()`, and the
1907    // HTTP transport was building that peer from the session rather than
1908    // from the request — so an authenticated caller was answered as
1909    // anonymous, and every scope-gated primitive was invisible to the very
1910    // caller entitled to it.
1911    // -----------------------------------------------------------------
1912
1913    /// The one tool the servers in this section publish.
1914    const GATED: &str = "gated";
1915
1916    /// A server whose only tool tries to elicit from the peer.
1917    ///
1918    /// The tool is the point: without a handler that asks a question and
1919    /// waits, there is nothing for the unreadable-exchange refusal to refuse,
1920    /// and the test could not tell "the exchange was refused" from "no tool
1921    /// was called".
1922    fn eliciting_server() -> Result<Server, Failure> {
1923        const ELICIT: &str = "elicit";
1924        let tool = Tool::new(ELICIT, r#"{"type":"object"}"#)
1925            .map_err(|_| Failure::Expectation("a tool schema the crate accepts"))?;
1926        // The refusal is carried back as the tool's text rather than raised as
1927        // a JSON-RPC error, because a tool that could not ask its question is
1928        // still a tool that ran and answered — and the point of the test is
1929        // that the caller finds out, with a reason, rather than hanging.
1930        // `"{}"` is the one string `JsonObject::new` cannot refuse, so the empty
1931        // object is built once here and cloned per call rather than being an
1932        // `unwrap` on a fact about the type.
1933        let empty =
1934            JsonObject::new("{}").map_err(|_| Failure::Expectation("an empty object parses"))?;
1935        let handler: ToolHandler = Arc::new(move |context, _arguments| {
1936            let empty = empty.clone();
1937            Box::pin(async move {
1938                match context.elicit(&empty).await {
1939                    Ok(answer) => answer,
1940                    // Carried back as the tool's own result rather than raised
1941                    // as a JSON-RPC error: a tool that could not ask its
1942                    // question still ran and still answered, and what is under
1943                    // test is that the caller finds out with a reason instead
1944                    // of hanging.
1945                    Err(error) => JsonObject::new(format!("{{\"reason\":\"{error:?}\"}}"))
1946                        .unwrap_or_else(|_| empty.clone()),
1947                }
1948            })
1949        });
1950        Ok(Server::builder("elicitation-on-json")
1951            .restricted_tool(Registered::from(Primitive::from(tool)), handler)
1952            .build())
1953    }
1954
1955    /// A server whose only tool is invisible without the `Read` scope.
1956    ///
1957    /// The scope is the assertion's teeth. An ungated tool is visible to
1958    /// everyone, so a test built on one cannot tell an authenticated caller
1959    /// from an anonymous one and would pass either way.
1960    fn gated_server() -> Result<Server, Failure> {
1961        let tool = Tool::new(GATED, r#"{"type":"object"}"#)
1962            .map_err(|_| Failure::Expectation("a tool schema the crate accepts"))?;
1963        let handler: ToolHandler =
1964            Arc::new(|_context, arguments| Box::pin(async move { arguments }));
1965        Ok(Server::builder("auth-propagation")
1966            .restricted_tool(
1967                Registered::from(Primitive::from(tool)).requiring_scope(Scope::Read),
1968                handler,
1969            )
1970            .build())
1971    }
1972
1973    /// `Bearer alice` authenticates as a principal holding `Read`.
1974    ///
1975    /// Every other credential — including none at all — authenticates as a
1976    /// *named* principal holding nothing. The naming is load-bearing: an
1977    /// anonymous context is indistinguishable from a peer the transport never
1978    /// authenticated, which is the state this section exists to rule out.
1979    /// The token `Tokens` recognises, named once so the tests and the
1980    /// authenticator cannot drift apart.
1981    const TOKEN_READ: &str = "tok-read";
1982
1983    #[derive(Debug, Clone, Copy)]
1984    struct Tokens;
1985
1986    impl Authenticator for Tokens {
1987        fn authenticate<'a>(
1988            &'a self,
1989            presented: &'a Credentials<'_>,
1990        ) -> Pin<Box<dyn Future<Output = AuthContext> + Send + 'a>> {
1991            let holds_read = presented
1992                .authorization()
1993                .is_some_and(|presented| presented == TOKEN_READ);
1994            Box::pin(std::future::ready(if holds_read {
1995                AuthContext::new("alice", ScopeSet::empty().with(Scope::Read))
1996            } else {
1997                AuthContext::new("bob", ScopeSet::empty())
1998            }))
1999        }
2000    }
2001
2002    /// The router these tests drive.
2003    ///
2004    /// `Require(ScopeSet::empty())` rather than `Public`, for two reasons. An
2005    /// endpoint with an authenticator wired under a `Public` policy is refused
2006    /// at mount time, and an empty requirement is the only policy that admits
2007    /// a *named* caller holding nothing. So both callers get past the endpoint
2008    /// policy and differ only at the visibility gate, which is the claim under
2009    /// test — a `403` here would prove nothing about the gate.
2010    fn auth_routes() -> Result<axum::Router, Failure> {
2011        Ok(routes(
2012            HttpBuilder::new(gated_server()?)
2013                .ids(test_ids())
2014                .with_authenticator(Arc::new(Tokens))
2015                .with_policy(AuthPolicy::Require(ScopeSet::empty())),
2016        )?
2017        .router)
2018    }
2019
2020    /// One POST to `/mcp`, with the credential and session the test chose.
2021    ///
2022    /// Reports the status, the minted session id if one was minted, and the
2023    /// decoded reply — all read off the response rather than off a value both
2024    /// sides already agreed on.
2025    /// One POST, returned undecoded.
2026    ///
2027    /// [`post`] reads the body into a document, which is the right shape for
2028    /// every assertion except the one this file could not make: a
2029    /// `text/event-stream` body is not a document, so a helper that decoded
2030    /// it would fail before the media type was ever read, and the test that
2031    /// needs to see the header needs the response untouched.
2032    async fn raw_post(
2033        router: &axum::Router,
2034        body: &str,
2035        session: Option<&str>,
2036    ) -> Result<axum::response::Response, Failure> {
2037        let mut request = Request::builder()
2038            .method("POST")
2039            .uri("/mcp")
2040            .header("host", "localhost")
2041            .header("content-type", "application/json")
2042            .header("accept", "application/json, text/event-stream");
2043        if let Some(session) = session {
2044            request = request.header("mcp-session-id", session);
2045        }
2046        let built = request
2047            .body(Body::from(body.as_bytes().to_vec()))
2048            .map_err(|_| Failure::Expectation("the request built"))?;
2049        router
2050            .clone()
2051            .oneshot(built)
2052            .await
2053            .map_err(|_| Failure::NoResponse)
2054    }
2055
2056    async fn post(
2057        router: &axum::Router,
2058        body: &str,
2059        credential: Option<&str>,
2060        session: Option<&str>,
2061    ) -> Result<(StatusCode, Option<String>, Value), Failure> {
2062        let mut request = Request::builder()
2063            .method("POST")
2064            .uri("/mcp")
2065            .header("host", "localhost")
2066            .header("content-type", "application/json")
2067            .header("accept", "application/json, text/event-stream");
2068        if let Some(credential) = credential {
2069            request = request.header("authorization", credential);
2070        }
2071        if let Some(session) = session {
2072            request = request.header("mcp-session-id", session);
2073        }
2074        let built = request
2075            .body(Body::from(body.as_bytes().to_vec()))
2076            .map_err(|_| Failure::Expectation("the request built"))?;
2077        let response = router
2078            .clone()
2079            .oneshot(built)
2080            .await
2081            .map_err(|_| Failure::NoResponse)?;
2082        let status = response.status();
2083        let minted = response
2084            .headers()
2085            .get("mcp-session-id")
2086            .and_then(|value| value.to_str().ok())
2087            .map(ToOwned::to_owned);
2088        let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
2089            .await
2090            .map_err(|_| Failure::Expectation("the reply body to be readable"))?;
2091        let document = document_in(&bytes)?;
2092        Ok((status, minted, document))
2093    }
2094
2095    /// The tool names a `tools/list` reply offers.
2096    fn listed(document: &Value) -> Result<Vec<String>, Failure> {
2097        let tools = document
2098            .get("result")
2099            .and_then(|result| result.get("tools"))
2100            .and_then(Value::as_array)
2101            .ok_or(Failure::Expectation("a tools array in the result"))?;
2102        Ok(tools
2103            .iter()
2104            .filter_map(|tool| tool.get("name").and_then(Value::as_str))
2105            .map(ToOwned::to_owned)
2106            .collect())
2107    }
2108
2109    /// The handshake, as the caller holding `Read`, naming the new session.
2110    async fn open_session(router: &axum::Router) -> Result<String, Failure> {
2111        let (status, minted, _) = post(
2112            router,
2113            r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#,
2114            Some("tok-read"),
2115            None,
2116        )
2117        .await?;
2118        if status != StatusCode::OK {
2119            return Err(Failure::Expectation("a 200 for a well-formed initialize"));
2120        }
2121        minted.ok_or(Failure::Expectation(
2122            "an mcp-session-id naming the new session",
2123        ))
2124    }
2125
2126    /// An authenticated caller sees what its scopes earn.
2127    ///
2128    /// The claim issue #19 was filed on: the peer the listing is filtered
2129    /// against is built from the request, so the principal that authenticated
2130    /// this request is the one the gate reads.
2131    #[tokio::test]
2132    async fn an_authenticated_caller_sees_what_its_scopes_earn() -> Result<(), Failure> {
2133        let router = auth_routes()?;
2134        let session = open_session(&router).await?;
2135
2136        let (status, _, reply) = post(
2137            &router,
2138            r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
2139            Some("tok-read"),
2140            Some(&session),
2141        )
2142        .await?;
2143        if status != StatusCode::OK {
2144            return Err(Failure::Expectation("a 200 for tools/list"));
2145        }
2146        if !listed(&reply)?.iter().any(|name| name == GATED) {
2147            return Err(Failure::Expectation(
2148                "the Read-gated tool listed for a caller holding Read",
2149            ));
2150        }
2151        Ok(())
2152    }
2153
2154    /// The principal is read from the request, so two requests on one session
2155    /// are filtered independently.
2156    ///
2157    /// Both calls here are the same method on the same session, opened by the
2158    /// caller holding `Read`, and they are answered differently: the second is
2159    /// filtered against the credential it presented rather than the one that
2160    /// opened the session. That is the whole of the lifetime claim as a caller
2161    /// experiences it, and it is what fails if the peer's principal comes from
2162    /// session state instead of from the request.
2163    ///
2164    /// The pairing is what makes it a test rather than a demonstration — one
2165    /// caller holding the scope and one holding nothing, against one session —
2166    /// because a gate that answered both the same way passes a test that ever
2167    /// only used one caller.
2168    /// A streamed `tools/list` is filtered against the caller's own credential.
2169    ///
2170    /// A sessioned `POST` is answered with an event stream, and its handler
2171    /// runs *beside* that stream rather than inside it — so the handler is
2172    /// given a peer rebuilt from the session. The principal is the
2173    /// *request's*, resolved by the router from the credentials this one
2174    /// presented, so a rebuild that takes the session's peer instead answers an
2175    /// authenticated call as though nobody had authenticated, and the gated
2176    /// primitive becomes invisible to the caller who earned it.
2177    ///
2178    /// Asserted on the listing rather than the call on purpose. A hidden tool
2179    /// answers a `tools/call` with the same `no such tool` as a tool that was
2180    /// never registered, deliberately — the difference would let a client probe
2181    /// for what it may not see. The listing is the only place the gate is
2182    /// visible, which makes it the only place this can be caught.
2183    #[tokio::test]
2184    async fn a_streamed_listing_keeps_the_callers_principal() -> Result<(), Failure> {
2185        let router = auth_routes()?;
2186        let session = open_session(&router).await?;
2187
2188        let (status, _, held) = post(
2189            &router,
2190            r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
2191            Some("tok-read"),
2192            Some(&session),
2193        )
2194        .await?;
2195        if status != StatusCode::OK {
2196            return Err(Failure::Expectation("a 200 for tools/list"));
2197        }
2198        if !listed(&held)?.iter().any(|name| name == GATED) {
2199            return Err(Failure::Expectation(
2200                "the streamed listing to carry the gate's principal",
2201            ));
2202        }
2203
2204        // The same request with no credential must not see it. If both answers
2205        // were the same, the principal was never consulted and the check above
2206        // would have passed for the wrong reason.
2207        let (_, _, anonymous) = post(
2208            &router,
2209            r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#,
2210            None,
2211            Some(&session),
2212        )
2213        .await?;
2214        if listed(&anonymous)?.iter().any(|name| name == GATED) {
2215            return Err(Failure::Expectation(
2216                "the streamed listing to hide the gate from an anonymous caller",
2217            ));
2218        }
2219        Ok(())
2220    }
2221
2222    #[tokio::test]
2223    async fn a_later_request_is_filtered_against_its_own_credential() -> Result<(), Failure> {
2224        let router = auth_routes()?;
2225        let session = open_session(&router).await?;
2226
2227        let (status, _, held) = post(
2228            &router,
2229            r#"{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}"#,
2230            Some("tok-read"),
2231            Some(&session),
2232        )
2233        .await?;
2234        if status != StatusCode::OK {
2235            return Err(Failure::Expectation("a 200 for tools/list"));
2236        }
2237        let (status, _, unheld) = post(
2238            &router,
2239            r#"{"jsonrpc":"2.0","id":3,"method":"tools/list","params":{}}"#,
2240            Some("tok-anon"),
2241            Some(&session),
2242        )
2243        .await?;
2244        if status != StatusCode::OK {
2245            return Err(Failure::Expectation("a 200 for tools/list"));
2246        }
2247
2248        if !listed(&held)?.iter().any(|name| name == GATED) {
2249            return Err(Failure::Expectation(
2250                "the Read-gated tool listed for a caller holding Read",
2251            ));
2252        }
2253        if listed(&unheld)?.iter().any(|name| name == GATED) {
2254            return Err(Failure::Expectation(
2255                "the Read-gated tool hidden from a caller holding nothing, on a session a caller who did hold it opened",
2256            ));
2257        }
2258        Ok(())
2259    }
2260}