majordomus_cli/mcp/
stdio.rs1use std::io::{BufRead, Read, Write};
11
12use serde_json::Value;
13
14use crate::error::{Error, Result};
15
16use super::protocol::Server;
17
18pub const MAX_LINE_BYTES: usize = crate::http::server::MAX_BODY_BYTES;
21
22pub fn serve<R, W, H, M>(input: R, mut output: W, mut handle: H) -> Result<usize>
37where
38 R: BufRead,
39 W: Write,
40 H: FnMut(Value) -> Option<M>,
41 M: serde::Serialize,
42{
43 let mut answered = 0;
44 let mut input = input;
45 loop {
46 let mut raw = Vec::new();
49 let read = (&mut input)
50 .take(MAX_LINE_BYTES as u64 + 1)
51 .read_until(b'\n', &mut raw)
52 .map_err(Error::Transport)?;
53 if read == 0 {
54 break;
55 }
56 if raw.len() > MAX_LINE_BYTES && !raw.ends_with(b"\n") {
57 input.skip_until(b'\n').map_err(Error::Transport)?;
58 write_line(
59 &mut output,
60 &Server::parse_error(&format!(
61 "the message is over the {MAX_LINE_BYTES}-byte bound and was not read"
62 )),
63 )?;
64 answered += 1;
65 continue;
66 }
67 let line = match String::from_utf8(raw) {
68 Ok(l) => l,
69 Err(_) => {
70 write_line(&mut output, &Server::parse_error("input is not UTF-8"))?;
71 continue;
72 }
73 };
74 if line.trim().is_empty() {
75 continue;
76 }
77 let written = match serde_json::from_str::<Value>(&line) {
78 Ok(message) => match handle(message) {
79 Some(response) => write_line(&mut output, &response),
80 None => continue,
81 },
82 Err(e) => write_line(&mut output, &Server::parse_error(&e.to_string())),
83 };
84 match written {
85 Ok(()) => answered += 1,
86 Err(Error::Transport(e)) if e.kind() == std::io::ErrorKind::BrokenPipe => {
87 tracing::info!("client closed the output; stopping");
88 return Ok(answered);
89 }
90 Err(e) => return Err(e),
91 }
92 }
93 tracing::info!(answered, "input ended; stopping");
94 Ok(answered)
95}
96
97fn write_line<W: Write, M: serde::Serialize>(output: &mut W, message: &M) -> Result<()> {
98 let mut text = serde_json::to_string(message).map_err(|e| Error::Protocol {
99 reason: e.to_string(),
100 })?;
101 text.push('\n');
102 output
103 .write_all(text.as_bytes())
104 .map_err(Error::Transport)?;
105 output.flush().map_err(Error::Transport)
106}
107
108#[cfg(test)]
109mod tests {
110 use std::io::{BufReader, Cursor, Read, Write};
111
112 use serde_json::{json, Value};
113
114 use super::*;
115
116 struct Broken;
118
119 impl Read for Broken {
120 fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
121 Err(std::io::Error::other("the input is gone"))
122 }
123 }
124
125 impl Write for Broken {
126 fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
127 Err(std::io::Error::other("the output is gone"))
128 }
129
130 fn flush(&mut self) -> std::io::Result<()> {
131 Err(std::io::Error::other("the output is gone"))
132 }
133 }
134
135 fn echo(m: Value) -> Option<Value> {
136 Some(json!({ "jsonrpc": "2.0", "id": m["id"], "result": {} }))
137 }
138
139 #[test]
140 fn an_oversized_message_is_answered_as_a_parse_error_and_the_session_goes_on() {
141 let mut input = vec![b'x'; MAX_LINE_BYTES + 10];
142 input.extend_from_slice(b"\n{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"ping\"}\n");
143 let mut out = Vec::new();
144 let answered = serve(Cursor::new(input), &mut out, echo).unwrap();
145 assert_eq!(answered, 2, "the refusal and the next message");
146 let lines: Vec<Value> = std::str::from_utf8(&out)
147 .unwrap()
148 .lines()
149 .map(|l| serde_json::from_str(l).unwrap())
150 .collect();
151 assert_eq!(lines[0]["id"], Value::Null);
152 assert_eq!(lines[0]["error"]["code"], -32700);
153 let text = lines[0]["error"]["message"].as_str().unwrap();
154 assert!(
155 text.contains("bound") && text.contains("not read"),
156 "{text}"
157 );
158 assert_eq!(lines[1]["id"], 2, "the message after it is answered");
159 }
160
161 #[test]
162 fn an_input_that_fails_ends_the_session_with_a_transport_error() {
163 let result = serve(BufReader::new(Broken), Vec::new(), echo);
164 assert!(matches!(result, Err(Error::Transport(_))), "{result:?}");
165 }
166
167 #[test]
168 fn an_input_that_fails_inside_an_oversized_message_ends_the_session() {
169 let input = Cursor::new(vec![b'x'; MAX_LINE_BYTES + 10]).chain(Broken);
171 let mut out = Vec::new();
172 let result = serve(BufReader::new(input), &mut out, echo);
173 assert!(matches!(result, Err(Error::Transport(_))), "{result:?}");
174 assert!(
175 out.is_empty(),
176 "nothing is answered for a line never read to its end"
177 );
178 }
179
180 #[test]
181 fn an_output_that_fails_on_the_refusal_of_an_oversized_message_ends_the_session() {
182 let mut input = vec![b'x'; MAX_LINE_BYTES + 10];
183 input.push(b'\n');
184 let result = serve(Cursor::new(input), Broken, echo);
185 assert!(matches!(result, Err(Error::Transport(_))), "{result:?}");
186 }
187}