liminal_cli/mcp/
framing.rs1use std::io::{self, BufRead, Read, Write};
9
10use serde_json::{Map, Value, json};
11
12#[derive(Clone, Debug, PartialEq, Eq)]
14pub enum Incoming {
15 Request {
17 id: Value,
19 method: String,
21 params: Map<String, Value>,
23 },
24 Notification {
26 method: String,
28 params: Map<String, Value>,
30 },
31}
32
33#[derive(Debug, thiserror::Error, PartialEq, Eq)]
35pub enum FrameError {
36 #[error("a line over {limit} bytes was refused")]
38 Overlong {
39 limit: usize,
41 },
42 #[error("the line is not JSON: {message}")]
44 NotJson {
45 message: String,
47 },
48 #[error("the line is not a JSON-RPC 2.0 request: {message}")]
50 NotRequest {
51 message: &'static str,
53 },
54 #[error("the host stream failed: {message}")]
56 Stream {
57 message: String,
59 },
60}
61
62pub const PARSE_ERROR: i64 = -32700;
64pub const INVALID_REQUEST: i64 = -32600;
66pub const METHOD_NOT_FOUND: i64 = -32601;
68pub const INVALID_PARAMS: i64 = -32602;
70pub const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022;
72
73pub 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
123pub 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#[must_use]
172pub fn result(id: &Value, result: &Value) -> Value {
173 json!({ "jsonrpc": "2.0", "id": id, "result": result })
174}
175
176#[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
187pub 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;