Skip to main content

majordomus_cli/mcp/
stdio.rs

1//! The stdio transport: one JSON message per line in, one per line out. Nothing but
2//! protocol messages is ever written to the output; diagnostics go through `tracing`,
3//! which the executable points at stderr. EOF on input ends the session cleanly, and so
4//! does a closed output, because both mean the client has gone.
5//!
6//! The loop does not know what answers a message: a local [`Server`], or a bridge to the
7//! shared server of another process, is a function from a message to an optional
8//! response, and the loop drives whichever it is given.
9
10use std::io::{BufRead, Read, Write};
11
12use serde_json::Value;
13
14use crate::error::{Error, Result};
15
16use super::protocol::Server;
17
18/// The longest message a client may send on one line: the bound a request body has over
19/// HTTP, so that both transports refuse the same message (I2147).
20pub const MAX_LINE_BYTES: usize = crate::http::server::MAX_BODY_BYTES;
21
22/// Serve until the input ends, answering every message through `handle`. Returns the
23/// number of messages answered.
24///
25/// ```
26/// use majordomus_cli::mcp::stdio;
27/// use serde_json::json;
28/// let input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\nnot json\n\n";
29/// let mut out = Vec::new();
30/// let n = stdio::serve(&input[..], &mut out, |m| Some(json!({ "jsonrpc": "2.0", "id": m["id"], "result": {} }))).unwrap();
31/// let lines: Vec<&str> = std::str::from_utf8(&out).unwrap().lines().collect();
32/// assert_eq!(n, 2, "one answer, one parse error");
33/// assert!(lines[0].contains("\"result\""));
34/// assert!(lines[1].contains("-32700"));
35/// ```
36pub 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        // one line at most MAX_LINE_BYTES long: a longer one is skipped to its newline without
47        // being held, answered as a parse error, and the session goes on (I2147)
48        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    /// A source or sink that fails on every call, as a closed descriptor does.
117    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        // over the bound, and the input breaks while the rest of the line is skipped
170        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}