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}