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:
@@ -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 —
|
||||
|
||||
Reference in New Issue
Block a user