Skip to content

Commit 8f39862

Browse files
authored
Merge pull request #101 from gfs/gfs/GracefulMcpErrors
fix: return JSON-RPC error responses for failed MCP tool calls
2 parents 9ba2c06 + add7b6a commit 8f39862

3 files changed

Lines changed: 114 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ Before `1.0.0`, breaking changes may still ship in minor releases.
77

88
## [Unreleased]
99

10+
### Fixed
11+
- MCP server no longer crashes when a tool call fails; instead propogate errors as JSON-RPC responses.
12+
1013
## [0.5.3]
1114

1215
### Added

src/main.rs

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2076,11 +2076,18 @@ async fn run_mcp(args: McpArgs, profile: Option<&str>) -> Result<(), KagiError>
20762076
]
20772077
}
20782078
}),
2079-
"tools/call" => serde_json::json!({
2080-
"jsonrpc": "2.0",
2081-
"id": id,
2082-
"result": run_mcp_tool_call(&request, profile).await?,
2083-
}),
2079+
"tools/call" => match run_mcp_tool_call(&request, profile).await {
2080+
Ok(result) => serde_json::json!({
2081+
"jsonrpc": "2.0",
2082+
"id": id,
2083+
"result": result,
2084+
}),
2085+
Err(error) => serde_json::json!({
2086+
"jsonrpc": "2.0",
2087+
"id": id,
2088+
"error": {"code": -32000, "message": error.to_string()},
2089+
}),
2090+
},
20842091
_ => serde_json::json!({
20852092
"jsonrpc": "2.0",
20862093
"id": id,

tests/integration-cli.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,3 +997,102 @@ fn mcp_news_search_tool_call_returns_clusters() {
997997
assert_eq!(clusters[0]["items"][0]["paywall"], true);
998998
assert_eq!(clusters[1]["items"].as_array().unwrap().len(), 2);
999999
}
1000+
1001+
#[test]
1002+
fn mcp_tool_call_error_returns_json_rpc_error_and_keeps_server_alive() {
1003+
let server = MockServer::start();
1004+
1005+
// Mock search endpoint to return a 500 error, simulating a backend failure.
1006+
let _search = server.mock(|when, then| {
1007+
when.method(GET).path("/search");
1008+
then.status(500).body("Internal Server Error");
1009+
});
1010+
1011+
// Mock news endpoints so kagi_news succeeds — proving the server survived.
1012+
let _latest = server.mock(|when, then| {
1013+
when.method(GET).path("/api/batches/latest");
1014+
then.status(200)
1015+
.header("content-type", "application/json")
1016+
.json_body(news_latest_batch());
1017+
});
1018+
let _metadata = server.mock(|when, then| {
1019+
when.method(GET).path("/api/categories/metadata");
1020+
then.status(200)
1021+
.header("content-type", "application/json")
1022+
.json_body(news_category_metadata());
1023+
});
1024+
let _categories = server.mock(|when, then| {
1025+
when.method(GET).path("/api/batches/batch-1/categories");
1026+
then.status(200)
1027+
.header("content-type", "application/json")
1028+
.json_body(news_batch_categories());
1029+
});
1030+
let _stories = server.mock(|when, then| {
1031+
when.method(GET)
1032+
.path("/api/batches/batch-1/categories/category-1/stories");
1033+
then.status(200)
1034+
.header("content-type", "application/json")
1035+
.json_body(news_stories());
1036+
});
1037+
1038+
let tempdir = TempDir::new().expect("tempdir");
1039+
let env = session_env(&server);
1040+
1041+
// Send a search tool call (will fail) followed by a news tool call (should succeed).
1042+
let failing_request = json!({
1043+
"jsonrpc": "2.0",
1044+
"id": 1,
1045+
"method": "tools/call",
1046+
"params": {
1047+
"name": "kagi_search",
1048+
"arguments": { "query": "test" }
1049+
}
1050+
});
1051+
let succeeding_request = json!({
1052+
"jsonrpc": "2.0",
1053+
"id": 2,
1054+
"method": "tools/call",
1055+
"params": {
1056+
"name": "kagi_news",
1057+
"arguments": { "category": "tech", "lang": "en", "limit": 3 }
1058+
}
1059+
});
1060+
1061+
let stdin = format!(
1062+
"{}\n{}\n",
1063+
serde_json::to_string(&failing_request).unwrap(),
1064+
serde_json::to_string(&succeeding_request).unwrap(),
1065+
);
1066+
1067+
let output = run_kagi_with_stdin(&["mcp"], &stdin, &env_refs(&env), tempdir.path());
1068+
1069+
assert_success(&output);
1070+
let stdout = String::from_utf8_lossy(&output.stdout);
1071+
let responses: Vec<Value> = stdout
1072+
.lines()
1073+
.map(|line| serde_json::from_str(line).expect("each line is valid JSON"))
1074+
.collect();
1075+
1076+
assert_eq!(responses.len(), 2, "expected two JSON-RPC responses");
1077+
1078+
// First response: the failed tool call should be a JSON-RPC error, not a crash.
1079+
let error_resp = &responses[0];
1080+
assert_eq!(error_resp["id"], 1);
1081+
assert!(
1082+
error_resp.get("error").is_some(),
1083+
"expected JSON-RPC error for failed tool call, got: {error_resp}"
1084+
);
1085+
assert_eq!(error_resp["error"]["code"], -32000);
1086+
assert!(
1087+
!error_resp["error"]["message"].as_str().unwrap().is_empty(),
1088+
"error message should be non-empty"
1089+
);
1090+
1091+
// Second response: the server stayed alive and processed the next request.
1092+
let success_resp = &responses[1];
1093+
assert_eq!(success_resp["id"], 2);
1094+
assert!(
1095+
success_resp.get("result").is_some(),
1096+
"expected successful result for second tool call, got: {success_resp}"
1097+
);
1098+
}

0 commit comments

Comments
 (0)