liminal-cli 0.3.1

The liminal command: serve the network, maintain its stores, or serve its discovery MCP over stdio
Documentation
//! Newline-delimited JSON-RPC framing for the stdio binding.
//!
//! Bounded newline-delimited JSON-RPC over a byte stream (SDK-017 R1, the
//! stdio binding SDK-014 R2 names): one message per line, no embedded
//! newline, a line over the limit refused by name and drained so the next
//! line is read whole.

use std::io::{self, BufRead, Read, Write};

use serde_json::{Map, Value, json};

/// A message read from the host.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Incoming {
    /// A request: answered on its id.
    Request {
        /// The JSON-RPC id, a string or a number, never null.
        id: Value,
        /// The method.
        method: String,
        /// The params object, empty when absent.
        params: Map<String, Value>,
    },
    /// A notification: never answered.
    Notification {
        /// The method.
        method: String,
        /// The params object, empty when absent.
        params: Map<String, Value>,
    },
}

/// Why a line could not be read as a message.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum FrameError {
    /// The line is longer than the limit; it was drained.
    #[error("a line over {limit} bytes was refused")]
    Overlong {
        /// The configured limit.
        limit: usize,
    },
    /// The line is not JSON.
    #[error("the line is not JSON: {message}")]
    NotJson {
        /// The parser's words.
        message: String,
    },
    /// The line is JSON but not a JSON-RPC 2.0 request or notification.
    #[error("the line is not a JSON-RPC 2.0 request: {message}")]
    NotRequest {
        /// What is wrong with it.
        message: &'static str,
    },
    /// The host closed its end or the stream failed.
    #[error("the host stream failed: {message}")]
    Stream {
        /// The operating-system failure.
        message: String,
    },
}

/// JSON-RPC's parse error code.
pub const PARSE_ERROR: i64 = -32700;
/// JSON-RPC's invalid-request code.
pub const INVALID_REQUEST: i64 = -32600;
/// JSON-RPC's method-not-found code.
pub const METHOD_NOT_FOUND: i64 = -32601;
/// JSON-RPC's invalid-params code.
pub const INVALID_PARAMS: i64 = -32602;
/// MCP 2026-07-28's unsupported-protocol-version code.
pub const UNSUPPORTED_PROTOCOL_VERSION: i64 = -32022;

/// Reads one line of at most `limit` bytes. `Ok(None)` is the host closing
/// its end. A line over the limit is drained to its newline and refused.
///
/// # Errors
/// Returns the overlong, not-JSON, not-a-request or stream failure.
pub fn read_message<R: BufRead>(
    reader: &mut R,
    limit: usize,
) -> Result<Option<Incoming>, FrameError> {
    let mut line = Vec::new();
    let read = Read::by_ref(reader)
        .take(limit as u64 + 1)
        .read_until(b'\n', &mut line)
        .map_err(|e| FrameError::Stream {
            message: e.to_string(),
        })?;
    if read == 0 {
        return Ok(None);
    }
    if line.len() > limit {
        drain_to_newline(reader)?;
        return Err(FrameError::Overlong { limit });
    }
    let text = String::from_utf8_lossy(&line);
    let text = text.trim_end_matches(['\r', '\n']);
    if text.trim().is_empty() {
        return read_message(reader, limit);
    }
    let value: Value = serde_json::from_str(text).map_err(|e| FrameError::NotJson {
        message: e.to_string(),
    })?;
    parse(value).map(Some)
}

fn drain_to_newline<R: BufRead>(reader: &mut R) -> Result<(), FrameError> {
    let mut scratch = Vec::new();
    loop {
        scratch.clear();
        let read = Read::by_ref(reader)
            .take(4096)
            .read_until(b'\n', &mut scratch)
            .map_err(|e| FrameError::Stream {
                message: e.to_string(),
            })?;
        if read == 0 || scratch.last() == Some(&b'\n') {
            return Ok(());
        }
    }
}

/// A JSON value as a JSON-RPC 2.0 request or notification.
///
/// # Errors
/// Returns why the value is neither.
pub fn parse(value: Value) -> Result<Incoming, FrameError> {
    let Value::Object(mut object) = value else {
        return Err(FrameError::NotRequest {
            message: "not an object",
        });
    };
    if object.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
        return Err(FrameError::NotRequest {
            message: "jsonrpc is not \"2.0\"",
        });
    }
    let Some(method) = object
        .get("method")
        .and_then(Value::as_str)
        .map(str::to_owned)
    else {
        return Err(FrameError::NotRequest {
            message: "method is missing or not a string",
        });
    };
    let params = match object.remove("params") {
        None | Some(Value::Null) => Map::new(),
        Some(Value::Object(params)) => params,
        Some(_) => {
            return Err(FrameError::NotRequest {
                message: "params is not an object",
            });
        }
    };
    match object.remove("id") {
        None => Ok(Incoming::Notification { method, params }),
        Some(Value::Null) => Err(FrameError::NotRequest {
            message: "id is null",
        }),
        Some(id @ (Value::String(_) | Value::Number(_))) => {
            Ok(Incoming::Request { id, method, params })
        }
        Some(_) => Err(FrameError::NotRequest {
            message: "id is not a string or a number",
        }),
    }
}

/// A result response for `id`.
#[must_use]
pub fn result(id: &Value, result: &Value) -> Value {
    json!({ "jsonrpc": "2.0", "id": id, "result": result })
}

/// An error response for `id` (`null` when the request's id could not be
/// read), with optional `data`.
#[must_use]
pub fn error(id: &Value, code: i64, message: &str, data: Option<Value>) -> Value {
    let mut error = json!({ "code": code, "message": message });
    if let (Some(data), Some(object)) = (data, error.as_object_mut()) {
        object.insert("data".to_owned(), data);
    }
    json!({ "jsonrpc": "2.0", "id": id, "error": error })
}

/// Writes one message as one line and flushes it. The message is serialised
/// by `serde_json`, which never emits a raw newline inside a string.
///
/// # Errors
/// Returns the stream failure.
pub fn write_message<W: Write>(writer: &mut W, message: &Value) -> io::Result<()> {
    let mut line = serde_json::to_vec(message)?;
    line.push(b'\n');
    writer.write_all(&line)?;
    writer.flush()
}

#[cfg(test)]
#[path = "framing_tests.rs"]
mod tests;