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;