1use 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
26pub const HEARTBEAT: Duration = Duration::from_secs(20);
28
29pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
31
32pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(60);
34
35const SESSION_HEADER: &str = "Mcp-Session-Id";
36
37#[derive(Debug, Clone)]
39pub struct Reply {
40 pub status: u16,
42 pub headers: Vec<(String, String)>,
44 pub body: String,
46}
47
48impl Reply {
49 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
59pub const MAX_REPLY_BYTES: usize = 32 * 1024 * 1024;
63
64pub 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
77fn 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 let stream = TcpStream::connect_timeout(&addr, CONNECT_TIMEOUT.min(timeout))?;
91 Ok((stream, host.to_string()))
92}
93
94fn 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#[derive(Debug, Clone, thiserror::Error)]
165pub enum BridgeError {
166 #[error("the shared server at {url} is unreachable: {reason}")]
167 Unreachable {
169 url: String,
171 reason: String,
173 },
174 #[error("the shared server at {url} did not answer: {reason}")]
175 Unanswered {
179 url: String,
181 reason: String,
183 },
184 #[error("the shared server at {url} rejected the request ({status}): {body}")]
185 Rejected {
187 url: String,
189 status: u16,
191 body: String,
193 },
194}
195
196impl BridgeError {
197 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 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
252pub 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 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#[derive(Debug)]
309pub struct Bridge {
310 url: String,
311 session: Option<String>,
312 initialize: Option<Value>,
313 client: Option<ClientInfo>,
314 announcements: BTreeMap<String, Value>,
318 timeout: Duration,
320}
321
322impl Bridge {
323 pub fn new(url: String) -> Self {
326 Self::with_timeout(url, REQUEST_TIMEOUT)
327 }
328
329 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 pub fn announcements(&self) -> impl Iterator<Item = &Value> {
361 self.announcements.values()
362 }
363
364 pub fn url(&self) -> &str {
366 &self.url
367 }
368
369 pub fn client(&self) -> Option<&ClientInfo> {
371 self.client.as_ref()
372 }
373
374 pub fn move_to(&mut self, url: String) {
376 self.url = url;
377 self.session = None;
378 }
379
380 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(¶ms));
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 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 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 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 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 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 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 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
559fn 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 #[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 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 #[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 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 type Scripted = (u16, &'static str, Option<&'static str>);
706
707 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 (400, r#"{"error":"session_required"}"#, None),
774 (200, answer, Some("s-1")),
776 (202, "", None),
777 (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}