From e8cdd8d6e3666f2dbf842de203dc108c2de2d19d Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 14 May 2026 15:33:19 +0800 Subject: [PATCH] feat(shell-core/mcp): JSON-RPC wire serialiser + parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bridges the gap between the in-memory `ToolCall` / `ToolResponse` types and on-the-wire JSON-RPC frames. Pure Rust, no serde dep (shell-core stays wasm32-clean — adding serde would inflate the bundle for a feature only the server binary uses). - `response_to_json(&ToolResponse) -> String` — emits the standard `{"jsonrpc":"2.0","id":...,"result":...}` for OK and `{"jsonrpc":"2.0","id":...,"error":{"code":...,"message":...}}` for Err. Hand-rolled emitter with proper JSON escaping for `"`, `\`, `\n`, `\r`, `\t`, and control chars. - `parse_tool_call(&str) -> Option` — minimal parser that extracts `id` / `method` from a single-line JSON-RPC request. Empty `arguments` map for now; the server binary will swap in a real serde parse when wired. - `error_code_to_int` — maps `ToolErrorCode` variants to JSON-RPC's reserved + application-range codes per the spec (-32600..-32603 transport, -32001..-32002 application). Tests (4 new): - Ok response carries `"jsonrpc":"2.0"`, the right id, and the result map serialised correctly. - Err response carries the right error code (-32601 for UnknownTool) and message. - Round-trip: parse_tool_call → registry.dispatch → response_to_json preserves the request id through the full pipeline. - JSON escapes special chars (`"`, `\n`) in both id and message. #7 MCP now ~30% — types + registry + wire format. Real stdio listener (line-delimited JSON over stdin/stdout) lives in the follow-up server binary. Tests total: 227 shell-core (+4) + 20 native + 8 desktop = 255. Wasm32 build clean. --- crates/openpencil-shell-core/src/mcp.rs | 168 ++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/crates/openpencil-shell-core/src/mcp.rs b/crates/openpencil-shell-core/src/mcp.rs index bbc89a57d..81fda93e5 100644 --- a/crates/openpencil-shell-core/src/mcp.rs +++ b/crates/openpencil-shell-core/src/mcp.rs @@ -121,6 +121,120 @@ impl ToolRegistry { } } +/// JSON-RPC wire serialiser for `ToolResponse`. Manual emitter so +/// shell-core stays serde-free (no dep adds for wasm32). Produces +/// the standard `{"jsonrpc": "2.0", "id": ..., "result": ...}` / +/// `{"jsonrpc": "2.0", "id": ..., "error": {"code": ..., "message" +/// ...}}` shape any MCP client expects. +pub fn response_to_json(r: &ToolResponse) -> String { + let (id_repr, body) = match r { + ToolResponse::Ok { id, result } => ( + id_to_json(id), + format!(r#""result":{}"#, btree_to_json(result)), + ), + ToolResponse::Err { id, code, message } => ( + id_to_json(id), + format!( + r#""error":{{"code":{},"message":{}}}"#, + error_code_to_int(*code), + json_escape(message), + ), + ), + }; + format!(r#"{{"jsonrpc":"2.0","id":{},{}}}"#, id_repr, body) +} + +/// Parse a JSON-RPC request line into a `ToolCall`. Returns None on +/// malformed input. Same minimal-parser strategy as `response_to_json` +/// — hand-rolled, no serde. Real production servers should use serde +/// but the stub is enough to round-trip the test fixtures. +pub fn parse_tool_call(line: &str) -> Option { + // Stub parser — extracts the three required fields (`id`, + // `method`, `params`) by simple string searches. Robust against + // ordering but not against deeply-nested params. Real serde- + // backed parsing lands when the server binary lands. + let id = extract_field(line, "id")?; + let id = if let Ok(n) = id.parse::() { + RequestId::Num(n) + } else { + RequestId::Str(id.trim_matches('"').to_string()) + }; + let tool = extract_field(line, "method")?.trim_matches('"').to_string(); + // Empty arguments map — real implementation parses the params + // object into the BTreeMap. Round-trip with the simple test + // fixtures is enough for the v1 scaffold. + Some(ToolCall { + id, + tool, + arguments: BTreeMap::new(), + }) +} + +fn id_to_json(id: &RequestId) -> String { + match id { + RequestId::Str(s) => json_escape(s), + RequestId::Num(n) => n.to_string(), + } +} + +fn error_code_to_int(code: ToolErrorCode) -> i32 { + // JSON-RPC reserves -32600..-32603 for transport-level errors; + // tool errors live in the application range (-32000..-32099). + match code { + ToolErrorCode::MissingArgument => -32_001, + ToolErrorCode::InvalidArgument => -32_602, + ToolErrorCode::ToolFailed => -32_002, + ToolErrorCode::UnknownTool => -32_601, + ToolErrorCode::Internal => -32_603, + } +} + +fn btree_to_json(m: &BTreeMap) -> String { + let mut out = String::from("{"); + let mut first = true; + for (k, v) in m { + if !first { + out.push(','); + } + first = false; + out.push_str(&format!("{}:{}", json_escape(k), json_escape(v))); + } + out.push('}'); + out +} + +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +fn extract_field<'a>(line: &'a str, key: &str) -> Option<&'a str> { + let needle = format!("\"{}\"", key); + let start = line.find(&needle)? + needle.len(); + let after_colon = &line[start..]; + let colon = after_colon.find(':')? + 1; + let val = after_colon[colon..].trim_start(); + let val_start = start + colon + (after_colon[colon..].len() - val.len()); + // Read until next , or }. + let end_rel = val + .find(|c: char| c == ',' || c == '}') + .unwrap_or(val.len()); + Some(line[val_start..val_start + end_rel].trim()) +} + #[cfg(test)] mod tests { use super::*; @@ -201,6 +315,60 @@ mod tests { } } + #[test] + fn response_to_json_ok_payload() { + let mut result = BTreeMap::new(); + result.insert("k".into(), "v".into()); + let r = ToolResponse::Ok { + id: RequestId::Num(7), + result, + }; + let j = response_to_json(&r); + assert!(j.contains(r#""jsonrpc":"2.0""#)); + assert!(j.contains(r#""id":7"#)); + assert!(j.contains(r#""result":"#)); + assert!(j.contains(r#""k":"v""#)); + } + + #[test] + fn response_to_json_err_payload() { + let r = ToolResponse::Err { + id: RequestId::Str("req".into()), + code: ToolErrorCode::UnknownTool, + message: "no such tool".into(), + }; + let j = response_to_json(&r); + assert!(j.contains(r#""id":"req""#)); + assert!(j.contains(r#""code":-32601"#)); + assert!(j.contains(r#""message":"no such tool""#)); + } + + #[test] + fn parse_tool_call_round_trips_through_registry() { + let line = r#"{"jsonrpc":"2.0","id":42,"method":"echo","params":{}}"#; + let call = parse_tool_call(line).expect("parse"); + assert_eq!(call.id, RequestId::Num(42)); + assert_eq!(call.tool, "echo"); + let mut r = ToolRegistry::default(); + r.register(Box::new(EchoTool)); + match r.dispatch(call) { + ToolResponse::Ok { id, .. } => assert_eq!(id, RequestId::Num(42)), + _ => panic!(), + } + } + + #[test] + fn json_escape_handles_special_chars() { + let r = ToolResponse::Err { + id: RequestId::Str("x\"y".into()), + code: ToolErrorCode::Internal, + message: "line1\nline2".into(), + }; + let j = response_to_json(&r); + assert!(j.contains(r#""x\"y""#)); + assert!(j.contains(r#""line1\nline2""#)); + } + #[test] fn registry_errors_on_unknown_tool() { let r = ToolRegistry::default();