From 646046db3612883f7860f9b5e834be30aebaab47 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 14 Sep 2026 00:08:54 +0000 Subject: [PATCH] Treat JSON-null MCP result/error members as omitted parse_response treated a present JSON-null member as a real result or error, so serializers that emit "error": null on success (or "result": null on error) threw. Client::rpc counted that as a parse failure and killed the MCP subprocess after five such lines. Match A2A: null means omitted; dual non-null members still reject. Co-authored-by: Tyler Reckart --- CHANGELOG.md | 7 +++++++ src/mcp/types.cpp | 20 +++++++++++++++----- tests/test_mcp.cpp | 30 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dae12b43..b06855bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/mcp/types.cpp b/src/mcp/types.cpp index d3ba0baf..1ac543b9 100644 --- a/src/mcp/types.cpp +++ b/src/mcp/types.cpp @@ -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; @@ -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; } diff --git a/tests/test_mcp.cpp b/tests/test_mcp.cpp index 590f9349..17637158 100644 --- a/tests/test_mcp.cpp +++ b/tests/test_mcp.cpp @@ -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(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(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(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"}},