Skip to main content

protocol_droid/adapters/primary/transport/legacy_sse/
router.rs

1//! The two routes the `2024-11-05` transport is defined as.
2//!
3//! # Why this is a separate router rather than a mode of `McpRouter`
4//!
5//! Because the two transports have different lifecycles, not different
6//! configuration. `McpRouter` is one endpoint that opens a session, streams on
7//! it, and terminates it, with the session named by a header on every request.
8//! The legacy transport is **two** endpoints that each do exactly one thing:
9//!
10//! - `GET <sse>` opens a stream and sends the `endpoint` event. It never
11//!   receives a message.
12//! - `POST <message>?sessionId=…` receives one message. It never opens a
13//!   stream, and its own response carries no answer.
14//!
15//! A `GET` that could also receive, or a `POST` that could also stream, is not
16//! a configuration of `McpRouter` — it is a router where each route has two
17//! behaviours and the tests must enumerate the cross product. Two handlers with
18//! one job each is the smaller thing to be correct about.
19//!
20//! Where the session is *named* also differs, and not by choice. `McpRouter`
21//! reads `Mcp-Session-Id`; this transport's specification has no such header,
22//! and the id is in the query string of the URI the server itself advertised.
23//! The two transports therefore need the same session lookup and different
24//! parsing, which is why the lookup is reused and the parsing is not.
25//!
26//! # The `202` a `POST` answers with is a convention
27//!
28//! The specification does not say. It says the client `POST`s to the endpoint
29//! the server advertised and the server sends messages on the SSE stream, and
30//! those two sentences together imply the `POST`'s own response has nothing to
31//! carry. Every implementation answers `202 Accepted`; none of them do so
32//! because the text requires it.
33//!
34//! The convention is recorded here so it is not mistaken for a guarantee, and
35//! so the part a client can actually branch on is stated instead: a `404` for
36//! an unknown session, a `400` for a body that is not JSON or a missing
37//! `sessionId`, a `403` for a foreign `Origin`. Those are the decisions a
38//! client has to make, and unlike the success status they are choices this
39//! server makes deliberately.
40
41use std::collections::HashMap;
42use std::str::FromStr as _;
43use std::sync::Arc;
44
45use axum::Router as AxumRouter;
46use axum::body::Body;
47use axum::extract::{Query, State};
48use axum::http::{HeaderMap, StatusCode, header};
49use axum::response::{IntoResponse, Response};
50use serde_json::Value;
51
52use crate::adapters::primary::transport::http::limits::RateLimiter;
53use crate::adapters::primary::transport::http::precondition::{authenticate, read_body};
54use crate::adapters::primary::transport::http::{
55    Anonymous, AuthPolicy, Authenticator, EventTail, InvalidHeaderValue, Refusal, ServerConfig,
56    SessionStore, with_security,
57};
58use crate::adapters::primary::transport::legacy_sse::{
59    LegacyDispatcher, LegacyFrame, LegacySession, LegacyTail, OriginPolicy,
60};
61use crate::domain::registry::AuthContext;
62use crate::domain::{McpSessionId, MessageEndpoint, MessageEndpointError, Verdict};
63
64/// The query parameter carrying the session id on the message endpoint.
65///
66/// A convention, not specification text — see the module docs. A constant
67/// because a literal at the two sites that must agree (the URI the server
68/// builds, the URI the server parses) is how a server advertises one parameter
69/// and reads another, and a client then gets a `404` forever with nothing in
70/// the logs to explain it.
71pub const SESSION_PARAMETER: &str = "sessionId";
72
73/// The `202` a `POST` answers with.
74///
75/// A convention, per the module docs. Chosen because it is the status that says
76/// "accepted, answered elsewhere", which is exactly the relationship between a
77/// `POST` and the stream that will carry the answer. A `200` would promise a
78/// body the specification says does not exist.
79const ACCEPTED: StatusCode = StatusCode::ACCEPTED;
80
81/// The stream path when the operator names none.
82pub const DEFAULT_STREAM_PATH: &str = "/sse";
83
84/// The message path when the operator names none.
85pub const DEFAULT_MESSAGE_PATH: &str = "/messages";
86
87/// Why this transport could not be mounted.
88///
89/// Exists because axum's `Router::route` **panics** on a path that does not
90/// start with `/`, and a server that aborts its own start-up on a
91/// configuration mistake is a server an operator cannot diagnose from a log
92/// line. Every variant here is a configuration that a human typed.
93#[derive(Debug, thiserror::Error)]
94pub enum RouteError {
95    /// A configured security header is not a legal HTTP header value.
96    #[error("a configured security header is not a legal HTTP header value")]
97    Header(#[from] InvalidHeaderValue),
98    /// A configured path could not be mounted or advertised.
99    #[error("the {which} path `{path}` cannot be used: {reason}")]
100    Path {
101        /// Which of the two paths it was, so the message names the setting.
102        which: &'static str,
103        /// The value as configured, so it can be found in the file it was set in.
104        path: String,
105        /// Why it was refused.
106        reason: MessageEndpointError,
107    },
108}
109
110/// State every legacy handler needs.
111struct LegacyState<D: LegacyDispatcher> {
112    /// The message path, kept separate from [`ServerConfig::endpoint`] because
113    /// this transport's configuration has two paths where `McpRouter` has one.
114    ///
115    /// A validated [`MessageEndpoint`] rather than the configured string,
116    /// because the only thing this router does with it is append a query
117    /// parameter, and a path that cannot take one has no business being here.
118    message_path: MessageEndpoint,
119    config: ServerConfig,
120    sessions: Arc<SessionStore<LegacySession<D::Session>>>,
121    dispatcher: Arc<D>,
122    origins: Arc<OriginPolicy>,
123    policy: AuthPolicy,
124    authenticator: Arc<dyn Authenticator + Send + Sync>,
125    rate_limiter: RateLimiter,
126}
127
128impl<D: LegacyDispatcher> LegacyState<D> {
129    /// The `endpoint` event's payload for `id`.
130    ///
131    /// Infallible, because the path was validated by
132    /// [`MessageEndpoint::bare_path`] when the router was assembled. That is
133    /// where the check belongs: a path that could escape the origin, or one
134    /// carrying its own query, is a *configuration* mistake, and finding it on
135    /// the first `GET` would mean finding it in production. The server is
136    /// about to tell a client "send your messages here" — a path that could
137    /// point elsewhere makes that instruction a redirect — so the check is at
138    /// assembly rather than left to the client that would act on it.
139    fn message_endpoint(&self, id: McpSessionId) -> MessageEndpoint {
140        self.message_path
141            .with_query_parameter(SESSION_PARAMETER, &id.to_string())
142    }
143}
144
145impl<D: LegacyDispatcher> Clone for LegacyState<D> {
146    fn clone(&self) -> Self {
147        Self {
148            message_path: self.message_path.clone(),
149            config: self.config.clone(),
150            sessions: Arc::clone(&self.sessions),
151            dispatcher: Arc::clone(&self.dispatcher),
152            origins: Arc::clone(&self.origins),
153            policy: self.policy,
154            authenticator: Arc::clone(&self.authenticator),
155            rate_limiter: self.rate_limiter.clone(),
156        }
157    }
158}
159
160/// The `2024-11-05` HTTP+SSE transport's two routes.
161pub struct LegacyRouter<D: LegacyDispatcher> {
162    message_path: String,
163    stream_path: String,
164    config: ServerConfig,
165    sessions: Arc<SessionStore<LegacySession<D::Session>>>,
166    dispatcher: Arc<D>,
167    origins: Arc<OriginPolicy>,
168    policy: AuthPolicy,
169    authenticator: Arc<dyn Authenticator + Send + Sync>,
170    rate_limiter: RateLimiter,
171}
172
173/// Written out rather than derived because `D` is not required to be `Debug`,
174/// and a derive would add that bound to a type an implementor should be free
175/// to make out of anything. The paths and the `Origin` count are in here
176/// because those are the two things an operator gets wrong, and a `Debug` that
177/// omits them is a `Debug` that cannot answer "what did this server serve?".
178impl<D: LegacyDispatcher> core::fmt::Debug for LegacyRouter<D> {
179    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
180        f.debug_struct("LegacyRouter")
181            .field("stream_path", &self.stream_path)
182            .field("message_path", &self.message_path)
183            .field("permitted_origins", &self.origins.permitted().count())
184            .field("policy", &self.policy)
185            .finish_non_exhaustive()
186    }
187}
188
189impl<D: LegacyDispatcher> LegacyRouter<D> {
190    /// A router with no `Origin` permitted, so every `Origin` that is present
191    /// is refused until the operator says which origins are served.
192    ///
193    /// See [`OriginPolicy`] for why the default denies rather than allows.
194    #[must_use]
195    pub fn new(
196        config: ServerConfig,
197        sessions: Arc<SessionStore<LegacySession<D::Session>>>,
198        dispatcher: D,
199    ) -> Self {
200        Self {
201            message_path: DEFAULT_MESSAGE_PATH.to_owned(),
202            stream_path: DEFAULT_STREAM_PATH.to_owned(),
203            rate_limiter: RateLimiter::new(*config.rate_limit()),
204            config,
205            sessions,
206            dispatcher: Arc::new(dispatcher),
207            origins: Arc::new(OriginPolicy::strict(None)),
208            policy: AuthPolicy::Public,
209            authenticator: Arc::new(Anonymous),
210        }
211    }
212
213    /// Sets which `Origin`s this server answers.
214    #[must_use]
215    pub fn with_origins(mut self, origins: OriginPolicy) -> Self {
216        self.origins = Arc::new(origins);
217        self
218    }
219
220    /// Replaces the anonymous default with a real implementation, and sets the
221    /// scope policy it is checked against.
222    #[must_use]
223    pub fn with_auth(
224        mut self,
225        policy: AuthPolicy,
226        authenticator: Arc<dyn Authenticator + Send + Sync>,
227    ) -> Self {
228        self.policy = policy;
229        self.authenticator = authenticator;
230        self
231    }
232
233    /// Replaces the per-subject request allowance.
234    ///
235    /// Defaults to [`ServerConfig::rate_limit`]. Requests are counted against
236    /// the session they name, and requests that name none (opening a stream)
237    /// share one allowance.
238    #[must_use]
239    pub fn with_rate_limiter(mut self, limiter: RateLimiter) -> Self {
240        self.rate_limiter = limiter;
241        self
242    }
243
244    /// Overrides the path the `GET` opens a stream on.
245    ///
246    /// Not validated here, and that is deliberate. This path is checked by axum
247    /// when the router is assembled, and a path that does not match produces a
248    /// router serving nothing — a loud failure. The *message* path, the one
249    /// that reaches a client as an instruction, is checked by
250    /// `LegacyState::message_endpoint` where the value is built, because a
251    /// message path that does not match produces a client posting into the
252    /// void, which is not loud at all.
253    #[must_use]
254    pub fn with_stream_path(mut self, path: impl Into<String>) -> Self {
255        self.stream_path = path.into();
256        self
257    }
258
259    /// Overrides the path the `endpoint` event advertises for `POST`.
260    #[must_use]
261    pub fn with_message_path(mut self, path: impl Into<String>) -> Self {
262        self.message_path = path.into();
263        self
264    }
265
266    /// Assembles the axum router.
267    ///
268    /// # Errors
269    ///
270    /// [`RouteError::Header`] if a configured security header is not a legal
271    /// HTTP value, for the same reason `McpRouter` fails there: a server that
272    /// starts and then answers without its headers is worse than one that does
273    /// not start. And [`RouteError::Path`] for a path that is not an absolute
274    /// path — which axum would otherwise **panic** on, taking the process with
275    /// it, rather than refusing to start.
276    pub fn into_router(self) -> Result<AxumRouter, RouteError> {
277        // Both paths are read out before either is moved into the state: the
278        // state needs the message path to build the `endpoint` event, and the
279        // router needs both to mount the routes.
280        let Self {
281            message_path: configured_message_path,
282            stream_path,
283            config,
284            sessions,
285            dispatcher,
286            origins,
287            policy,
288            authenticator,
289            rate_limiter,
290        } = self;
291
292        // Checked here rather than left to the two places that would otherwise
293        // discover it. The stream path is handed to axum, which **panics** on
294        // one that does not start with `/`; the message path is handed to a
295        // client, which would post into the void. The same validator for both,
296        // because the requirement is identical: an absolute path on this
297        // server's own origin, carrying no query and no fragment, so a
298        // `sessionId` appended to it *is* the query rather than part of one.
299        //
300        // Written out twice rather than looped. The two paths land in two
301        // different shapes — one is mounted, one is stored in the state — and a
302        // loop over a pair of `&str`s that then has to be unpacked again is
303        // longer than the duplication it saves.
304        MessageEndpoint::bare_path(&stream_path).map_err(|reason| RouteError::Path {
305            which: "stream",
306            path: stream_path.clone(),
307            reason,
308        })?;
309        let message_path =
310            MessageEndpoint::bare_path(&configured_message_path).map_err(|reason| {
311                RouteError::Path {
312                    which: "message",
313                    path: configured_message_path.clone(),
314                    reason,
315                }
316            })?;
317
318        let security = config.security().clone();
319        let state = Arc::new(LegacyState {
320            message_path,
321            config,
322            sessions,
323            dispatcher,
324            origins,
325            policy,
326            authenticator,
327            rate_limiter,
328        });
329        // Two `MethodRouter`s on two paths, and deliberately not one
330        // `MethodRouter` taking both methods on both. A `POST` to the stream
331        // path and a `GET` to the message path are both mistakes, and axum
332        // answers each with a `405` naming the methods it does serve — a better
333        // answer than one route trying to be both.
334        let router = AxumRouter::new()
335            .route(&stream_path, axum::routing::get(open_stream))
336            .route(
337                &configured_message_path,
338                axum::routing::post(receive_message),
339            )
340            .with_state(state);
341        Ok(with_security(router, &security)?)
342    }
343}
344
345/// `GET` — open a stream, and send the `endpoint` event first.
346///
347/// The ordering is not a convention here. The `endpoint` frame is pushed onto
348/// the tail before the response is built, and nothing can dispatch a `message`
349/// until this handler has returned, the client has read the id out of the event
350/// it was just sent, and that id has been posted back. A `message` frame cannot
351/// precede the `endpoint` frame. The specification requires the `endpoint`
352/// event first, and pushing it here makes that a property of the tail rather
353/// than something every future handler has to remember.
354async fn open_stream<D: LegacyDispatcher>(
355    State(state): State<Arc<LegacyState<D>>>,
356    headers: HeaderMap,
357) -> Response {
358    // The `GET` opens the stream and dispatches nothing, so the principal it
359    // resolved has nowhere to go. It is still resolved, because the endpoint's
360    // policy applies to opening a stream exactly as it does to posting a
361    // message — an unauthenticated caller must not be able to open one.
362    if let Err(response) = check_common(&state, &headers, None).await {
363        return *response;
364    }
365
366    let tail: LegacyTail = EventTail::new(state.config.replay_capacity());
367    // Taken before the session exists, because the dispatcher's state is part
368    // of the value being stored and may need the id it will be stored under.
369    let taken = state.sessions.next_id();
370    let kept = state.dispatcher.open(taken, &tail);
371    let id = match state
372        .sessions
373        .open_with_id(Arc::new(LegacySession::new(tail.clone(), kept)), taken)
374    {
375        Ok(id) => id,
376        Err(error) => return refuse(Refusal::Handler(error.to_string())),
377    };
378
379    // Nothing between the session being registered and the endpoint event being
380    // pushed can fail. That is deliberate, and it is why a `GET` cannot leak a
381    // session: the earlier version built the endpoint URI here and had to
382    // un-register the session when it could not, which is a second failure
383    // path through the one handler that should not have any. Validating the
384    // path at assembly moved the whole class of failure out of the request.
385    tail.push(LegacyFrame::Endpoint(state.message_endpoint(id)));
386
387    // `None` for the cursor, and no `Last-Event-ID` is read. This transport
388    // defines no resumption, so a client that sends the header anyway is
389    // ignored rather than obeyed: obeying it would replay from a cursor whose
390    // meaning this transport never established, and the client's idea of what
391    // it has already seen would silently change under it.
392    match tail.plan(None).into_response(state.config.keep_alive()) {
393        Ok(response) => response,
394        Err(error) => refuse(Refusal::Handler(error.to_string())),
395    }
396}
397
398/// `POST` — take one message, and put the answer on the stream.
399async fn receive_message<D: LegacyDispatcher>(
400    State(state): State<Arc<LegacyState<D>>>,
401    headers: HeaderMap,
402    Query(query): Query<HashMap<String, String>>,
403    body: Body,
404) -> Response {
405    // Kept rather than discarded: this is the request's principal, and
406    // `dispatch` is the only thing downstream that can act on it.
407    let auth = match check_common(
408        &state,
409        &headers,
410        query.get(SESSION_PARAMETER).map(String::as_str),
411    )
412    .await
413    {
414        Ok(auth) => auth,
415        Err(response) => return *response,
416    };
417
418    let Some(raw) = query.get(SESSION_PARAMETER) else {
419        return refuse(Refusal::MissingSession);
420    };
421    // A malformed id is refused as its own kind rather than as "no such
422    // session". The distinction is the operator's only clue when a client is
423    // posting to the wrong URI: a `404` says "no such session" and sends them
424    // looking at session lifetime, while this one says "that is not an id" and
425    // sends them looking at the URI they built.
426    let Ok(id) = McpSessionId::from_str(raw) else {
427        return refuse(Refusal::MalformedSessionId);
428    };
429    let session = match state.sessions.lookup(&id) {
430        Ok(Some(session)) => session,
431        Ok(None) => return refuse(Refusal::UnknownSession),
432        Err(error) => return refuse(Refusal::Handler(error.to_string())),
433    };
434
435    // The ceiling is the domain's, read once and converted once, for the reason
436    // `McpRouter` does the same: a `u32` that does not fit a `usize` is a
437    // target whose `usize` is narrower than 32 bits, and refusing every body is
438    // the safe reading there rather than a limit that silently wraps.
439    let Ok(limit) = usize::try_from(state.config.max_body().bytes()) else {
440        return refuse(Refusal::Handler(
441            "the configured body limit does not fit this platform".to_owned(),
442        ));
443    };
444    let body = match read_body(body, limit).await {
445        Ok(body) => body,
446        Err(refusal) => return refuse(refusal),
447    };
448
449    // Parsed at the boundary, so that `Value` — the one untyped thing in this
450    // file — is the shape the port declares rather than something the port has
451    // to re-parse. The `jsonrpc` member is *not* checked here: whether this is a
452    // valid JSON-RPC message for the negotiated revision belongs to the
453    // dispatcher, which is the only thing that knows the revision.
454    let frame: Value = match serde_json::from_slice(&body) {
455        Ok(frame) => frame,
456        Err(error) => {
457            return refuse(Refusal::MalformedBody {
458                reason: error.to_string(),
459            });
460        }
461    };
462
463    match state
464        .dispatcher
465        .dispatch(&auth, &id, session.state(), &frame)
466        .await
467    {
468        Ok(replies) => {
469            for reply in replies {
470                session.tail().push(LegacyFrame::Message(reply));
471            }
472            (ACCEPTED, ()).into_response()
473        }
474        Err(refusal) => refuse(refusal),
475    }
476}
477
478/// The checks both routes make, in the order a router must make them.
479///
480/// `Origin` first, before authentication. The point of the `Origin` check is to
481/// refuse a *connection*, and a credential is not what makes a connection
482/// acceptable. Authenticating first would let a hostile page drive the
483/// authenticator, which on a deployment with a token check is a credential
484/// oracle reachable from any page the operator's users visit.
485async fn check_common<D: LegacyDispatcher>(
486    state: &LegacyState<D>,
487    headers: &HeaderMap,
488    subject: Option<&str>,
489) -> Checked {
490    let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
491    if let Err(refusal) = state.origins.check(origin) {
492        return Err(Box::new(refuse(refusal)));
493    }
494    // `Origin` is absent from every non-browser client, so on its own it
495    // cannot catch a rebound DNS name. `Host` is set from the URL the client
496    // dialled and cannot be forged by a page.
497    let host = headers.get(header::HOST).and_then(|v| v.to_str().ok());
498    if let Err(refusal) = state.origins.check_host(host) {
499        return Err(Box::new(refuse(refusal)));
500    }
501    // Before authentication, so a flood of bad credentials is counted too.
502    if let Verdict::Limited { retry_after } = state
503        .rate_limiter
504        .check(subject.filter(|s| !s.is_empty()).unwrap_or("anonymous"))
505    {
506        return Err(Box::new(refuse(Refusal::RateLimited { retry_after })));
507    }
508    // The resolved context is the *return value*, not a discarded result.
509    // This router authenticates on every route and there is no other path for
510    // the principal to reach the dispatcher, so returning `Ok(())` here is what
511    // made a legacy-SSE deployment authenticate every caller and then serve
512    // them all as anonymous.
513    match authenticate(state.authenticator.as_ref(), &state.policy, headers).await {
514        Ok(auth) => Ok(auth),
515        Err(refusal) => Err(Box::new(refuse(refusal))),
516    }
517}
518
519/// Renders a refusal as the response it is.
520fn refuse(refusal: Refusal) -> Response {
521    refusal.into_response()
522}
523
524/// What the pre-checks leave behind: the caller, or the response that refuses
525/// it.
526///
527/// [`Response`] is ~128 bytes, so a `Result<AuthContext, Response>` makes every
528/// `?` on this path pay for a response wrapper it is about to unwrap. The
529/// handlers each want to `return` the response, so the boxing is here rather
530/// than at two call sites that would each have to remember it.
531type Checked = Result<AuthContext, Box<Response>>;
532
533#[cfg(test)]
534mod tests;