huskies: merge 1232 bug Gateway chat bot crashes (CLI exit 1) on a sled MCP tool error instead of surfacing it

This commit is contained in:
Huskies Agent
2026-07-19 18:12:33 +00:00
parent 7b3990430e
commit 933fb5a54b
3 changed files with 296 additions and 19 deletions
+98
View File
@@ -160,6 +160,12 @@ pub async fn proxy_mcp_via_ws(
let timeout = std::time::Duration::from_millis(MCP_VIA_WS_TIMEOUT_MS);
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(response_value)) => {
if !is_valid_jsonrpc_response(&response_value) {
return Err(
"sled returned a malformed/incompatible MCP response (possible version mismatch)"
.to_string(),
);
}
serde_json::to_vec(&response_value).map_err(|e| format!("serialise mcp_response: {e}"))
}
Ok(Err(_)) => Err("sled response channel dropped".to_string()),
@@ -172,6 +178,21 @@ pub async fn proxy_mcp_via_ws(
}
}
/// Returns `true` when `value` looks like a well-formed JSON-RPC 2.0 response
/// (`jsonrpc: "2.0"` plus exactly one of `result`/`error`) — the shape the
/// downstream `claude` CLI's MCP client expects on every `mcp_response`. A
/// down or version-mismatched sled can send something else entirely (an
/// empty object, a request instead of a response, ...); forwarding that
/// verbatim risks the CLI's MCP parser crashing instead of surfacing a
/// normal tool error (story 1232).
fn is_valid_jsonrpc_response(value: &serde_json::Value) -> bool {
let Some(obj) = value.as_object() else {
return false;
};
obj.get("jsonrpc").and_then(|v| v.as_str()) == Some("2.0")
&& (obj.contains_key("result") ^ obj.contains_key("error"))
}
// ── Error type ──────────────────────────────────────────────────────────────
/// Typed errors returned by `service::gateway` functions.
@@ -880,6 +901,83 @@ mod tests {
}
}
// ── is_valid_jsonrpc_response / proxy_mcp_via_ws malformed handling
// (story 1232) ────────────────────────────────────────────────────────
#[test]
fn is_valid_jsonrpc_response_accepts_well_formed_result() {
assert!(is_valid_jsonrpc_response(&serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"result": { "content": [] }
})));
}
#[test]
fn is_valid_jsonrpc_response_accepts_well_formed_error() {
assert!(is_valid_jsonrpc_response(&serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32603, "message": "boom" }
})));
}
#[test]
fn is_valid_jsonrpc_response_rejects_missing_jsonrpc_field() {
assert!(!is_valid_jsonrpc_response(&serde_json::json!({
"id": 1,
"result": {}
})));
}
#[test]
fn is_valid_jsonrpc_response_rejects_missing_result_and_error() {
assert!(!is_valid_jsonrpc_response(&serde_json::json!({
"jsonrpc": "2.0",
"id": 1
})));
}
#[test]
fn is_valid_jsonrpc_response_rejects_non_object() {
assert!(!is_valid_jsonrpc_response(&serde_json::json!(
"not an object"
)));
assert!(!is_valid_jsonrpc_response(&serde_json::json!(null)));
}
/// End-to-end: a sled that responds with a payload that is valid JSON but
/// not a well-formed JSON-RPC response (simulating a version-mismatched
/// sled) must make `proxy_mcp_via_ws` return `Err`, not `Ok` with garbage
/// bytes forwarded to the caller.
#[tokio::test]
async fn proxy_mcp_via_ws_errors_on_malformed_sled_response() {
let (tx, mut rx) = mpsc::unbounded_channel::<crate::sled_uplink::UplinkEnvelope>();
let in_flight: Arc<
TokioMutex<HashMap<String, tokio::sync::oneshot::Sender<serde_json::Value>>>,
> = Arc::new(TokioMutex::new(HashMap::new()));
let in_flight_task = Arc::clone(&in_flight);
tokio::spawn(async move {
while let Some(env) = rx.recv().await {
if let Some(sender) = in_flight_task.lock().await.remove(&env.req_id) {
let _ = sender.send(serde_json::json!({ "not": "jsonrpc" }));
}
}
});
let conn = SledConnection {
tx,
last_heartbeat_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
in_flight,
};
let result = proxy_mcp_via_ws(&conn, b"{}").await;
let err = result.expect_err("malformed sled response must be surfaced as an error");
assert!(
err.contains("malformed") || err.contains("incompatible"),
"error should explain the response was malformed/incompatible, got: {err}"
);
}
#[test]
fn gateway_uptime_secs_is_zero_or_positive_immediately_after_start() {
// Just ensure it doesn't panic and returns a sane (small) value —