Skip to main content

mcpmesh_codec/
lib.rs

1//! mcpmesh-codec: the family's ONE wire codec (mcpmesh spec §7.3) — compact JSON, UTF-8,
2//! one frame per `\n`, 16 MiB cap.
3//!
4//! Both ends of every wire share THIS implementation: the daemon side re-exports it as
5//! `mcpmesh_net::framing`, and the no-iroh client side (kb, loc, the host shell) as
6//! `mcpmesh_local_api::codec` — so the two ends can never drift apart. Deliberately tiny:
7//! serde_json + tokio io traits only, NO iroh. Session POLICY (violation strikes,
8//! synthesized error frames) stays with the session owners (`mcpmesh-net`); only the pure
9//! codec lives here.
10use serde_json::Value;
11use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
12
13/// 16 MiB — the family frame cap (mcpmesh §7.3, spec §12 default). App payloads are
14/// ≤1 MiB by policy.
15pub const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
16
17/// One inbound frame, or a framing violation (kept distinct so callers can log/strike).
18#[derive(Debug)]
19pub enum Inbound {
20    Frame(Value),
21    Violation(Violation),
22}
23
24#[derive(Debug, PartialEq, Eq)]
25pub enum Violation {
26    TooLarge,
27    InvalidJson,
28}
29
30/// NDJSON frame reader over any `AsyncRead`. Reads byte-at-a-time from an INTERNAL
31/// `BufReader` (misuse-proof: callers cannot forget the buffering and pay a
32/// poll-per-byte penalty).
33///
34/// `next()` is cancellation-safe: all parse state lives in `self`; dropping the
35/// future loses no bytes.
36#[derive(Debug)]
37pub struct FrameReader<R> {
38    reader: BufReader<R>,
39    max_frame: usize,
40    buf: Vec<u8>,
41    /// true while skipping the remainder of an oversized line
42    discarding: bool,
43}
44
45impl<R: AsyncRead + Unpin> FrameReader<R> {
46    pub fn new(reader: R, max_frame: usize) -> Self {
47        debug_assert!(max_frame > 0);
48        Self {
49            reader: BufReader::new(reader),
50            max_frame,
51            buf: Vec::new(),
52            discarding: false,
53        }
54    }
55
56    /// Unwrap to the BUFFERED reader half. Returns the internal `BufReader` — NOT the
57    /// raw stream — so bytes already read ahead (e.g. a frame the peer pipelined behind
58    /// the one just parsed) travel WITH the reader instead of being silently dropped.
59    /// Call this only BETWEEN frames: any partially-accumulated frame bytes in the
60    /// codec's own line buffer are discarded (immediately after a successful `next()`
61    /// that buffer is empty, so the hand-off is lossless).
62    pub fn into_inner(self) -> BufReader<R> {
63        self.reader
64    }
65
66    /// `Ok(None)` = clean EOF. Violations do not consume the following frames.
67    ///
68    /// EOF while discarding an oversized line still reports `TooLarge` (the next
69    /// call returns `Ok(None)`). A truncated ORDINARY frame at EOF is silently
70    /// dropped (matches MCP stdio convention — deliberate).
71    pub async fn next(&mut self) -> std::io::Result<Option<Inbound>> {
72        let mut byte = [0u8; 1];
73        loop {
74            let n = self.reader.read(&mut byte).await?;
75            if n == 0 {
76                if std::mem::take(&mut self.discarding) {
77                    return Ok(Some(Inbound::Violation(Violation::TooLarge)));
78                }
79                return Ok(None);
80            }
81            if byte[0] == b'\n' {
82                if std::mem::take(&mut self.discarding) {
83                    return Ok(Some(Inbound::Violation(Violation::TooLarge)));
84                }
85                // Re-grows from zero each frame by design: no 16MiB pinned per idle
86                // session; do not "optimize" into buffer retention (M4 note).
87                let line = std::mem::take(&mut self.buf);
88                // An empty line is InvalidJson (-32700) and strikes: recorded decision.
89                return Ok(Some(match serde_json::from_slice::<Value>(&line) {
90                    Ok(v) => Inbound::Frame(v),
91                    Err(_) => Inbound::Violation(Violation::InvalidJson),
92                }));
93            }
94            if self.discarding {
95                continue;
96            }
97            if self.buf.len() >= self.max_frame {
98                self.discarding = true;
99                self.buf.clear();
100                continue;
101            }
102            self.buf.push(byte[0]);
103        }
104    }
105}
106
107/// Serialize `frame` and write it terminated by `\n`, flushing (a BufWriter-safe no-op
108/// for unbuffered writers — makes BufWriter wrapping misuse-proof).
109pub async fn write_frame<W: AsyncWrite + Unpin>(w: &mut W, frame: &Value) -> std::io::Result<()> {
110    let mut line = serde_json::to_vec(frame)?;
111    line.push(b'\n');
112    w.write_all(&line).await?;
113    w.flush().await
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use serde_json::json;
120
121    fn reader_over(bytes: &[u8], cap: usize) -> FrameReader<&[u8]> {
122        FrameReader::new(bytes, cap)
123    }
124
125    #[tokio::test]
126    async fn valid_frames_roundtrip() {
127        let mut buf = Vec::new();
128        write_frame(&mut buf, &json!({"jsonrpc":"2.0","id":1,"method":"ping"}))
129            .await
130            .unwrap();
131        write_frame(&mut buf, &json!({"jsonrpc":"2.0","id":2,"method":"pong"}))
132            .await
133            .unwrap();
134        let mut r = reader_over(&buf, 1024);
135        assert!(matches!(r.next().await.unwrap().unwrap(), Inbound::Frame(v) if v["id"] == 1));
136        assert!(matches!(r.next().await.unwrap().unwrap(), Inbound::Frame(v) if v["id"] == 2));
137        assert!(r.next().await.unwrap().is_none()); // clean EOF
138    }
139
140    #[tokio::test]
141    async fn oversized_frame_is_discarded_and_reported_and_stream_continues() {
142        let big = format!("{{\"pad\":\"{}\"}}\n", "x".repeat(100));
143        let mut bytes = big.into_bytes();
144        bytes.extend_from_slice(b"{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ok\"}\n");
145        let mut r = reader_over(&bytes, 64);
146        assert!(matches!(
147            r.next().await.unwrap().unwrap(),
148            Inbound::Violation(Violation::TooLarge)
149        ));
150        assert!(matches!(r.next().await.unwrap().unwrap(), Inbound::Frame(v) if v["id"] == 7));
151    }
152
153    #[tokio::test]
154    async fn invalid_json_is_a_violation() {
155        let mut r = reader_over(b"not json at all\n", 1024);
156        assert!(matches!(
157            r.next().await.unwrap().unwrap(),
158            Inbound::Violation(Violation::InvalidJson)
159        ));
160    }
161
162    #[tokio::test]
163    async fn oversized_frame_truncated_by_eof_is_still_reported() {
164        let bytes = format!("{{\"pad\":\"{}\"}}", "x".repeat(100)).into_bytes(); // no newline
165        let mut r = reader_over(&bytes, 64);
166        assert!(matches!(
167            r.next().await.unwrap().unwrap(),
168            Inbound::Violation(Violation::TooLarge)
169        ));
170        assert!(r.next().await.unwrap().is_none());
171    }
172
173    #[tokio::test]
174    async fn back_to_back_oversized_frames_then_valid_frame() {
175        let big = format!("{{\"pad\":\"{}\"}}\n", "x".repeat(100));
176        let mut bytes = big.clone().into_bytes();
177        bytes.extend_from_slice(big.as_bytes());
178        bytes.extend_from_slice(b"{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"ok\"}\n");
179        let mut r = reader_over(&bytes, 64);
180        assert!(matches!(
181            r.next().await.unwrap().unwrap(),
182            Inbound::Violation(Violation::TooLarge)
183        ));
184        assert!(matches!(
185            r.next().await.unwrap().unwrap(),
186            Inbound::Violation(Violation::TooLarge)
187        ));
188        assert!(matches!(r.next().await.unwrap().unwrap(), Inbound::Frame(v) if v["id"] == 9));
189    }
190
191    #[tokio::test]
192    async fn frame_of_exactly_max_frame_bytes_passes_one_more_is_too_large() {
193        // A bare JSON string is a valid frame; 62 x's + 2 quotes = exactly 64 bytes
194        // before the newline terminator (the newline is not counted against the cap).
195        let payload = "x".repeat(62);
196        let exact = format!("\"{payload}\"\n");
197        let mut r = reader_over(exact.as_bytes(), 64);
198        assert!(matches!(r.next().await.unwrap().unwrap(), Inbound::Frame(v) if v == payload));
199
200        let over = format!("\"{}\"\n", "x".repeat(63)); // 65 bytes before the newline
201        let mut r = reader_over(over.as_bytes(), 64);
202        assert!(matches!(
203            r.next().await.unwrap().unwrap(),
204            Inbound::Violation(Violation::TooLarge)
205        ));
206    }
207
208    /// `into_inner` hands back the internal `BufReader`, so read-ahead the codec already
209    /// pulled off the stream (a pipelined second frame) survives the hand-off and is
210    /// readable through a NEW FrameReader over the returned reader. This is the
211    /// lossless-rebox contract kb's mesh opener relies on (mcpmesh-local/1 OpenSession).
212    #[tokio::test]
213    async fn into_inner_preserves_pipelined_read_ahead() {
214        let bytes = b"{\"id\":1}\n{\"id\":2}\n";
215        let mut r = reader_over(bytes, 1024);
216        // Frame 1 consumed; the internal BufReader has (with an 8 KiB buffer over a
217        // ready slice) already read frame 2 ahead.
218        assert!(matches!(r.next().await.unwrap().unwrap(), Inbound::Frame(v) if v["id"] == 1));
219        // Re-box exactly like kb does: into_inner -> Box<dyn AsyncRead> -> new reader.
220        let boxed: Box<dyn AsyncRead + Unpin + Send> = Box::new(r.into_inner());
221        let mut r2 = FrameReader::new(boxed, 1024);
222        assert!(matches!(r2.next().await.unwrap().unwrap(), Inbound::Frame(v) if v["id"] == 2));
223        assert!(r2.next().await.unwrap().is_none());
224    }
225}