Skip to main content

liminal_cli/mcp/
framing.rs

1//! Newline-delimited JSON-RPC framing for the stdio binding.
2//!
3//! Bounded newline-delimited JSON-RPC over a byte stream (SDK-017 R1, the
4//! stdio binding SDK-014 R2 names): one message per line, no embedded
5//! newline, a line over the limit refused by name and drained so the next
6//! line is read whole.
7
8use std::io::{self, BufRead, Read, Write};
9
10use serde_json::{Map, Value, json};
11
12/// A message read from the host.
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum Incoming {
15    /// A request: answered on its id.
16    Request {
17        /// The JSON-RPC id, a string or a number, never null.
18        id: Value,
19        /// The method.
20        method: String,
21        /// The params object, empty when absent.
22        params: Map<String, Value>,
23    },
24    /// A notification: never answered.
25    Notification {
26        /// The method.
27        method: String,
28        /// The params object, empty when absent.
29        params: Map<String, Value>,
30    },
31}
32
33/// Why a line could not be read as a message.
34#[derive(Debug, thiserror::Error, PartialEq, Eq)]
35pub enum FrameError {
36    /// The line is longer than the limit; it was drained.
37    #[error("a line over {limit} bytes was refused")]
38    Overlong {
39        /// The configured limit.
40        limit: usize,
41    },
42    /// The line is not JSON.
43    #[error("the line is not JSON: {message}")]
44    NotJson {
45        /// The parser's words.
46        message: String,
47    },
48    /// The line is JSON but not a JSON-RPC 2.0 request or notification.
49    #[error("the line is not a JSON-RPC 2.0 request: {message}")]
50    NotRequest {
51        /// What is wrong with it.
52        message: &'static str,
53    },
54    /// The host closed its end or the stream failed.
55    #[error("the host stream failed: {message}")]
56    Stream {
57        /// The operating-system failure.
58        message: String,
59    },
60}
61
62/// JSON-RPC's parse error code.
63pub const PARSE_ERROR: i64 = -32700;
64/// JSON-RPC's invalid-request code.
65pub const INVALID_REQUEST: i64 = -32600;
66/// JSON-RPC's method-not-found code.
67pub const METHOD_NOT_FOUND: i64 = -32601;
68/// JSON-RPC's invalid-params code.
69pub const INVALID_PARAMS: i64 = -32602;
70/// MCP 2026-07-28's unsupported-protocol-version code.
71pub const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022;
72
73/// Reads one line of at most `limit` bytes. `Ok(None)` is the host closing
74/// its end. A line over the limit is drained to its newline and refused.
75///
76/// # Errors
77/// Returns the overlong, not-JSON, not-a-request or stream failure.
78pub fn read_message<R: BufRead>(
79    reader: &mut R,
80    limit: usize,
81) -> Result<Option<Incoming>, FrameError> {
82    let mut line = Vec::new();
83    let read = Read::by_ref(reader)
84        .take(limit as u64 + 1)
85        .read_until(b'\n', &mut line)
86        .map_err(|e| FrameError::Stream {
87            message: e.to_string(),
88        })?;
89    if read == 0 {
90        return Ok(None);
91    }
92    if line.len() > limit {
93        drain_to_newline(reader)?;
94        return Err(FrameError::Overlong { limit });
95    }
96    let text = String::from_utf8_lossy(&line);
97    let text = text.trim_end_matches(['\r', '\n']);
98    if text.trim().is_empty() {
99        return read_message(reader, limit);
100    }
101    let value: Value = serde_json::from_str(text).map_err(|e| FrameError::NotJson {
102        message: e.to_string(),
103    })?;
104    parse(value).map(Some)
105}
106
107fn drain_to_newline<R: BufRead>(reader: &mut R) -> Result<(), FrameError> {
108    let mut scratch = Vec::new();
109    loop {
110        scratch.clear();
111        let read = Read::by_ref(reader)
112            .take(4096)
113            .read_until(b'\n', &mut scratch)
114            .map_err(|e| FrameError::Stream {
115                message: e.to_string(),
116            })?;
117        if read == 0 || scratch.last() == Some(&b'\n') {
118            return Ok(());
119        }
120    }
121}
122
123/// A JSON value as a JSON-RPC 2.0 request or notification.
124///
125/// # Errors
126/// Returns why the value is neither.
127pub fn parse(value: Value) -> Result<Incoming, FrameError> {
128    let Value::Object(mut object) = value else {
129        return Err(FrameError::NotRequest {
130            message: "not an object",
131        });
132    };
133    if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
134        return Err(FrameError::NotRequest {
135            message: "jsonrpc is not \"2.0\"",
136        });
137    }
138    let Some(method) = object
139        .get("method")
140        .and_then(Value::as_str)
141        .map(str::to_owned)
142    else {
143        return Err(FrameError::NotRequest {
144            message: "method is missing or not a string",
145        });
146    };
147    let params = match object.remove("params") {
148        None | Some(Value::Null) => Map::new(),
149        Some(Value::Object(params)) => params,
150        Some(_) => {
151            return Err(FrameError::NotRequest {
152                message: "params is not an object",
153            });
154        }
155    };
156    match object.remove("id") {
157        None => Ok(Incoming::Notification { method, params }),
158        Some(Value::Null) => Err(FrameError::NotRequest {
159            message: "id is null",
160        }),
161        Some(id @ (Value::String(_) | Value::Number(_))) => {
162            Ok(Incoming::Request { id, method, params })
163        }
164        Some(_) => Err(FrameError::NotRequest {
165            message: "id is not a string or a number",
166        }),
167    }
168}
169
170/// A result response for `id`.
171#[must_use]
172pub fn result(id: &Value, result: &Value) -> Value {
173    json!({ "jsonrpc": "2.0", "id": id, "result": result })
174}
175
176/// An error response for `id` (`null` when the request's id could not be
177/// read), with optional `data`.
178#[must_use]
179pub fn error(id: &Value, code: i64, message: &str, data: Option<Value>) -> Value {
180    let mut error = json!({ "code": code, "message": message });
181    if let (Some(data), Some(object)) = (data, error.as_object_mut()) {
182        object.insert("data".to_owned(), data);
183    }
184    json!({ "jsonrpc": "2.0", "id": id, "error": error })
185}
186
187/// Writes one message as one line and flushes it. The message is serialised
188/// by `serde_json`, which never emits a raw newline inside a string.
189///
190/// # Errors
191/// Returns the stream failure.
192pub fn write_message<W: Write>(writer: &mut W, message: &Value) -> io::Result<()> {
193    let mut line = serde_json::to_vec(message)?;
194    line.push(b'\n');
195    writer.write_all(&line)?;
196    writer.flush()
197}
198
199#[cfg(test)]
200#[path = "framing_tests.rs"]
201mod tests;