fix(cr-2026-04-22): MCP stdio replies with parse-error on malformed JSON

MAJOR from the 2026-04-22 review (crates/mcp/src/main.rs:26-30): the
stdio transport logged malformed JSON lines to stderr and continued
without sending any JSON-RPC response. Clients saw silence instead of
the -32700 Parse Error they could key off. handle_message has a
parse-error branch for shape mismatch, but it never ran for bytes
that failed to parse as JSON at all.

Exposes a new public helper kon_mcp::parse_error_response(detail)
that mirrors the existing internal error_response pattern, filling
id with null per JSON-RPC 2.0 §5.1 (parse error, no id recoverable).
main.rs now writes that response out before continuing the read
loop.

Regression test on the helper asserts: jsonrpc "2.0", id null,
code -32700, message starts with "Parse error" and includes the
underlying serde detail.
This commit is contained in:
2026-04-22 09:13:33 +01:00
parent 7ece0df0ac
commit d25b095788
2 changed files with 34 additions and 8 deletions

View File

@@ -335,6 +335,16 @@ fn error_response(id: Value, code: i32, message: String) -> JsonRpcResponse {
} }
} }
/// Build a JSON-RPC 2.0 Parse Error response (code -32700, id null),
/// for use by the stdio transport when a raw line fails to parse as
/// JSON at all. `handle_message` covers the shape-mismatch case; this
/// helper covers the `serde_json::from_str` failure in `main.rs` so
/// clients receive a well-formed JSON-RPC reply instead of silence
/// (2026-04-22 review MAJOR).
pub fn parse_error_response(detail: &str) -> JsonRpcResponse {
error_response(Value::Null, -32700, format!("Parse error: {detail}"))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -398,6 +408,18 @@ mod tests {
); );
} }
#[test]
fn parse_error_response_has_jsonrpc_2_0_shape() {
let resp = parse_error_response("expected value at line 1 column 1");
assert_eq!(resp.jsonrpc, "2.0");
assert_eq!(resp.id, Value::Null);
assert!(resp.result.is_none());
let err = resp.error.expect("parse_error_response must carry an error");
assert_eq!(err.code, -32700);
assert!(err.message.contains("Parse error"));
assert!(err.message.contains("expected value"));
}
#[tokio::test] #[tokio::test]
async fn unknown_method_returns_method_not_found_error() { async fn unknown_method_returns_method_not_found_error() {
let request = json!({ let request = json!({

View File

@@ -23,18 +23,22 @@ async fn main() -> anyhow::Result<()> {
continue; continue;
} }
let raw: serde_json::Value = match serde_json::from_str(trimmed) { let response = match serde_json::from_str::<serde_json::Value>(trimmed) {
Ok(value) => value, Ok(raw) => match kon_mcp::handle_message(&pool, raw).await {
Some(response) => response,
None => continue, // notification — no reply
},
Err(err) => { Err(err) => {
eprintln!("[kon-mcp] ignoring malformed line: {err}"); // Per JSON-RPC 2.0 §5.1: a Parse Error responds with
continue; // code -32700 and id null. Previously this branch
// logged and continued, dropping the response —
// clients saw silence instead of a structured error
// (2026-04-22 review MAJOR).
eprintln!("[kon-mcp] parse error: {err}");
kon_mcp::parse_error_response(&err.to_string())
} }
}; };
let Some(response) = kon_mcp::handle_message(&pool, raw).await else {
continue; // notification — no reply
};
let payload = serde_json::to_string(&response)?; let payload = serde_json::to_string(&response)?;
stdout.write_all(payload.as_bytes()).await?; stdout.write_all(payload.as_bytes()).await?;
stdout.write_all(b"\n").await?; stdout.write_all(b"\n").await?;