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}