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(¶ms);
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, ¶ms) {
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 ¶ms,
632 );
633 }
634 match self.dispatcher.dispatch(&context, method, ¶ms).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, ¶ms).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}