Skip to main content

sioul/mcp/
transport.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! How MCP messages travel, apart from what they say (MCP 2025-06-18,
5//! "Transports"). The stdio transport is the one Claude Code and Claude
6//! Desktop use: the client starts `sioul mcp` and writes one JSON-RPC message
7//! per line on its standard input; each answer is one line on its standard
8//! output. Streamable HTTP (one POST per message, for ChatGPT and remote
9//! clients) will be another `Transport` over the same `protocol::Server`.
10
11use super::protocol::{self, Server};
12use std::io::{BufRead, Read, Write};
13
14/// What came: one message, or a line that could not be taken (too long,
15/// not UTF-8), which is said to the client before the next one is read.
16pub enum Incoming {
17    Message(String),
18    Unreadable(&'static str),
19}
20
21/// Where messages come from and go to.
22pub trait Transport {
23    /// The next message, without its line ending; None once the client has gone.
24    fn receive(&mut self) -> Result<Option<Incoming>, String>;
25    /// Sends one message, whole, at once.
26    fn send(&mut self, message: &str) -> Result<(), String>;
27}
28
29/// Answers every message until the client goes: requests get their response,
30/// notifications nothing.
31pub fn serve(server: &mut Server, transport: &mut dyn Transport) -> Result<(), String> {
32    while let Some(incoming) = transport.receive()? {
33        let answer = match incoming {
34            Incoming::Message(message) if message.trim().is_empty() => continue,
35            Incoming::Message(message) => server.handle(&message),
36            Incoming::Unreadable(why) => Some(protocol::unreadable(why)),
37        };
38        if let Some(answer) = answer {
39            transport.send(&answer)?;
40        }
41    }
42    Ok(())
43}
44
45/// The longest message taken, in bytes: a note or a draft is far shorter,
46/// and a client that sends more cannot fill the memory with one line.
47const LONGEST_LINE: usize = 8 << 20;
48
49/// Standard input and output: newline-delimited JSON, as MCP's stdio transport says.
50pub struct Stdio {
51    input: std::io::StdinLock<'static>,
52    output: std::io::Stdout,
53}
54
55impl Stdio {
56    pub fn new() -> Stdio {
57        Stdio { input: std::io::stdin().lock(), output: std::io::stdout() }
58    }
59}
60
61impl Transport for Stdio {
62    fn receive(&mut self) -> Result<Option<Incoming>, String> {
63        read_message(&mut self.input, LONGEST_LINE)
64    }
65
66    fn send(&mut self, message: &str) -> Result<(), String> {
67        // A message holds no line break of its own: serde_json escapes them in strings.
68        let mut out = self.output.lock();
69        writeln!(out, "{message}").and_then(|()| out.flush()).map_err(|e| format!("standard output: {e}"))
70    }
71}
72
73/// One line of `input`, at most `longest` bytes; the rest of a longer one skipped.
74fn read_message(input: &mut impl BufRead, longest: usize) -> Result<Option<Incoming>, String> {
75    let fail = |e: std::io::Error| format!("standard input: {e}");
76    let mut line = Vec::new();
77    let read = (&mut *input).take(longest as u64 + 1).read_until(b'\n', &mut line).map_err(fail)?;
78    if read == 0 {
79        return Ok(None);
80    }
81    if line.len() > longest && line.last() != Some(&b'\n') {
82        // The rest of that line, up to its end, left unread.
83        loop {
84            let buffer = input.fill_buf().map_err(fail)?;
85            if buffer.is_empty() {
86                break;
87            }
88            match buffer.iter().position(|&b| b == b'\n') {
89                Some(at) => {
90                    input.consume(at + 1);
91                    break;
92                }
93                None => {
94                    let length = buffer.len();
95                    input.consume(length);
96                }
97            }
98        }
99        return Ok(Some(Incoming::Unreadable("A message is one line of at most 8 MiB.")));
100    }
101    while matches!(line.last(), Some(b'\n' | b'\r')) {
102        line.pop();
103    }
104    Ok(Some(match String::from_utf8(line) {
105        Ok(text) => Incoming::Message(text),
106        Err(_) => Incoming::Unreadable("A message is UTF-8 text."),
107    }))
108}
109
110/// Messages given in advance, and what was answered: for tests.
111#[cfg(test)]
112pub struct Script {
113    pub incoming: std::collections::VecDeque<String>,
114    pub outgoing: Vec<String>,
115}
116
117#[cfg(test)]
118impl Transport for Script {
119    fn receive(&mut self) -> Result<Option<Incoming>, String> {
120        Ok(self.incoming.pop_front().map(Incoming::Message))
121    }
122
123    fn send(&mut self, message: &str) -> Result<(), String> {
124        self.outgoing.push(message.to_string());
125        Ok(())
126    }
127}
128
129/// Bytes given in advance, read as standard input is: for tests.
130#[cfg(test)]
131pub struct Bytes<'a> {
132    pub input: &'a [u8],
133    pub longest: usize,
134    pub outgoing: Vec<String>,
135}
136
137#[cfg(test)]
138impl Transport for Bytes<'_> {
139    fn receive(&mut self) -> Result<Option<Incoming>, String> {
140        read_message(&mut self.input, self.longest)
141    }
142
143    fn send(&mut self, message: &str) -> Result<(), String> {
144        self.outgoing.push(message.to_string());
145        Ok(())
146    }
147}