Skip to main content

majordomus_cli/mcp/
bridge.rs

1//! The bridge: a `majordomus mcp` process that found the repository's shared server
2//! already running forwards its client's stdio frames to that server's `/mcp` endpoint
3//! and writes the answers back, one HTTP request per message, and pings on the side so
4//! that the server keeps the session. It carries no index and no registry of its own,
5//! which is why it starts in milliseconds. The HTTP client below is the few lines a
6//! loopback request needs (the server always answers with `Content-Length`, never
7//! chunked); no HTTP library is pulled in for it.
8//!
9//! A message is sent a second time only when the server provably did not run it: the
10//! connection was never made, or the server answered 404 (the session is unknown) or 409
11//! (the lease is lost), both before it looks at the message. A message that was sent and
12//! never answered — a timeout, a dropped connection, a 5xx — may have run, so it is sent
13//! again only when it is idempotent: every method but `tools/call`, and a tool whose
14//! declared execution hints say so ([`idempotent`]). Anything else is the caller's to
15//! report as an outcome nobody knows ([`BridgeError::may_resend`]).
16
17use std::collections::BTreeMap;
18use std::io::{Read, Write};
19use std::net::{TcpStream, ToSocketAddrs};
20use std::time::Duration;
21
22use serde_json::{json, Value};
23
24use crate::peers::ClientInfo;
25
26/// How often a bridge pings the server so that its session is not forgotten.
27pub const HEARTBEAT: Duration = Duration::from_secs(20);
28
29/// How long a bridge waits to connect.
30pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
31
32/// How long a bridge waits for one answer, unless it was made with [`Bridge::with_timeout`].
33pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
34
35const SESSION_HEADER: &str = "Mcp-Session-Id";
36
37/// One HTTP reply.
38#[derive(Debug, Clone)]
39pub struct Reply {
40    /// The status code.
41    pub status: u16,
42    /// Header names lowercased.
43    pub headers: Vec<(String, String)>,
44    /// The body, as text.
45    pub body: String,
46}
47
48impl Reply {
49    /// A header value, by case-insensitive name.
50    pub fn header(&self, name: &str) -> Option<&str> {
51        let name = name.to_ascii_lowercase();
52        self.headers
53            .iter()
54            .find(|(k, _)| *k == name)
55            .map(|(_, v)| v.as_str())
56    }
57}
58
59/// The largest reply [`request`] reads. Far above what an answer of this server weighs — a
60/// `tools/list` of every capability is a few megabytes — and finite, so that a peer, a
61/// rendezvous hub or a broken proxy that never stops sending costs a bounded buffer (I2147).
62pub const MAX_REPLY_BYTES: usize = 32 * 1024 * 1024;
63
64/// One HTTP/1.1 request to `base_url` (`http://host:port`), with `Connection: close`.
65pub fn request(
66    base_url: &str,
67    method: &str,
68    path: &str,
69    headers: &[(&str, &str)],
70    body: Option<&str>,
71    timeout: Duration,
72) -> std::io::Result<Reply> {
73    let (stream, host) = connect(base_url, timeout)?;
74    exchange(stream, &host, method, path, headers, body, timeout)
75}
76
77/// Open a connection to `base_url`. Nothing has been sent when this fails, which is what
78/// lets a caller send the same message again elsewhere.
79fn connect(base_url: &str, timeout: Duration) -> std::io::Result<(TcpStream, String)> {
80    let host = base_url
81        .strip_prefix("http://")
82        .unwrap_or(base_url)
83        .trim_end_matches('/');
84    let addr = host
85        .to_socket_addrs()?
86        .next()
87        .ok_or_else(|| std::io::Error::other(format!("{host}: no address")))?;
88    // The connection shares the caller's budget when that is the shorter: a probe on a shell
89    // prompt that allows 250 ms must not wait 2 s on a server whose accept queue is full.
90    let stream = TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT.min(timeout))?;
91    Ok((stream, host.to_string()))
92}
93
94/// Send one request on an open connection and read the reply. From the first byte written
95/// the server may have the request, so a failure here leaves its outcome unknown.
96fn exchange(
97    mut stream: TcpStream,
98    host: &str,
99    method: &str,
100    path: &str,
101    headers: &[(&str, &str)],
102    body: Option<&str>,
103    timeout: Duration,
104) -> std::io::Result<Reply> {
105    stream.set_read_timeout(Some(timeout))?;
106    stream.set_write_timeout(Some(timeout))?;
107    let body = body.unwrap_or("");
108    let mut text = format!(
109        "{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nAccept: application/json\r\nContent-Type: application/json\r\nContent-Length: {}\r\n",
110        body.len()
111    );
112    for (k, v) in headers {
113        text.push_str(&format!("{k}: {v}\r\n"));
114    }
115    text.push_str("\r\n");
116    text.push_str(body);
117    stream.write_all(text.as_bytes())?;
118    let mut raw = Vec::new();
119    (&mut stream)
120        .take(MAX_REPLY_BYTES as u64 + 1)
121        .read_to_end(&mut raw)?;
122    if raw.len() > MAX_REPLY_BYTES {
123        return Err(std::io::Error::new(
124            std::io::ErrorKind::InvalidData,
125            format!("the reply is over the {MAX_REPLY_BYTES}-byte bound and was not read"),
126        ));
127    }
128    parse_reply(&raw)
129}
130
131fn parse_reply(raw: &[u8]) -> std::io::Result<Reply> {
132    let text = String::from_utf8_lossy(raw);
133    let (head, body) = text
134        .split_once("\r\n\r\n")
135        .ok_or_else(|| std::io::Error::other("malformed HTTP response: no header end"))?;
136    let mut lines = head.lines();
137    let status = lines
138        .next()
139        .and_then(|l| l.split_whitespace().nth(1))
140        .and_then(|s| s.parse::<u16>().ok())
141        .ok_or_else(|| std::io::Error::other("malformed HTTP response: no status line"))?;
142    let headers = lines
143        .filter_map(|l| {
144            l.split_once(':')
145                .map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string()))
146        })
147        .collect::<Vec<_>>();
148    let length = headers
149        .iter()
150        .find(|(k, _)| k == "content-length")
151        .and_then(|(_, v)| v.parse::<usize>().ok());
152    let body = match length {
153        Some(n) if n <= body.len() => body[..n].to_string(),
154        _ => body.to_string(),
155    };
156    Ok(Reply {
157        status,
158        headers,
159        body,
160    })
161}
162
163/// Why the bridge could not get an answer.
164#[derive(Debug, Clone, thiserror::Error)]
165pub enum BridgeError {
166    #[error("the shared server at {url} is unreachable: {reason}")]
167    /// No connection could be made: nothing was sent, so nothing ran, and the server is gone.
168    Unreachable {
169        /// The server's URL.
170        url: String,
171        /// What happened.
172        reason: String,
173    },
174    #[error("the shared server at {url} did not answer: {reason}")]
175    /// The message was sent and no usable answer came back: a timeout, a dropped
176    /// connection, a server-side failure (5xx), an answer that is not JSON. The server
177    /// may have run it, and whether it did is unknown.
178    Unanswered {
179        /// The server's URL.
180        url: String,
181        /// What happened.
182        reason: String,
183    },
184    #[error("the shared server at {url} rejected the request ({status}): {body}")]
185    /// The server answered and refused.
186    Rejected {
187        /// The server's URL.
188        url: String,
189        /// The status code.
190        status: u16,
191        /// The body.
192        body: String,
193    },
194}
195
196impl BridgeError {
197    /// Whether this failure says the server is no longer the one to talk to, so that the
198    /// client should elect again: it could not be reached, it took the request and never
199    /// answered, it forgot the session twice running (404), or it said it had lost the lease
200    /// (409).
201    ///
202    /// Any other refusal comes from a server that is there and serving: it read the request
203    /// and would not take it — a request before `initialize`, a body too large. An election
204    /// cannot change that answer; it is relayed to the client.
205    ///
206    /// ```
207    /// use majordomus_cli::mcp::bridge::BridgeError;
208    /// let url = "http://127.0.0.1:1".to_string();
209    /// let refused = |status| BridgeError::Rejected { url: url.clone(), status, body: String::new() };
210    /// assert!(refused(404).calls_for_an_election());
211    /// assert!(refused(409).calls_for_an_election());
212    /// assert!(!refused(400).calls_for_an_election(), "a serving server's refusal is its answer");
213    /// assert!(!refused(413).calls_for_an_election(), "a message too large is too large anywhere");
214    /// assert!(BridgeError::Unanswered { url: url.clone(), reason: "timed out".into() }.calls_for_an_election());
215    /// ```
216    pub fn calls_for_an_election(&self) -> bool {
217        match self {
218            BridgeError::Unreachable { .. } | BridgeError::Unanswered { .. } => true,
219            BridgeError::Rejected { status, .. } => matches!(status, 404 | 409),
220        }
221    }
222
223    /// Whether `message` may be sent again after this failure without the risk of running
224    /// it twice: when the server provably did not run it (no connection, a lost session or
225    /// lease), or when running it twice changes nothing the first run did not
226    /// ([`idempotent`]).
227    ///
228    /// ```
229    /// use majordomus_cli::mcp::bridge::BridgeError;
230    /// use serde_json::json;
231    /// let url = "http://127.0.0.1:1".to_string();
232    /// let call = |tool: &str| json!({ "jsonrpc": "2.0", "id": 7, "method": "tools/call", "params": { "name": tool } });
233    /// let writer = call("majordomus_plan_transition");
234    /// let reader = call("majordomus_repository");
235    /// let lost = BridgeError::Unanswered { url: url.clone(), reason: "timed out".into() };
236    /// assert!(!lost.may_resend(&writer), "a write that may have run is not run again");
237    /// assert!(lost.may_resend(&reader), "a read is");
238    /// let never_sent = BridgeError::Unreachable { url: url.clone(), reason: "refused".into() };
239    /// assert!(never_sent.may_resend(&writer));
240    /// let too_large = BridgeError::Rejected { url, status: 413, body: String::new() };
241    /// assert!(!too_large.may_resend(&reader));
242    /// ```
243    pub fn may_resend(&self, message: &Value) -> bool {
244        match self {
245            BridgeError::Unreachable { .. } => true,
246            BridgeError::Rejected { status, .. } => matches!(status, 404 | 409),
247            BridgeError::Unanswered { .. } => idempotent(message),
248        }
249    }
250}
251
252/// Whether sending `message` twice changes nothing that sending it once did not.
253///
254/// Every MCP method but `tools/call` reads. A `tools/call` is idempotent when its tool's
255/// declared execution hints say so ([`crate::capability::ExecutionPolicy::hints`]), looked
256/// up in the compiled-in registry; a tool that registry does not know is assumed not to
257/// be, because a call wrongly withheld is reported while a call wrongly repeated may have
258/// written twice. A batch is idempotent when every message in it is.
259///
260/// ```
261/// use majordomus_cli::mcp::bridge::idempotent;
262/// use serde_json::json;
263/// let call = |tool: &str| json!({ "method": "tools/call", "params": { "name": tool } });
264/// assert!(idempotent(&json!({ "method": "resources/read", "params": { "uri": "majordomus://repository" } })));
265/// assert!(idempotent(&call("majordomus_repository")));
266/// assert!(!idempotent(&call("majordomus_plan_transition")));
267/// assert!(!idempotent(&call("no_such_tool")));
268/// assert!(!idempotent(&json!([call("majordomus_repository"), call("majordomus_plan_transition")])));
269/// ```
270pub fn idempotent(message: &Value) -> bool {
271    match message {
272        Value::Array(batch) => batch.iter().all(idempotent),
273        m if m.get("method").and_then(Value::as_str) == Some("tools/call") => m
274            .pointer("/params/name")
275            .and_then(Value::as_str)
276            .is_some_and(tool_is_idempotent),
277        _ => true,
278    }
279}
280
281fn tool_is_idempotent(name: &str) -> bool {
282    // composed only on a failure, never on the path of a message that was answered: the
283    // bridge keeps starting in milliseconds
284    crate::capability::CapabilityRegistry::builder()
285        .with_modules(crate::capability::builtin::modules())
286        .build()
287        .ok()
288        .and_then(|r| r.by_mcp_tool(name).map(|c| c.execution.hints().idempotent))
289        .unwrap_or(false)
290}
291
292/// A stdio session forwarded to a shared server.
293///
294/// Besides the frames, the bridge keeps two things it saw pass through: the client's
295/// `initialize`, so that a session can be re-opened without the client noticing, and the
296/// arguments of every accepted announcement the client has made, so that what the client
297/// said it was working on outlives the server it said it to. An announcement was the one
298/// fact that died with a server: on 2026-09-09 a session's intent vanished from the board
299/// when its transport was re-established, and eight peers could not see it for three hours.
300/// The bridge is the one place that sees both the announcement and the reconnection, so
301/// the bridge replays them — after a re-attach through [`Bridge::reinitialize`], and, for a
302/// takeover, through [`Bridge::announcements`] onto the new server's own board.
303///
304/// *Every* announcement, one per claim name, and not merely the last one: a client may
305/// hold several claims at once, and replaying only the most recent would restore one of
306/// them and quietly drop the rest — which is the same loss the replay exists to prevent,
307/// arriving by a different route.
308#[derive(Debug)]
309pub struct Bridge {
310    url: String,
311    session: Option<String>,
312    initialize: Option<Value>,
313    client: Option<ClientInfo>,
314    /// The arguments of the last accepted announcement per claim name, `""` for the
315    /// unnamed claim. A `BTreeMap` so a replay is in a stable order and re-announcing a
316    /// name replaces rather than appends, exactly as the board itself does.
317    announcements: BTreeMap<String, Value>,
318    /// How long one answer is waited for.
319    timeout: Duration,
320}
321
322impl Bridge {
323    /// A bridge to the server at `url`, with no session yet, waiting [`REQUEST_TIMEOUT`]
324    /// for each answer.
325    pub fn new(url: String) -> Self {
326        Self::with_timeout(url, REQUEST_TIMEOUT)
327    }
328
329    /// A bridge to the server at `url` that waits `timeout` for each answer. A call that
330    /// outlasts it is [`BridgeError::Unanswered`]: it was sent, and whether it ran is unknown.
331    ///
332    /// ```
333    /// use majordomus_cli::mcp::bridge::Bridge;
334    /// use std::time::Duration;
335    /// let bridge = Bridge::with_timeout("http://127.0.0.1:8741".into(), Duration::from_secs(5));
336    /// assert_eq!(bridge.url(), "http://127.0.0.1:8741");
337    /// ```
338    pub fn with_timeout(url: String, timeout: Duration) -> Self {
339        Bridge {
340            url,
341            session: None,
342            initialize: None,
343            client: None,
344            announcements: BTreeMap::new(),
345            timeout,
346        }
347    }
348
349    /// The arguments of every announcement the server accepted, one per claim name, in
350    /// name order: what a takeover replays onto the new server's board.
351    ///
352    /// Empty when the client has announced nothing, which is every bridge that has only
353    /// just been made — nothing has passed through it yet to be remembered.
354    ///
355    /// ```
356    /// use majordomus_cli::mcp::bridge::Bridge;
357    /// let bridge = Bridge::new("http://127.0.0.1:8741/mcp".into());
358    /// assert_eq!(bridge.announcements().count(), 0);
359    /// ```
360    pub fn announcements(&self) -> impl Iterator<Item = &Value> {
361        self.announcements.values()
362    }
363
364    /// The server's URL.
365    pub fn url(&self) -> &str {
366        &self.url
367    }
368
369    /// What the client said in `initialize`, once it has.
370    pub fn client(&self) -> Option<&ClientInfo> {
371        self.client.as_ref()
372    }
373
374    /// Point the bridge at another server; the next message opens a session there.
375    pub fn move_to(&mut self, url: String) {
376        self.url = url;
377        self.session = None;
378    }
379
380    /// Forward one message; `None` when the server had nothing to say (a notification).
381    pub fn handle(&mut self, message: &Value) -> Result<Option<Value>, BridgeError> {
382        if crate::http::mcp::is_initialize(message) {
383            let params = first_initialize_params(message);
384            self.client = Some(ClientInfo::from_initialize(&params));
385            self.initialize = Some(params);
386            self.session = None;
387        }
388        let answer = match self.send(message)? {
389            Sent::Answer(v) => v,
390            Sent::SessionLost => {
391                // the server forgot us (it restarted, or we were idle too long): open a
392                // new session with the client's own initialize and try once more
393                self.reinitialize()?;
394                match self.send(message)? {
395                    Sent::Answer(v) => v,
396                    Sent::SessionLost => {
397                        return Err(BridgeError::Rejected {
398                            url: self.url.clone(),
399                            status: 404,
400                            body: "session lost twice in a row".into(),
401                        })
402                    }
403                }
404            }
405        };
406        if let Some(arguments) = announcement_in(message, answer.as_ref()) {
407            let name = arguments
408                .get("claim")
409                .and_then(Value::as_str)
410                .map(str::trim)
411                .filter(|c| !c.is_empty())
412                .unwrap_or("")
413                .to_string();
414            self.announcements.insert(name, arguments);
415        }
416        Ok(answer)
417    }
418
419    /// Open a new session with the initialize the client sent earlier. A no-op when it
420    /// has not sent one yet.
421    pub fn reinitialize(&mut self) -> Result<(), BridgeError> {
422        let Some(params) = self.initialize.clone() else {
423            return Ok(());
424        };
425        self.session = None;
426        let init = json!({ "jsonrpc": "2.0", "id": "majordomus-bridge-reinitialize", "method": "initialize", "params": params });
427        if let Sent::SessionLost = self.send(&init)? {
428            return Err(BridgeError::Rejected {
429                url: self.url.clone(),
430                status: 404,
431                body: "initialize was not accepted".into(),
432            });
433        }
434        let _ = self.send(&json!({ "jsonrpc": "2.0", "method": "notifications/initialized" }))?;
435        tracing::info!(url = %self.url, "session re-opened on the shared server");
436        // what the client said it was working on is said again, to whoever serves now —
437        // every claim it holds, because restoring one of several is still a loss
438        for arguments in self.announcements.values().cloned().collect::<Vec<_>>() {
439            let replay = json!({
440                "jsonrpc": "2.0",
441                "id": "majordomus-bridge-announce",
442                "method": "tools/call",
443                "params": { "name": crate::capability::builtin::peers::ANNOUNCE_TOOL, "arguments": arguments },
444            });
445            match self.send(&replay) {
446                Ok(Sent::Answer(_)) => {
447                    tracing::info!(url = %self.url, "the client's announcement was replayed to the server it is now attached to")
448                }
449                Ok(Sent::SessionLost) | Err(_) => {
450                    tracing::warn!(url = %self.url, "the client's announcement could not be replayed; the board understates it until it announces again")
451                }
452            }
453        }
454        Ok(())
455    }
456
457    /// Keep the session alive.
458    pub fn heartbeat(&mut self) -> Result<(), BridgeError> {
459        if self.session.is_none() {
460            return Ok(());
461        }
462        self.send(
463            &json!({ "jsonrpc": "2.0", "id": "majordomus-bridge-heartbeat", "method": "ping" }),
464        )
465        .map(|_| ())
466    }
467
468    /// End the session on the server; the client has gone.
469    pub fn close(&mut self) {
470        if let Some(session) = self.session.take() {
471            let _ = request(
472                &self.url,
473                "DELETE",
474                crate::http::mcp::PATH,
475                &[(SESSION_HEADER, &session)],
476                None,
477                CONNECT_TIMEOUT,
478            );
479        }
480    }
481
482    fn send(&mut self, message: &Value) -> Result<Sent, BridgeError> {
483        let session = self.session.clone();
484        let headers: Vec<(&str, &str)> = session
485            .as_deref()
486            .map(|s| vec![(SESSION_HEADER, s)])
487            .unwrap_or_default();
488        let body = message.to_string();
489        // the server would refuse it unread; saying so here keeps the refusal from being
490        // mistaken for a connection the server cut short while the body was still going out
491        if body.len() > crate::http::server::MAX_BODY_BYTES {
492            return Err(BridgeError::Rejected {
493                url: self.url.clone(),
494                status: 413,
495                body: format!(
496                    "the message is {} bytes, over the {} the server accepts; it was not sent",
497                    body.len(),
498                    crate::http::server::MAX_BODY_BYTES
499                ),
500            });
501        }
502        let (stream, host) =
503            connect(&self.url, self.timeout).map_err(|e| BridgeError::Unreachable {
504                url: self.url.clone(),
505                reason: e.to_string(),
506            })?;
507        let reply = exchange(
508            stream,
509            &host,
510            "POST",
511            crate::http::mcp::PATH,
512            &headers,
513            Some(&body),
514            self.timeout,
515        )
516        .map_err(|e| BridgeError::Unanswered {
517            url: self.url.clone(),
518            reason: e.to_string(),
519        })?;
520        if let Some(id) = reply.header(SESSION_HEADER) {
521            self.session = Some(id.to_string());
522        }
523        match reply.status {
524            200 => serde_json::from_str::<Value>(&reply.body)
525                .map(|v| Sent::Answer(Some(v)))
526                .map_err(|e| BridgeError::Unanswered {
527                    url: self.url.clone(),
528                    reason: format!("the answer is not JSON: {e}"),
529                }),
530            202 | 204 => Ok(Sent::Answer(None)),
531            404 => Ok(Sent::SessionLost),
532            // sent without a session by a bridge whose client did initialize (a re-attach
533            // whose initialize failed): refused before the message was looked at, so it is
534            // a session to open again, as a 404 is. A client that never initialized is told.
535            400 if session.is_none()
536                && self.initialize.is_some()
537                && reply.body.contains("session_required") =>
538            {
539                Ok(Sent::SessionLost)
540            }
541            s if s >= 500 => Err(BridgeError::Unanswered {
542                url: self.url.clone(),
543                reason: format!("status {s}: {}", reply.body),
544            }),
545            s => Err(BridgeError::Rejected {
546                url: self.url.clone(),
547                status: s,
548                body: reply.body,
549            }),
550        }
551    }
552}
553
554enum Sent {
555    Answer(Option<Value>),
556    SessionLost,
557}
558
559/// The arguments of an announcement the server accepted: the message is a `tools/call` of
560/// the announce tool, and the answer is a result rather than an error. A batch is not
561/// looked into; a bridge that carried one announcement in a batch replays nothing, which
562/// is what a bridge did for every announcement before this.
563fn announcement_in(message: &Value, answer: Option<&Value>) -> Option<Value> {
564    if message.get("method")?.as_str()? != "tools/call" {
565        return None;
566    }
567    let params = message.get("params")?;
568    if params.get("name")?.as_str()? != crate::capability::builtin::peers::ANNOUNCE_TOOL {
569        return None;
570    }
571    let answer = answer?;
572    if answer.get("error").is_some() || answer["result"]["isError"] == true {
573        return None;
574    }
575    Some(
576        params
577            .get("arguments")
578            .cloned()
579            .unwrap_or_else(|| json!({})),
580    )
581}
582
583fn first_initialize_params(message: &Value) -> Value {
584    match message {
585        Value::Array(batch) => batch
586            .iter()
587            .find(|m| m["method"] == "initialize")
588            .map(|m| m["params"].clone())
589            .unwrap_or(Value::Null),
590        m => m["params"].clone(),
591    }
592}
593
594#[cfg(test)]
595mod tests {
596    use super::*;
597
598    /// A bridge remembers one announcement per claim name, so that a client holding several
599    /// claims has all of them replayed after a re-attach rather than the newest one. Before
600    /// named claims this was a single slot, and a client that announced twice replayed only
601    /// the second — a loss the replay itself exists to prevent, arriving by another route.
602    #[test]
603    fn a_bridge_remembers_one_announcement_per_claim() {
604        let mut bridge = Bridge::new("http://127.0.0.1:1/mcp".into());
605        let call = |claim: Option<&str>, intent: &str| {
606            let mut arguments = json!({ "intent": intent, "scope": ["docs"] });
607            if let Some(c) = claim {
608                arguments["claim"] = json!(c);
609            }
610            json!({
611                "method": "tools/call",
612                "params": { "name": crate::capability::builtin::peers::ANNOUNCE_TOOL, "arguments": arguments },
613            })
614        };
615        let accepted = json!({ "result": { "isError": false } });
616
617        // remembering is what `send` does after an answer; do the same thing here without
618        // a socket, because the remembering is the behaviour under test and not the transport
619        for (claim, intent) in [
620            (None, "the unnamed claim"),
621            (Some("worker-a"), "the guard"),
622            (Some("worker-b"), "the cockpit"),
623            (Some("worker-a"), "the guard, narrowed"),
624        ] {
625            let message = call(claim, intent);
626            let arguments = announcement_in(&message, Some(&accepted)).expect("accepted");
627            let name = arguments
628                .get("claim")
629                .and_then(Value::as_str)
630                .unwrap_or("")
631                .to_string();
632            bridge.announcements.insert(name, arguments);
633        }
634
635        let held: Vec<&str> = bridge
636            .announcements()
637            .map(|a| a["intent"].as_str().unwrap())
638            .collect();
639        assert_eq!(
640            held,
641            vec!["the unnamed claim", "the guard, narrowed", "the cockpit"],
642            "three claims, in name order, and the repeated name updated rather than appended"
643        );
644    }
645
646    /// An announcement the server refused is not remembered, so a takeover does not replay a
647    /// claim the board never accepted.
648    #[test]
649    fn a_refused_announcement_is_not_remembered() {
650        let message = json!({
651            "method": "tools/call",
652            "params": {
653                "name": crate::capability::builtin::peers::ANNOUNCE_TOOL,
654                "arguments": { "intent": "refused", "scope": [] },
655            },
656        });
657        let refused = json!({ "result": { "isError": true } });
658        assert!(announcement_in(&message, Some(&refused)).is_none());
659        let errored = json!({ "error": { "code": -32000 } });
660        assert!(announcement_in(&message, Some(&errored)).is_none());
661    }
662
663    fn call(tool: &str) -> Value {
664        json!({ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": tool } })
665    }
666
667    #[test]
668    fn a_batch_is_idempotent_only_when_every_message_in_it_is() {
669        let read = call("majordomus_repository");
670        let write = call("majordomus_plan_transition");
671        let ping = json!({ "jsonrpc": "2.0", "id": 4, "method": "ping" });
672        assert!(idempotent(&json!([read.clone(), ping])));
673        assert!(!idempotent(&json!([read, write])));
674        assert!(idempotent(&json!([])), "an empty batch runs nothing twice");
675    }
676
677    #[test]
678    fn a_message_over_the_body_bound_is_refused_without_being_sent() {
679        // nothing listens on port 1: a message that were sent would come back unreachable
680        let mut bridge = Bridge::new("http://127.0.0.1:1".into());
681        let pad = "x".repeat(crate::http::server::MAX_BODY_BYTES);
682        let message = json!({ "jsonrpc": "2.0", "id": 9, "method": "tools/call",
683            "params": { "name": "majordomus_repository", "arguments": { "pad": pad } } });
684        let err = bridge.handle(&message).expect_err("refused");
685        match &err {
686            BridgeError::Rejected { status, body, url } => {
687                assert_eq!(*status, 413);
688                assert_eq!(url, "http://127.0.0.1:1");
689                assert!(body.contains("it was not sent"), "{body}");
690                assert!(
691                    body.contains(&crate::http::server::MAX_BODY_BYTES.to_string()),
692                    "{body}"
693                );
694            }
695            other => panic!("refused before a connection was made, not {other:?}"),
696        }
697        assert!(
698            !err.calls_for_an_election(),
699            "too large is too large anywhere"
700        );
701        assert!(!err.may_resend(&message));
702    }
703
704    /// One scripted HTTP answer: status, body, and the session id it opens.
705    type Scripted = (u16, &'static str, Option<&'static str>);
706
707    /// A server that answers each connection with the next scripted reply and records what
708    /// it received: the request line, the session header, and the body.
709    fn scripted(replies: Vec<Scripted>) -> (String, std::thread::JoinHandle<Vec<Value>>) {
710        use std::io::BufRead;
711        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
712        let url = format!("http://{}", listener.local_addr().unwrap());
713        let server = std::thread::spawn(move || {
714            let mut seen = Vec::new();
715            for (status, body, session) in replies {
716                let (stream, _) = listener.accept().unwrap();
717                stream
718                    .set_read_timeout(Some(Duration::from_secs(30)))
719                    .unwrap();
720                let mut reader = std::io::BufReader::new(stream.try_clone().unwrap());
721                let mut line = String::new();
722                reader.read_line(&mut line).unwrap();
723                let (mut length, mut header) = (0usize, Value::Null);
724                loop {
725                    let mut h = String::new();
726                    reader.read_line(&mut h).unwrap();
727                    let h = h.trim_end();
728                    if h.is_empty() {
729                        break;
730                    }
731                    let (k, v) = h.split_once(':').unwrap();
732                    if k.eq_ignore_ascii_case("content-length") {
733                        length = v.trim().parse().unwrap();
734                    }
735                    if k.eq_ignore_ascii_case(SESSION_HEADER) {
736                        header = json!(v.trim());
737                    }
738                }
739                let mut raw = vec![0u8; length];
740                reader.read_exact(&mut raw).unwrap();
741                let message: Value = serde_json::from_slice(&raw).unwrap_or(Value::Null);
742                seen.push(
743                    json!({ "line": line.trim_end(), "session": header, "message": message }),
744                );
745                let mut head = format!(
746                    "HTTP/1.1 {status} X\r\nConnection: close\r\nContent-Length: {}\r\n",
747                    body.len()
748                );
749                if let Some(s) = session {
750                    head.push_str(&format!("{SESSION_HEADER}: {s}\r\n"));
751                }
752                let mut stream = stream;
753                stream
754                    .write_all(format!("{head}\r\n{body}").as_bytes())
755                    .unwrap();
756            }
757            seen
758        });
759        (url, server)
760    }
761
762    fn initialize() -> Value {
763        json!({ "jsonrpc": "2.0", "id": 1, "method": "initialize",
764            "params": { "protocolVersion": "2025-06-18", "capabilities": {},
765                "clientInfo": { "name": "bridge-test", "version": "0" } } })
766    }
767
768    #[test]
769    fn a_session_required_after_initialize_is_opened_again_with_the_clients_initialize() {
770        let answer = r#"{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18"}}"#;
771        let (url, server) = scripted(vec![
772            // refused before it was looked at: no session, although the client initialized
773            (400, r#"{"error":"session_required"}"#, None),
774            // the session the bridge opens again with the client's own initialize
775            (200, answer, Some("s-1")),
776            (202, "", None),
777            // and the message, sent once more on it
778            (200, answer, None),
779        ]);
780        let mut bridge = Bridge::with_timeout(url, Duration::from_secs(30));
781        let got = bridge
782            .handle(&initialize())
783            .expect("answered on the reopened session");
784        assert_eq!(got.unwrap()["result"]["protocolVersion"], "2025-06-18");
785        let seen = server.join().unwrap();
786        let methods: Vec<&str> = seen
787            .iter()
788            .map(|s| s["message"]["method"].as_str().unwrap_or_default())
789            .collect();
790        assert_eq!(
791            methods,
792            vec![
793                "initialize",
794                "initialize",
795                "notifications/initialized",
796                "initialize"
797            ]
798        );
799        assert_eq!(
800            seen[1]["message"]["params"]["clientInfo"]["name"],
801            "bridge-test"
802        );
803        assert_eq!(seen[2]["session"], "s-1", "the reopened session is used");
804        assert_eq!(seen[3]["session"], "s-1");
805    }
806
807    #[test]
808    fn a_client_that_never_initialized_is_told_a_session_is_required() {
809        let (url, server) = scripted(vec![(400, r#"{"error":"session_required"}"#, None)]);
810        let mut bridge = Bridge::with_timeout(url, Duration::from_secs(30));
811        let err = bridge
812            .handle(&json!({ "jsonrpc": "2.0", "id": 2, "method": "ping" }))
813            .expect_err("refused");
814        assert!(
815            matches!(&err, BridgeError::Rejected { status: 400, body, .. } if body.contains("session_required")),
816            "{err:?}"
817        );
818        assert!(!err.calls_for_an_election());
819        assert_eq!(server.join().unwrap().len(), 1, "sent once, never again");
820    }
821
822    #[test]
823    fn a_server_failure_leaves_the_outcome_unknown() {
824        let (url, server) = scripted(vec![(503, "overloaded", None)]);
825        let mut bridge = Bridge::with_timeout(url, Duration::from_secs(30));
826        let write = call("majordomus_plan_transition");
827        let err = bridge.handle(&write).expect_err("not answered");
828        assert!(
829            matches!(&err, BridgeError::Unanswered { reason, .. } if reason.contains("status 503") && reason.contains("overloaded")),
830            "{err:?}"
831        );
832        assert!(err.calls_for_an_election());
833        assert!(!err.may_resend(&write), "a write that may have run");
834        assert_eq!(server.join().unwrap().len(), 1);
835    }
836}