Skip to main content

majordomus_cli/http/
mcp.rs

1//! MCP over HTTP at `/mcp`: the Streamable HTTP transport's request half. A client
2//! `POST`s one JSON-RPC message (or a batch) and gets the response in the body; the
3//! server answers `initialize` with an `Mcp-Session-Id` header, requires it on every later
4//! request, and forgets the session on `DELETE` or after a silence longer than
5//! [`SESSION_IDLE_TIMEOUT`]. There is no server-initiated stream (`GET` is 405): this
6//! server sends no notifications, so a stream would carry nothing.
7//!
8//! Every session is one [`Server`] over the shared surface, seen from its own peer, so the
9//! stdio bridges of other `majordomus mcp` processes and clients speaking this transport
10//! directly are peers of one board, and `peers.announce` knows who spoke.
11
12use std::collections::BTreeMap;
13use std::sync::{Arc, Mutex, MutexGuard};
14use std::time::{Duration, Instant};
15
16use serde_json::Value;
17
18use crate::mcp::{Server, Surface};
19use crate::peers::{PeerId, Transport};
20
21use super::router::{Request, Response};
22
23/// The path of the endpoint.
24pub const PATH: &str = "/mcp";
25
26/// The session header, as the transport specifies it (compared case-insensitively).
27pub const SESSION_HEADER: &str = "Mcp-Session-Id";
28
29/// A session that has been silent this long is forgotten. A `majordomus mcp` bridge
30/// pings every [`crate::mcp::bridge::HEARTBEAT`], well inside it; a client that speaks
31/// the transport directly and falls silent longer re-initialises on the 404 it gets, as
32/// the transport prescribes.
33pub const SESSION_IDLE_TIMEOUT: Duration = Duration::from_secs(90);
34
35struct Session {
36    peer: PeerId,
37    server: Mutex<Server>,
38    last_seen: Mutex<Instant>,
39}
40
41/// The endpoint: the sessions it holds and the surface they share.
42pub struct McpEndpoint {
43    surface: Surface,
44    version: &'static str,
45    url: String,
46    sessions: Mutex<BTreeMap<String, Arc<Session>>>,
47}
48
49impl std::fmt::Debug for McpEndpoint {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.debug_struct("McpEndpoint")
52            .field("url", &self.url)
53            .field("sessions", &self.active())
54            .finish()
55    }
56}
57
58impl McpEndpoint {
59    /// An endpoint over a context, announcing `version`, reachable at `url`.
60    pub fn new(live: impl crate::live::IntoLive, version: &'static str, url: String) -> Self {
61        McpEndpoint {
62            surface: Surface::new(live),
63            version,
64            url,
65            sessions: Mutex::new(BTreeMap::new()),
66        }
67    }
68
69    /// The base URL of the server this endpoint belongs to.
70    pub fn url(&self) -> &str {
71        &self.url
72    }
73
74    /// How many sessions are open.
75    pub fn active(&self) -> usize {
76        self.sessions().len()
77    }
78
79    /// Answer one request to the endpoint.
80    pub fn handle(&self, req: &Request) -> Response {
81        match req.method.as_str() {
82            "POST" => self.post(req),
83            "DELETE" => self.delete(req),
84            other => Response::error(
85                405,
86                "method_not_allowed",
87                &format!("{other} {PATH} is not served: POST a JSON-RPC message, DELETE to end the session; this server opens no stream"),
88            ),
89        }
90    }
91
92    fn post(&self, req: &Request) -> Response {
93        // a client names the protocol version it negotiated on every request after
94        // `initialize`; one this server does not speak is refused rather than guessed at
95        if let Some(version) = req.header("mcp-protocol-version") {
96            if !crate::mcp::protocol::PROTOCOL_VERSIONS.contains(&version) {
97                return Response::error(
98                    400,
99                    "unsupported_protocol_version",
100                    &format!(
101                        "MCP-Protocol-Version {version} is not one this server speaks ({})",
102                        crate::mcp::protocol::PROTOCOL_VERSIONS.join(", ")
103                    ),
104                );
105            }
106        }
107        let message: Value = match serde_json::from_slice(&req.body) {
108            Ok(v) => v,
109            Err(e) => {
110                return Response::error(400, "invalid_json", &format!("the body is not JSON: {e}"))
111            }
112        };
113        let (id, session) = match req.header(SESSION_HEADER) {
114            Some(id) => match self.sessions().get(id) {
115                Some(s) => (id.to_string(), Arc::clone(s)),
116                None => {
117                    return Response::error(
118                        404,
119                        "session_not_found",
120                        "unknown or expired Mcp-Session-Id; send initialize again to open a new session",
121                    )
122                }
123            },
124            // A server whose lease was taken over serves the peers it already has and ends
125            // with them. Opening a *new* session would be the opposite of that: the client
126            // would get a peer board, a generation of the layer and an execution history
127            // that no other client of this checkout can see, and nothing in its answers
128            // would say so. It is told instead, and its launcher's election finds the
129            // process that does hold the lease.
130            None if is_initialize(&message) && crate::lease::was_lost() => {
131                return Response::error(
132                    409,
133                    "lease_lost",
134                    "this server no longer holds this checkout's lease: another process took it \
135                     over and is the one to attach to. This one serves the sessions it already \
136                     had and ends with them; start again through the launcher, whose election \
137                     finds the current server.",
138                )
139            }
140            None if is_initialize(&message) => self.open(),
141            None => {
142                return Response::error(
143                    400,
144                    "session_required",
145                    &format!("the {SESSION_HEADER} header is required on every request after initialize"),
146                )
147            }
148        };
149        // A server that lost the checkout's lease answers its sessions for the busy grace and
150        // then hands each one on: the session is closed and answered 404, the client's bridge
151        // opens a new one, is refused 409 lease_lost, elects, and attaches to the current
152        // server. Without this, the bridges' pings kept the superseded server alive forever,
153        // and its clients on the code it was started from (I2127).
154        if crate::lease::lost_for().is_some_and(|age| age >= crate::lease::timings().busy_grace) {
155            // the session this request holds: one that a concurrent request handed on first is
156            // detached again, which changes nothing
157            self.sessions().remove(&id);
158            self.surface.context().episodes.detach(&session.peer);
159            self.surface.context().peers.detach(&session.peer);
160            tracing::info!(session = %id, peer = %session.peer, "session handed on: this server lost the lease");
161            return Response::error(
162                404,
163                "session_not_found",
164                "this server lost the checkout's lease and has handed the session on; open a new session through the launcher, whose election finds the current server",
165            );
166        }
167        *lock(&session.last_seen) = Instant::now();
168        let response = {
169            let mut server = lock(&session.server);
170            server.set_remote(req.is_remote());
171            server.handle(message)
172        };
173        // stamped again once answered: a request that ran longer than the reap interval must
174        // not find its session reaped behind it (I2147)
175        *lock(&session.last_seen) = Instant::now();
176        let mut out = match response {
177            None => Response::new(202, "application/json", String::new()),
178            Some(reply) => Response::new(
179                200,
180                "application/json",
181                serde_json::to_string(&reply).unwrap_or_default(),
182            ),
183        };
184        out.headers.push((SESSION_HEADER.to_string(), id));
185        out
186    }
187
188    fn open(&self) -> (String, Arc<Session>) {
189        let peer = self.surface.context().peers.attach(Transport::Http);
190        // the session id is the client's only credential to its session: 128 bits from the
191        // operating system's random source, never a pid, a counter and a clock (I2147)
192        let id = crate::mesh::link::fresh_token();
193        let server = Server::new(self.surface.for_peer(peer.clone()), self.version)
194            .with_endpoint(Some(self.url.clone()));
195        let session = Arc::new(Session {
196            peer: peer.clone(),
197            server: Mutex::new(server),
198            last_seen: Mutex::new(Instant::now()),
199        });
200        self.sessions().insert(id.clone(), Arc::clone(&session));
201        tracing::info!(session = %id, peer = %peer, "http session opened");
202        (id, session)
203    }
204
205    fn delete(&self, req: &Request) -> Response {
206        let Some(id) = req.header(SESSION_HEADER) else {
207            return Response::error(
208                400,
209                "session_required",
210                &format!("DELETE {PATH} names the session to end in {SESSION_HEADER}"),
211            );
212        };
213        match self.sessions().remove(id) {
214            Some(s) => {
215                // The episode is detached, never closed here: a client that ends its
216                // transport session has not necessarily ended its work, and it may come
217                // back inside the reattach grace to the very same episode. What closes one
218                // is a deliberate `episodes.detach`, the reaper, or the server stopping.
219                self.surface.context().episodes.detach(&s.peer);
220                self.surface.context().peers.detach(&s.peer);
221                tracing::info!(session = %id, peer = %s.peer, "http session closed by the client");
222                Response::new(204, "application/json", String::new())
223            }
224            None => Response::error(404, "session_not_found", "no such session"),
225        }
226    }
227
228    /// Forget every session silent longer than [`SESSION_IDLE_TIMEOUT`], and close every
229    /// episode detached longer than [`crate::episodes::REATTACH_GRACE`]; the peers it
230    /// returns are gone.
231    pub fn reap(&self) -> Vec<PeerId> {
232        self.reap_idle(SESSION_IDLE_TIMEOUT, crate::episodes::REATTACH_GRACE)
233    }
234
235    /// The same, with both bounds given.
236    ///
237    /// Two parameters and not one, because there are two clocks and a suite that could only
238    /// move the first could never see the second fire. The episode grace is fifteen minutes
239    /// and no test may wait it out, so a reaper wired to a constant would be a reaper
240    /// nothing proves — the shape this repository keeps calling a half-verdict. Production
241    /// passes the constants; `tests/shared_units.rs` passes zero and watches an episode close.
242    pub fn reap_idle(&self, timeout: Duration, episode_grace: Duration) -> Vec<PeerId> {
243        let mut gone = Vec::new();
244        let mut sessions = self.sessions();
245        sessions.retain(|id, s| {
246            let idle = lock(&s.last_seen).elapsed();
247            if idle > timeout {
248                tracing::info!(session = %id, peer = %s.peer, idle_seconds = idle.as_secs(), "http session expired");
249                gone.push(s.peer.clone());
250                false
251            } else {
252                true
253            }
254        });
255        drop(sessions);
256        for p in &gone {
257            self.surface.context().episodes.detach(p);
258            self.surface.context().peers.detach(p);
259        }
260        // And the other reaper, over a different clock. A connection is forgotten after
261        // ninety seconds of silence; the episode it was carrying waits out the reattach
262        // grace for its client to come back, and is closed as interrupted only when it does
263        // not. Two timeouts because they answer two questions: is this socket alive, and is
264        // this work over.
265        for episode in self.surface.context().episodes.reap(episode_grace) {
266            tracing::info!(
267                episode = %episode.external_id,
268                "episode expired: no connection came back inside the reattach grace"
269            );
270        }
271        gone
272    }
273
274    /// Forget every session: the server is stopping.
275    pub fn close_all(&self) {
276        let mut sessions = self.sessions();
277        for (_, s) in std::mem::take(&mut *sessions) {
278            self.surface.context().peers.detach(&s.peer);
279        }
280        drop(sessions);
281        // An episode left open by a server that has gone is an episode nothing will ever
282        // close, and the repository would carry it as open for ever. The server is the last
283        // thing that can close them, so it does, before it stops.
284        for episode in self.surface.context().episodes.close_all() {
285            tracing::info!(episode = %episode.external_id, "episode closed: the shared server is stopping");
286        }
287    }
288
289    fn sessions(&self) -> MutexGuard<'_, BTreeMap<String, Arc<Session>>> {
290        lock(&self.sessions)
291    }
292}
293
294fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
295    m.lock().unwrap_or_else(|e| e.into_inner())
296}
297
298/// Is this message, or any message of this batch, an `initialize` request?
299///
300/// ```
301/// use majordomus_cli::http::mcp::is_initialize;
302/// use serde_json::json;
303/// assert!(is_initialize(&json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize" })));
304/// assert!(is_initialize(&json!([{ "method": "ping" }, { "method": "initialize" }])));
305/// assert!(!is_initialize(&json!({ "method": "ping" })));
306/// ```
307pub fn is_initialize(message: &Value) -> bool {
308    match message {
309        Value::Array(batch) => batch.iter().any(is_initialize),
310        Value::Object(m) => m.get("method").and_then(Value::as_str) == Some("initialize"),
311        _ => false,
312    }
313}