fix(mcp): era-split not-found codes and -32600 for non-object input

resources/read reports -32602 on the modern path (2026-07-28 forbids emitting -32002) and keeps -32002 on legacy, where it was the code. Non-object top-level input (batches included - no batch semantics defined) gets -32600 Invalid Request instead of silence, so senders never hang. Id-less objects stay silent (notifications). Ping arm deliberately untouched: the method was removed only on the modern path, and legacy health checks may rely on it.
This commit is contained in:
Karim Jerbi committed 2026-10-04 17:00:59 +01:00
1 parent 22715c9485
commit 499b46ab4e
1 file changed
+56 -2
+56 -2
View File
@@ -2158,6 +2158,13 @@ fn handle_message(
tasks: &mut TaskStore,
resources: &mut ResourceStore,
) -> Option<Value> {
// Non-object input (batches included: the spec defines no batch
// semantics for us) is Invalid Request, never silence: a sender must
// not hang waiting for a response that will never come. Id-less
// *objects* stay silent (notifications).
if !message.is_object() {
return Some(rpc_error(Value::Null, -32600, "Invalid request: expected a JSON-RPC object"));
}
let id = message.get("id").cloned();
let method = message.get("method").and_then(Value::as_str)?;
if id.is_none() {
@@ -2248,7 +2255,9 @@ fn handle_message(
};
match read_resource(uri, clients, resources) {
Ok(contents) => response(id, protocol_result(contents, modern, true)),
Err(err) => rpc_error(id, -32002, err),
// -32002 was THE not-found code in 2025-11-25 and earlier;
// 2026-07-28 says MUST NOT emit it, so modern gets -32602.
Err(err) => rpc_error(id, if modern { -32602 } else { -32002 }, err),
}
}
"tools/list" => response(
@@ -2665,6 +2674,50 @@ mod tests {
assert!(fallback.get("error").is_none());
}
#[test]
fn resource_not_found_code_follows_era() {
// Modern path MUST NOT emit -32002 (2026-07-28); legacy path keeps
// it (it was the code in 2025-11-25 and earlier).
let modern = handle_message(
json!({"jsonrpc":"2.0","id":1,"method":"resources/read","params":{"uri":"cad://session/nope/snapshot/nope.png","_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}),
&mut HashMap::new(),
&mut TaskStore::default(),
&mut ResourceStore::default(),
)
.unwrap();
assert_eq!(modern["error"]["code"], -32602);
let legacy = handle_message(
json!({"jsonrpc":"2.0","id":2,"method":"resources/read","params":{"uri":"cad://session/nope/snapshot/nope.png"}}),
&mut HashMap::new(),
&mut TaskStore::default(),
&mut ResourceStore::default(),
)
.unwrap();
assert_eq!(legacy["error"]["code"], -32002);
}
#[test]
fn non_object_input_is_invalid_request() {
// Batches and other non-objects get -32600 (so senders never hang)
// while id-less objects stay silent (notifications).
for bad in [json!([1, 2]), json!("tools/list"), json!(42), json!(null)] {
let rejected = handle_message(
bad,
&mut HashMap::new(),
&mut TaskStore::default(),
&mut ResourceStore::default(),
);
assert_eq!(rejected.unwrap()["error"]["code"], -32600);
}
let silent = handle_message(
json!({"jsonrpc":"2.0","method":"ping"}),
&mut HashMap::new(),
&mut TaskStore::default(),
&mut ResourceStore::default(),
);
assert!(silent.is_none());
}
#[test]
fn malformed_requests_get_jsonrpc_errors() {
let unknown = handle_message(
@@ -2823,7 +2876,8 @@ mod tests {
let text = read_meta["result"]["contents"][0]["text"].as_str().unwrap();
assert!(text.contains("\"width\": 100"));
// Test resources/read on non-existent resource returns -32002 error
// Legacy resources/read on non-existent resource keeps -32002
// (modern gets -32602; see resource_not_found_code_follows_era)
let read_err = handle_message(
json!({"jsonrpc":"2.0","id":"r-err","method":"resources/read","params":{"uri":"cad://session/sess1/snapshot/nonexistent.png"}}),
&mut clients,