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
+186 -16
View File
@@ -443,11 +443,7 @@ async fn proxy_and_respond_with_resolved_project(
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(annotate_resolved_project(&resp_body, &active))),
Err(e) => to_json_response(JsonRpcResponse::error(
id,
-32603,
format!("proxy error: {e}"),
)),
Err(e) => tool_error_response(id, format!("Sled '{active}' is unavailable: {e}")),
}
}
@@ -486,14 +482,22 @@ async fn proxy_and_respond_for_project(
.status(StatusCode::OK)
.header("Content-Type", "application/json")
.body(Body::from(resp_body)),
Err(e) => to_json_response(JsonRpcResponse::error(
id,
-32603,
format!("proxy error: {e}"),
)),
Err(e) => tool_error_response(id, format!("Sled '{project}' is unavailable: {e}")),
}
}
/// Build a JSON-RPC **success** response shaped as an MCP tool_result error
/// (`isError: true`) rather than a top-level JSON-RPC protocol error.
///
/// A down or version-mismatched sled is a tool-call-time failure, not a
/// protocol violation — the downstream `claude` CLI's MCP client must see a
/// normal tool_result so the model can report the failure and the turn
/// completes, instead of a top-level error that the CLI may treat as fatal
/// and exit non-zero (story 1232).
fn tool_error_response(id: Option<Value>, message: String) -> Response {
to_json_response(tool_error_json(id, message))
}
/// Stream an MCP tool call to the active sled as SSE, re-emitting each `data:`
/// event from the sled to the originating gateway client without buffering.
///
@@ -502,7 +506,7 @@ async fn proxy_and_respond_for_project(
async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option<Value>) -> Response {
let url = match state.active_url().await {
Ok(u) => u,
Err(e) => return sse_error_response(id, -32603, e.to_string()),
Err(e) => return sse_tool_error_response(id, e.to_string()),
};
stream_mcp_call_sse(state, &url, bytes, id).await
}
@@ -537,7 +541,7 @@ async fn stream_mcp_call_sse(
) -> Response {
let resp = match gateway::io::proxy_mcp_call_sse(&state.client, url, bytes).await {
Ok(r) => r,
Err(e) => return sse_error_response(id, -32603, format!("proxy error: {e}")),
Err(e) => return sse_tool_error_response(id, format!("proxy error: {e}")),
};
let id_for_error = id;
@@ -563,9 +567,8 @@ async fn stream_mcp_call_sse(
}
}
Err(e) => {
let err = JsonRpcResponse::error(
let err = tool_error_json(
id_for_error.clone(),
-32603,
format!("upstream disconnected: {e}"),
);
let data = serde_json::to_string(&err).unwrap_or_default();
@@ -581,6 +584,32 @@ async fn stream_mcp_call_sse(
.into_response()
}
/// Build the JSON-RPC value used by [`tool_error_response`] and
/// [`sse_tool_error_response`] — extracted so the SSE mid-stream-disconnect
/// branch above can reuse the same non-fatal `isError` shape.
fn tool_error_json(id: Option<Value>, message: String) -> JsonRpcResponse {
JsonRpcResponse::success(
id,
json!({
"content": [{ "type": "text", "text": message }],
"isError": true
}),
)
}
/// Build a minimal SSE response containing a single non-fatal MCP tool_result
/// error event (`isError: true`) — the SSE counterpart of
/// [`tool_error_response`], used when the initial proxy connection to the
/// sled fails (story 1232).
fn sse_tool_error_response(id: Option<Value>, message: String) -> Response {
let err = tool_error_json(id, message);
let data = serde_json::to_string(&err).unwrap_or_default();
let stream = async_stream::stream! {
yield Event::message(data);
};
SSE::new(stream).into_response()
}
/// Build a minimal SSE response containing a single JSON-RPC error event.
fn sse_error_response(id: Option<Value>, code: i64, msg: String) -> Response {
let err = JsonRpcResponse::error(id, code, msg);
@@ -1760,9 +1789,14 @@ mod tests {
proxy_and_respond_for_project(&state, "test-project", b"{}", Some(json!(1))).await;
let bytes = resp.into_body().into_bytes().await.unwrap();
let parsed: Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed["error"]["code"], -32603);
// Story 1232: a known project with no live sled connection is a
// tool-call-time failure, not a protocol fault — it must come back as
// a non-fatal tool_result (isError: true), not a top-level JSON-RPC
// error, so the calling `claude` CLI treats it as a normal tool error.
assert!(parsed["error"].is_null(), "got: {parsed}");
assert_eq!(parsed["result"]["isError"], true, "got: {parsed}");
assert!(
parsed["error"]["message"]
parsed["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("no live WS uplink"),
@@ -1998,6 +2032,142 @@ mod tests {
);
}
// ── story 1232: down/mismatched sled must not surface as a fatal
// top-level JSON-RPC error, which is what makes the calling `claude` CLI
// exit non-zero instead of completing the turn ────────────────────────
/// AC 1/2/3: a `tools/call` against a project with no live sled-uplink
/// connection at all (the "unreachable sled" case) must come back as a
/// normal, non-fatal MCP tool_result (`isError: true`), not a top-level
/// JSON-RPC `error` object — the CLI's MCP client treats the latter as a
/// protocol fault rather than something the model can react to.
#[tokio::test]
async fn unreachable_sled_returns_non_fatal_tool_result_not_top_level_error() {
let dir = tempfile::tempdir().unwrap();
// No sled connection registered for "test-project" — proxy_active_mcp
// must fail with "no live WS uplink connection".
let state = make_test_state(dir.path());
let app = poem::Route::new()
.at("/mcp", poem::post(gateway_mcp_post_handler))
.data(state);
let cli = poem::test::TestClient::new(app);
let rpc_body = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_pipeline_status",
"arguments": {}
}
}))
.unwrap();
let resp = cli
.post("/mcp")
.header("content-type", "application/json")
.body(rpc_body)
.send()
.await;
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
assert!(
parsed["error"].is_null(),
"an unreachable sled must not produce a top-level JSON-RPC error: {parsed}"
);
assert_eq!(
parsed["result"]["isError"], true,
"an unreachable sled must produce a tool_result with isError: true: {parsed}"
);
let text = parsed["result"]["content"][0]["text"].as_str().unwrap();
assert!(
text.contains("unavailable"),
"tool_result text should explain the sled is unavailable, got: {text}"
);
}
/// AC 1/2/3: a sled that IS connected but replies with a malformed or
/// version-mismatched MCP response (not a well-formed JSON-RPC object)
/// must also surface as a non-fatal tool_result, not garbage forwarded
/// verbatim to the calling `claude` CLI's MCP parser.
#[tokio::test]
async fn malformed_sled_response_returns_non_fatal_tool_result() {
let dir = tempfile::tempdir().unwrap();
let mut projects = BTreeMap::new();
projects.insert(
"test-project".to_string(),
ProjectEntry::with_url("http://127.0.0.1:3001"),
);
let config = GatewayConfig {
projects,
sled_tokens: BTreeMap::new(),
release_channels: BTreeMap::new(),
};
let state = Arc::new(GatewayState::new(config, dir.path().to_path_buf(), 3000).unwrap());
// Fake sled that answers every mcp_request with a payload that is
// valid JSON but not a well-formed JSON-RPC response — simulating a
// version-mismatched sled speaking an incompatible protocol shape.
let (tx, mut rx) =
tokio::sync::mpsc::unbounded_channel::<crate::sled_uplink::UplinkEnvelope>();
let in_flight: Arc<
tokio::sync::Mutex<
std::collections::HashMap<String, tokio::sync::oneshot::Sender<Value>>,
>,
> = Arc::new(tokio::sync::Mutex::new(std::collections::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(json!({ "unexpected": "shape", "no_jsonrpc_field": true }));
}
}
});
state
.register_sled_connection(
"test-project".to_string(),
gateway::SledConnection {
tx,
last_heartbeat_ms: Arc::new(std::sync::atomic::AtomicI64::new(
chrono::Utc::now().timestamp_millis(),
)),
in_flight,
},
)
.await;
let app = poem::Route::new()
.at("/mcp", poem::post(gateway_mcp_post_handler))
.data(state);
let cli = poem::test::TestClient::new(app);
let rpc_body = serde_json::to_vec(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "get_pipeline_status",
"arguments": {}
}
}))
.unwrap();
let resp = cli
.post("/mcp")
.header("content-type", "application/json")
.body(rpc_body)
.send()
.await;
let parsed: Value = resp.0.into_body().into_json().await.unwrap();
assert!(
parsed["error"].is_null(),
"a malformed sled response must not produce a top-level JSON-RPC error: {parsed}"
);
assert_eq!(
parsed["result"]["isError"], true,
"a malformed sled response must produce a tool_result with isError: true: {parsed}"
);
}
#[test]
fn annotate_resolved_project_inserts_field_into_result_object() {
let bytes = serde_json::to_vec(&json!({