Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ loosely while pre-1.0 (breaking changes can land on minor bumps).

## [Unreleased]

### Fixed
- **MCP JSON-RPC null members.** `parse_response` now treats `"error": null`
/ `"result": null` as omitted, matching A2A and serializers that emit
optional fields as null. A success envelope with `"error": null` (or an
error envelope with `"result": null`) no longer throws and no longer
burns the client's 5-parse-failure budget.

## [0.13.4] — 2026-09-10

### Fixed
Expand Down
20 changes: 15 additions & 5 deletions src/mcp/types.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,17 @@ Response parse_response(const std::string& line) {

auto res_v = v->get("result");
auto err_v = v->get("error");
if (res_v && err_v)
// JSON-RPC 2.0 allows only one of result/error. Serializers (and
// A2A's parser) still emit the other member as JSON null. Treat
// null as omitted so `"result":{...},"error":null` is success and
// `"result":null,"error":{...}` is an error. Otherwise Client::rpc
// counts the throw as a parse failure and kills the MCP subprocess
// after five such lines.
const bool have_result = res_v && !res_v->is_null();
const bool have_error = err_v && !err_v->is_null();
if (have_result && have_error)
throw std::runtime_error("response has both 'result' and 'error'");
if (res_v) {
r.result = res_v;
} else if (err_v) {
if (have_error) {
if (!err_v->is_object())
throw std::runtime_error("'error' is not a JSON object");
RpcError e;
Expand All @@ -73,8 +79,12 @@ Response parse_response(const std::string& line) {
e.message = m->as_string();
if (auto d = err_v->get("data")) e.data = d;
r.error = std::move(e);
} else if (res_v) {
// Keep JSON-null `result` so a successful method that returns
// null is distinct from a notification (no result member).
r.result = res_v;
}
// Empty result + empty error is allowed (e.g. notifications/initialized
// Empty result + empty error is allowed (e.g. notifications/initialized)
// ack from some servers); leaves result null and error empty.
return r;
}
Expand Down
30 changes: 30 additions & 0 deletions tests/test_mcp.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,36 @@ TEST_CASE("parse_response rejects malformed envelopes") {
CHECK_THROWS(parse_response(R"({"jsonrpc":"2.0","id":1,"result":{},"error":{"code":1,"message":"x"}})")); // both
}

TEST_CASE("parse_response treats JSON-null result/error as omitted") {
auto ok = parse_response(
R"({"jsonrpc":"2.0","id":5,"result":{"x":1},"error":null})");
CHECK(ok.id == 5);
REQUIRE(ok.result);
CHECK_FALSE(ok.result->is_null());
CHECK(static_cast<int>(ok.result->get_number("x", 0)) == 1);
CHECK_FALSE(ok.error.has_value());

auto err = parse_response(
R"({"jsonrpc":"2.0","id":6,"result":null,"error":{"code":-32601,"message":"no such method"}})");
CHECK(err.id == 6);
CHECK_FALSE(static_cast<bool>(err.result));
REQUIRE(err.error.has_value());
CHECK(err.error->code == -32601);
CHECK(err.error->message == "no such method");

auto null_result = parse_response(
R"({"jsonrpc":"2.0","id":7,"result":null})");
CHECK(null_result.id == 7);
REQUIRE(null_result.result);
CHECK(null_result.result->is_null());
CHECK_FALSE(null_result.error.has_value());

auto empty = parse_response(R"({"jsonrpc":"2.0","id":8,"error":null})");
CHECK(empty.id == 8);
CHECK_FALSE(static_cast<bool>(empty.result));
CHECK_FALSE(empty.error.has_value());
}

TEST_CASE("parse_tools_list extracts name + description + schema") {
auto resp_v = json_parse(R"({"jsonrpc":"2.0","id":1,"result":{"tools":[
{"name":"navigate","description":"Open a URL","inputSchema":{"type":"object"}},
Expand Down
Loading