Skip to main content

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

1//! Assembling the axum `Router`.
2//!
3//! [`HttpBuilder`](super::HttpBuilder) collects the four decisions a socket
4//! server needs — a session store, an id source, the handler, the ceiling —
5//! and [`routes`] is the part that turns them into axum routes. It lives apart
6//! from the handler because it is a different kind of code: everything here is
7//! wiring, and everything in the parent module is a decision about a request.
8//!
9//! Two of the defaults here are `cfg`-gated on `random-ids`. A session id is
10//! presented by the peer on every subsequent request, so a guessable sequence
11//! is a guessable session; a lean build that cannot mint one gets an error at
12//! startup rather than a counter.
13
14use std::net::SocketAddr;
15use std::sync::Arc;
16
17use super::{HttpHandler, HttpSession, Ids};
18use crate::adapters::primary::transport::http::config::ServerConfig;
19use crate::adapters::primary::transport::http::limits::RateLimiter;
20use crate::adapters::primary::transport::http::router::McpRouter;
21use crate::adapters::primary::transport::http::session_store::SessionStore;
22use crate::adapters::primary::transport::http::{AuthPolicy, Authenticator};
23use crate::adapters::primary::transport::origin::OriginPolicy;
24use crate::application::server::Server;
25use crate::application::server::dispatch::Dispatcher;
26use crate::domain::{ProtocolVersion, SupportedVersions};
27
28/// The builder behind the server transport.
29///
30/// Every method is optional and every default is safe. The one decision this
31/// builder does not make for the caller is which address to bind, and it
32/// defaults that to loopback rather than to every interface.
33pub struct HttpBuilder {
34    /// The server whose tools and resources are served.
35    server: Server,
36    /// The address to listen on.
37    addr: SocketAddr,
38    /// The revision pre-handshake sessions are gated with.
39    ceiling: ProtocolVersion,
40    /// The endpoint policy and framing limits.
41    ///
42    /// Not defaulted here: [`ServerConfig::new`] parses the specification's
43    /// endpoint and can fail, and a builder that cannot fail is worth more than
44    /// one that resolves a constant early. An absent value is the crate's own
45    /// defaults, resolved in [`serve`](Self::serve).
46    config: Option<ServerConfig>,
47    /// Where session ids come from.
48    ids: Option<Ids>,
49    /// The authenticator the router is built with.
50    ///
51    /// Absent means the router's default `Anonymous` authenticator; the
52    /// `with_authenticator` setter records the choice so the route builder
53    /// can pair it with the policy in the right order. See
54    /// `McpRouter::with_authenticator` for the issue #11 fail-closed
55    /// contract this is the consumer entry point for.
56    authenticator: Option<Arc<dyn Authenticator + Send + Sync>>,
57    /// The auth policy the router is built with.
58    policy: Option<AuthPolicy>,
59    /// The `Origin` allow-list the router enforces.
60    origin_policy: Option<OriginPolicy>,
61    /// The per-subject request allowance.
62    rate_limiter: Option<RateLimiter>,
63    /// Where the `2024-11-05` HTTP+SSE routes are mounted, when they are.
64    #[cfg(feature = "legacy-sse")]
65    legacy_sse: Option<LegacySsePaths>,
66}
67
68/// The two paths the legacy transport is mounted on.
69#[cfg(feature = "legacy-sse")]
70#[derive(Debug, Clone)]
71struct LegacySsePaths {
72    stream: String,
73    message: String,
74}
75
76impl core::fmt::Debug for HttpBuilder {
77    /// Prints what was configured and nothing else.
78    ///
79    /// Handwritten for the same reason as [`HttpHandler`]'s: a derived `Debug`
80    /// would walk the [`Server`] and the id source, and neither belongs in a
81    /// log line. The address and the ceiling are the two facts worth having
82    /// when a serve has gone wrong.
83    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
84        formatter
85            .debug_struct("HttpBuilder")
86            .field("addr", &self.addr)
87            .field("ceiling", &self.ceiling)
88            .field("config", &self.config)
89            .field("ids_supplied", &self.ids.is_some())
90            .finish_non_exhaustive()
91    }
92}
93
94impl HttpBuilder {
95    /// Serves `server`, on loopback unless told otherwise.
96    ///
97    /// The defaults are the crate's own strict ones rather than the permissive
98    /// ones, so a builder that sets nothing is already safe: loopback, the
99    /// newest handshake-capable revision, and a session id from the system
100    /// entropy pool.
101    #[must_use]
102    pub fn new(server: Server) -> Self {
103        Self {
104            server,
105            addr: SocketAddr::from(([127, 0, 0, 1], 3000)),
106            ceiling: default_ceiling(),
107            config: None,
108            ids: None,
109            authenticator: None,
110            policy: None,
111            origin_policy: None,
112            rate_limiter: None,
113            #[cfg(feature = "legacy-sse")]
114            legacy_sse: None,
115        }
116    }
117
118    /// Also serves the `2024-11-05` HTTP+SSE transport, beside Streamable HTTP.
119    ///
120    /// A client opens a stream with `GET /sse`, is told where to `POST` by the
121    /// `endpoint` event, and speaks `2024-11-05` over the pair. The same
122    /// tools, resources and prompts answer on both transports, and the
123    /// authenticator, `Origin` policy and rate limit set on this builder apply
124    /// to both. Use [`legacy_sse_at`](Self::legacy_sse_at) to move the paths.
125    #[cfg(feature = "legacy-sse")]
126    #[must_use]
127    pub fn legacy_sse(self) -> Self {
128        self.legacy_sse_at(
129            crate::adapters::primary::transport::legacy_sse::DEFAULT_STREAM_PATH,
130            crate::adapters::primary::transport::legacy_sse::DEFAULT_MESSAGE_PATH,
131        )
132    }
133
134    /// [`legacy_sse`](Self::legacy_sse), on `stream` and `message` rather than
135    /// `/sse` and `/messages`.
136    ///
137    /// Both must be absolute paths with no query; [`serve`](Self::serve)
138    /// refuses to start otherwise.
139    #[cfg(feature = "legacy-sse")]
140    #[must_use]
141    pub fn legacy_sse_at(mut self, stream: impl Into<String>, message: impl Into<String>) -> Self {
142        self.legacy_sse = Some(LegacySsePaths {
143            stream: stream.into(),
144            message: message.into(),
145        });
146        self
147    }
148
149    /// The address to bind. Defaults to `127.0.0.1:3000`.
150    ///
151    /// A non-loopback address is permitted but not made safe: this server
152    /// speaks plain HTTP, so whatever is in front of it has to terminate TLS
153    /// for it to be exposed to a network rather than to a machine.
154    #[must_use]
155    pub const fn addr(mut self, addr: SocketAddr) -> Self {
156        self.addr = addr;
157        self
158    }
159
160    /// The revision to gate pre-handshake sessions with.
161    #[must_use]
162    pub const fn version(mut self, version: ProtocolVersion) -> Self {
163        self.ceiling = version;
164        self
165    }
166
167    /// The endpoint policy and framing limits.
168    #[must_use]
169    pub fn config(mut self, config: ServerConfig) -> Self {
170        self.config = Some(config);
171        self
172    }
173
174    /// Where session ids come from.
175    ///
176    /// Defaults to a counter from this crate's own source. Supply
177    /// [`RandomIds`](crate::adapters::secondary::id_source::RandomIds) — behind
178    /// the `random-ids` feature — for an id a peer cannot guess, which is the
179    /// right choice anywhere a session id authorises anything.
180    #[must_use]
181    pub fn ids(mut self, ids: Ids) -> Self {
182        self.ids = Some(ids);
183        self
184    }
185
186    /// Replaces the anonymous default with a real implementation.
187    ///
188    /// Pair with [`with_policy`](Self::with_policy); a wired authenticator
189    /// under the default `AuthPolicy::Public` is the silent fail-open the
190    /// router's `into_router` refuses at mount time. The same contract
191    /// applies here, surfaced one layer up: a deployment that sets this
192    /// without `with_policy` will see the misconfiguration at
193    /// [`serve`](Self::serve) rather than at the first request.
194    #[must_use]
195    pub fn with_authenticator(
196        mut self,
197        authenticator: Arc<dyn Authenticator + Send + Sync>,
198    ) -> Self {
199        self.authenticator = Some(authenticator);
200        self
201    }
202
203    /// Replaces the default `AuthPolicy::Public` policy.
204    ///
205    /// A deployment that wired an authenticator above must also set
206    /// `Require(scopes)` here, or `into_router` will refuse to build the
207    /// route table. See `McpRouter::with_policy` for the matching
208    /// contract.
209    #[must_use]
210    pub const fn with_policy(mut self, policy: AuthPolicy) -> Self {
211        self.policy = Some(policy);
212        self
213    }
214
215    /// Replaces the default `Origin` allow-list.
216    ///
217    /// The default is `OriginPolicy::strict(None)`, which refuses every
218    /// `Origin` header sent and allows a request that sent none — see
219    /// `OriginPolicy::check` for why absent is not the same as foreign.
220    /// A deployment serving a browser over a non-loopback address will
221    /// need to widen this to the page's origin.
222    #[must_use]
223    pub fn with_origin_policy(mut self, origin: OriginPolicy) -> Self {
224        self.origin_policy = Some(origin);
225        self
226    }
227
228    /// Replaces the per-subject request allowance.
229    ///
230    /// Defaults to `ServerConfig::rate_limit()`, so a configuration that
231    /// set one does not have to set it twice.
232    #[must_use]
233    pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
234        self.rate_limiter = Some(limiter);
235        self
236    }
237
238    /// Binds the listener without serving it.
239    ///
240    /// Split from serving because a caller terminating TLS itself needs the
241    /// listener before anything is served on it, and because binding before
242    /// accepting removes the window in which a connection is refused for a port
243    /// this process is about to take.
244    ///
245    /// # Errors
246    ///
247    /// [`ServeError::Bind`] when the address is taken or unusable.
248    pub async fn bind(self) -> Result<(tokio::net::TcpListener, ServeReport), ServeError> {
249        let (listener, bound) = bind(self.addr).await?;
250        let report = ServeReport {
251            addr: bound,
252            ceiling: self.ceiling,
253        };
254        Ok((listener, report))
255    }
256
257    /// Binds and serves until the accept loop fails.
258    ///
259    /// # Errors
260    ///
261    /// [`ServeError`] from building the route table, from binding, or from the
262    /// accept loop itself. A protocol error is not one of those: a peer whose
263    /// request this server cannot serve is answered on the wire rather than
264    /// raised here.
265    pub async fn serve(self) -> Result<ServeReport, ServeError> {
266        let parts = routes(self)?;
267        let (listener, bound) = bind(parts.addr).await?;
268        axum::serve(listener, parts.router)
269            .await
270            .map_err(ServeError::Accept)?;
271        Ok(ServeReport {
272            addr: bound,
273            ceiling: parts.ceiling,
274        })
275    }
276
277    /// Serves on a listener the caller already bound.
278    ///
279    /// The other half of [`bind`](Self::bind), and the shape a deployment with
280    /// TLS in front needs: bind, wrap the listener in a TLS acceptor, and serve
281    /// on whatever that acceptor produces. Binding inside `serve` would make
282    /// this impossible, because there would be no moment at which the raw
283    /// listener was in the caller's hands.
284    ///
285    /// # Errors
286    ///
287    /// `ServeError::Routes` when the route table cannot be built, and
288    /// [`ServeError::Accept`] when the loop does.
289    pub async fn serve_on(
290        self,
291        listener: tokio::net::TcpListener,
292    ) -> Result<ServeReport, ServeError> {
293        let parts = routes(self)?;
294        let bound = listener.local_addr().map_err(|source| ServeError::Bind {
295            addr: parts.addr,
296            source,
297        })?;
298        axum::serve(listener, parts.router)
299            .await
300            .map_err(ServeError::Accept)?;
301        Ok(ServeReport {
302            addr: bound,
303            ceiling: parts.ceiling,
304        })
305    }
306}
307
308/// What `routes` returns: the assembled router and the two builder fields
309/// the calling method still needs after the table is built.
310#[derive(Debug)]
311pub(super) struct BuilderOutput {
312    /// The mounted `axum::Router`.
313    pub(super) router: axum::Router,
314    /// The address the caller is binding. Preserved for `ServeReport` and
315    /// for the `Bind` error path.
316    pub(super) addr: SocketAddr,
317    /// The pre-handshake revision. Preserved for `ServeReport`.
318    pub(super) ceiling: ProtocolVersion,
319}
320
321/// The route table this builder describes, and nothing that binds a socket.
322///
323/// Free so that both [`HttpBuilder::serve`] and [`HttpBuilder::serve_on`] build
324/// the same table from the same parts. A second implementation here would be a
325/// second answer to "what routes does this server have", and the two would
326/// differ the first time either was edited.
327pub(super) fn routes(builder: HttpBuilder) -> Result<BuilderOutput, ServeError> {
328    let HttpBuilder {
329        server,
330        addr,
331        ceiling,
332        config,
333        ids,
334        authenticator,
335        policy,
336        origin_policy,
337        rate_limiter,
338        #[cfg(feature = "legacy-sse")]
339        legacy_sse,
340    } = builder;
341    let ids = match ids {
342        Some(ids) => ids,
343        None => default_ids()?,
344    };
345    #[cfg(feature = "random-ids")]
346    let server = {
347        let mut server = server;
348        server.ensure_request_state_key(crate::adapters::secondary::id_source::random_state_key);
349        server
350    };
351    let config = match config {
352        Some(config) => config,
353        None => ServerConfig::new()?,
354    };
355    let dispatcher = Arc::new(Dispatcher::new(Arc::new(server)));
356    let sessions = Arc::new(SessionStore::<HttpSession>::new(Arc::clone(&ids)));
357    let handler = HttpHandler::new(Arc::clone(&dispatcher), Arc::clone(&sessions), ceiling);
358    #[cfg(feature = "legacy-sse")]
359    let legacy = legacy_sse
360        .map(|paths| {
361            let mut router = crate::adapters::primary::transport::legacy_sse::LegacyRouter::new(
362                config.clone(),
363                Arc::new(SessionStore::new(ids)),
364                // The transport belongs to `2024-11-05`; a client offering a
365                // later revision is answered with this one and decides for
366                // itself whether to continue.
367                crate::adapters::primary::transport::legacy_sse::ServerDispatcher::new(
368                    Arc::clone(&dispatcher),
369                    ProtocolVersion::V2024_11_05,
370                ),
371            )
372            .with_stream_path(paths.stream)
373            .with_message_path(paths.message);
374            if let Some(origin) = origin_policy.clone() {
375                router = router.with_origins(origin);
376            }
377            if let Some(limiter) = rate_limiter.clone() {
378                router = router.with_rate_limiter(limiter);
379            }
380            if let (Some(policy), Some(authenticator)) = (policy, authenticator.clone()) {
381                router = router.with_auth(policy, authenticator);
382            }
383            router.into_router()
384        })
385        .transpose()?;
386    let mut router = McpRouter::new(config, sessions, handler);
387    if let Some(authenticator) = authenticator {
388        router = router.with_authenticator(authenticator);
389    }
390    if let Some(policy) = policy {
391        router = router.with_policy(policy);
392    }
393    if let Some(origin) = origin_policy {
394        router = router.with_origin_policy(origin);
395    }
396    if let Some(limiter) = rate_limiter {
397        router = router.with_rate_limiter(limiter);
398    }
399    let router = router.into_router().map_err(ServeError::from)?;
400    #[cfg(feature = "legacy-sse")]
401    let router = match legacy {
402        Some(legacy) => router.merge(legacy),
403        None => router,
404    };
405    Ok(BuilderOutput {
406        router,
407        addr,
408        ceiling,
409    })
410}
411
412/// Test-only pass-through that builds a `BuilderOutput` from the four
413/// arguments the original `routes` took, so the existing tests keep their
414/// one-line call site while the production path goes through the
415/// builder.
416///
417/// Not `#[cfg(test)]` because the tests live in this module and need
418/// `pub(crate)`, but the function is documented and named so a future
419/// reader does not confuse it with the production entry point.
420#[cfg(test)]
421#[allow(
422    clippy::redundant_pub_crate,
423    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."
424)]
425pub(crate) fn test_routes(
426    server: Server,
427    ceiling: ProtocolVersion,
428    config: Option<ServerConfig>,
429    ids: Option<Ids>,
430) -> Result<axum::Router, ServeError> {
431    routes(HttpBuilder {
432        server,
433        addr: SocketAddr::from(([127, 0, 0, 1], 3000)),
434        ceiling,
435        config,
436        ids,
437        authenticator: None,
438        policy: None,
439        origin_policy: None,
440        rate_limiter: None,
441        #[cfg(feature = "legacy-sse")]
442        legacy_sse: None,
443    })
444    .map(|parts| parts.router)
445}
446
447/// The id source a builder uses when none was supplied.
448///
449/// [`RandomIds`] behind the `random-ids` feature, and an error without it. A
450/// counter would be the obvious fallback and is the wrong one: a session id is
451/// presented by the peer on every subsequent request, so a guessable sequence
452/// is a guessable session. Failing is the honest answer for a lean build, and
453/// it fails once at startup rather than per request.
454#[cfg(feature = "random-ids")]
455fn default_ids() -> Result<Ids, ServeError> {
456    use crate::adapters::secondary::id_source::RandomIds;
457    RandomIds::new()
458        .map(|ids| Arc::new(ids) as Ids)
459        .map_err(|error| ServeError::IdSource {
460            reason: error.to_string(),
461        })
462}
463
464/// No id source is available in this build, and none was supplied.
465#[cfg(not(feature = "random-ids"))]
466fn default_ids() -> Result<Ids, ServeError> {
467    Err(ServeError::NoIdSource)
468}
469
470/// The newest revision this build can complete a handshake on.
471///
472/// The same fallback as the stdio builder's, and for the same reason: an empty
473/// supported set leaves the ceiling a question rather than a wrong answer. Not
474/// `latest_known()`, which is `2026-07-28` — the revision SEP-2575 removed
475/// `initialize` from, and a server gated on it could never finish talking to
476/// anybody.
477pub(super) fn default_ceiling() -> ProtocolVersion {
478    SupportedVersions::all().map_or_else(
479        |_| ProtocolVersion::latest_known(),
480        |supported| supported.latest_handshake(),
481    )
482}
483
484/// What [`HttpBuilder::bind`] reports.
485///
486/// The address the OS chose rather than the one that was asked for, because
487/// `127.0.0.1:0` is the ordinary way to ask for a free port and the address a
488/// caller needs in order to connect is the one that was bound.
489#[derive(Debug, Clone, PartialEq, Eq)]
490pub struct ServeReport {
491    /// The address actually bound.
492    pub addr: SocketAddr,
493    /// The revision ceiling pre-handshake sessions were gated with.
494    ///
495    /// The *ceiling*, not a negotiated revision: no peer has connected yet.
496    pub ceiling: ProtocolVersion,
497}
498
499/// Why an HTTP server could not be brought up.
500#[derive(Debug, thiserror::Error)]
501pub enum ServeError {
502    /// The address could not be listened on.
503    #[error("could not bind {addr}: {source}")]
504    Bind {
505        /// What was asked for.
506        addr: SocketAddr,
507        /// What the OS said.
508        #[source]
509        source: std::io::Error,
510    },
511
512    /// The endpoint policy is not usable.
513    #[error("the endpoint policy is not usable: {0}")]
514    Endpoint(#[from] crate::adapters::primary::transport::http::config::EndpointError),
515
516    /// The accept loop failed.
517    #[error("the accept loop failed: {0}")]
518    Accept(#[source] std::io::Error),
519
520    /// The route table could not be built.
521    ///
522    /// In practice a configured security header that is not a legal HTTP
523    /// header value, or the issue #11 fail-open combination of a wired
524    /// authenticator and the default `AuthPolicy::Public` policy. Failing
525    /// here rather than at the first request is the point: the alternative
526    /// is a server that starts and then answers every request with a
527    /// permission boundary that does not exist.
528    #[error("the route table could not be built: {0}")]
529    Routes(#[from] crate::adapters::primary::transport::http::router::RouterError),
530
531    /// The legacy HTTP+SSE routes could not be mounted.
532    ///
533    /// A path that is not an absolute path on this origin, or a configured
534    /// security header that is not a legal value.
535    #[cfg(feature = "legacy-sse")]
536    #[error("the legacy HTTP+SSE routes could not be mounted: {0}")]
537    LegacyRoutes(#[from] crate::adapters::primary::transport::legacy_sse::RouteError),
538
539    /// No session-id source is available in this build.
540    #[error(
541        "this build has no id source: build with `random-ids`, or pass one to \
542         HttpBuilder::ids"
543    )]
544    NoIdSource,
545
546    /// The system entropy pool could not be read.
547    #[error("session ids could not be drawn: {reason}")]
548    IdSource {
549        /// What the entropy source said.
550        reason: String,
551    },
552}
553
554/// Listens on `addr`, and reports what the OS actually gave.
555///
556/// Free rather than a method because [`HttpBuilder::serve`] has already moved
557/// its own fields out by the time it calls this, and re-reading them off a
558/// partially-consumed builder is how the two copies drift apart.
559async fn bind(addr: SocketAddr) -> Result<(tokio::net::TcpListener, SocketAddr), ServeError> {
560    let listener = tokio::net::TcpListener::bind(addr)
561        .await
562        .map_err(|source| ServeError::Bind { addr, source })?;
563    let bound = listener
564        .local_addr()
565        .map_err(|source| ServeError::Bind { addr, source })?;
566    Ok((listener, bound))
567}