feat(shell-core/mcp): JSON-RPC wire serialiser + parser
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<ToolCall>` — 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.
This commit is contained in:
parent
4c543c078c
commit
e8cdd8d6e3
|
|
@ -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<ToolCall> {
|
||||
// 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::<i64>() {
|
||||
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, String>) -> 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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue