diff --git a/src/core/jsonrpc/include/sourcemeta/core/jsonrpc.h b/src/core/jsonrpc/include/sourcemeta/core/jsonrpc.h index 314fafbcdc..ffabca5597 100644 --- a/src/core/jsonrpc/include/sourcemeta/core/jsonrpc.h +++ b/src/core/jsonrpc/include/sourcemeta/core/jsonrpc.h @@ -66,6 +66,37 @@ constexpr std::int64_t JSONRPC_CODE_SERVER_ERROR_MAX = -32000; SOURCEMETA_CORE_JSONRPC_EXPORT auto jsonrpc_is_server_error(const std::int64_t code) -> bool; +/// @ingroup jsonrpc +/// Check whether the given JSON value is a JSON-RPC 2.0 batch envelope. For +/// example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto payload{sourcemeta::core::parse_json(R"([])")}; +/// assert(sourcemeta::core::jsonrpc_is_batch(payload)); +/// ``` +SOURCEMETA_CORE_JSONRPC_EXPORT +auto jsonrpc_is_batch(const sourcemeta::core::JSON &payload) -> bool; + +/// @ingroup jsonrpc +/// Check whether the given JSON value is a non-empty JSON-RPC 2.0 batch +/// envelope. For example: +/// +/// ```cpp +/// #include +/// #include +/// #include +/// +/// const auto payload{sourcemeta::core::parse_json( +/// R"([ { "jsonrpc": "2.0", "method": "ping" } ])")}; +/// assert(sourcemeta::core::jsonrpc_is_valid_batch(payload)); +/// ``` +SOURCEMETA_CORE_JSONRPC_EXPORT +auto jsonrpc_is_valid_batch(const sourcemeta::core::JSON &payload) -> bool; + /// @ingroup jsonrpc /// Extract the request identifier from a JSON-RPC 2.0 envelope. Returns a /// pointer to the identifier (string, number, or null per the specification) diff --git a/src/core/jsonrpc/jsonrpc.cc b/src/core/jsonrpc/jsonrpc.cc index ca15a878e0..89724d34ee 100644 --- a/src/core/jsonrpc/jsonrpc.cc +++ b/src/core/jsonrpc/jsonrpc.cc @@ -29,6 +29,14 @@ auto jsonrpc_is_server_error(const std::int64_t code) -> bool { code <= JSONRPC_CODE_SERVER_ERROR_MAX; } +auto jsonrpc_is_batch(const sourcemeta::core::JSON &payload) -> bool { + return payload.is_array(); +} + +auto jsonrpc_is_valid_batch(const sourcemeta::core::JSON &payload) -> bool { + return jsonrpc_is_batch(payload) && !payload.empty(); +} + auto jsonrpc_request_id(const sourcemeta::core::JSON &request) -> const sourcemeta::core::JSON * { if (!request.is_object()) { diff --git a/src/core/mcp/include/sourcemeta/core/mcp.h b/src/core/mcp/include/sourcemeta/core/mcp.h index 8d4c87c9aa..de244891ee 100644 --- a/src/core/mcp/include/sourcemeta/core/mcp.h +++ b/src/core/mcp/include/sourcemeta/core/mcp.h @@ -206,6 +206,14 @@ constexpr auto mcp_supports_implementation_website_url( return version == MCPProtocolVersion::V_2025_11_25; } +/// @ingroup mcp +/// Whether the given protocol version supports JSON-RPC 2.0 batching. +constexpr auto +mcp_supports_jsonrpc_batching(const MCPProtocolVersion version) noexcept + -> bool { + return version == MCPProtocolVersion::V_2025_03_26; +} + /// @ingroup mcp /// Build an MCP `text` content block carrying the given text payload. For /// example: diff --git a/test/jsonrpc/jsonrpc_test.cc b/test/jsonrpc/jsonrpc_test.cc index 190d5a2d1e..b0e2d7ff80 100644 --- a/test/jsonrpc/jsonrpc_test.cc +++ b/test/jsonrpc/jsonrpc_test.cc @@ -51,6 +51,70 @@ TEST(JSONRPC, is_server_error_positive_application_code) { EXPECT_FALSE(sourcemeta::core::jsonrpc_is_server_error(1)); } +TEST(JSONRPC, is_batch_empty_array) { + const auto payload{sourcemeta::core::parse_json(R"([])")}; + EXPECT_TRUE(sourcemeta::core::jsonrpc_is_batch(payload)); +} + +TEST(JSONRPC, is_batch_non_empty_array) { + const auto payload{sourcemeta::core::parse_json( + R"([ { "jsonrpc": "2.0", "method": "ping" } ])")}; + EXPECT_TRUE(sourcemeta::core::jsonrpc_is_batch(payload)); +} + +TEST(JSONRPC, is_batch_object) { + const auto payload{sourcemeta::core::parse_json( + R"({ "jsonrpc": "2.0", "id": 1, "method": "ping" })")}; + EXPECT_FALSE(sourcemeta::core::jsonrpc_is_batch(payload)); +} + +TEST(JSONRPC, is_batch_string) { + const auto payload{sourcemeta::core::parse_json(R"("hello")")}; + EXPECT_FALSE(sourcemeta::core::jsonrpc_is_batch(payload)); +} + +TEST(JSONRPC, is_batch_null) { + const auto payload{sourcemeta::core::parse_json(R"(null)")}; + EXPECT_FALSE(sourcemeta::core::jsonrpc_is_batch(payload)); +} + +TEST(JSONRPC, is_batch_integer) { + const auto payload{sourcemeta::core::parse_json(R"(42)")}; + EXPECT_FALSE(sourcemeta::core::jsonrpc_is_batch(payload)); +} + +TEST(JSONRPC, is_valid_batch_non_empty_array) { + const auto payload{sourcemeta::core::parse_json( + R"([ { "jsonrpc": "2.0", "method": "ping" } ])")}; + EXPECT_TRUE(sourcemeta::core::jsonrpc_is_valid_batch(payload)); +} + +TEST(JSONRPC, is_valid_batch_empty_array) { + const auto payload{sourcemeta::core::parse_json(R"([])")}; + EXPECT_FALSE(sourcemeta::core::jsonrpc_is_valid_batch(payload)); +} + +TEST(JSONRPC, is_valid_batch_object) { + const auto payload{sourcemeta::core::parse_json( + R"({ "jsonrpc": "2.0", "id": 1, "method": "ping" })")}; + EXPECT_FALSE(sourcemeta::core::jsonrpc_is_valid_batch(payload)); +} + +TEST(JSONRPC, is_valid_batch_string) { + const auto payload{sourcemeta::core::parse_json(R"("hello")")}; + EXPECT_FALSE(sourcemeta::core::jsonrpc_is_valid_batch(payload)); +} + +TEST(JSONRPC, is_valid_batch_null) { + const auto payload{sourcemeta::core::parse_json(R"(null)")}; + EXPECT_FALSE(sourcemeta::core::jsonrpc_is_valid_batch(payload)); +} + +TEST(JSONRPC, is_valid_batch_integer) { + const auto payload{sourcemeta::core::parse_json(R"(42)")}; + EXPECT_FALSE(sourcemeta::core::jsonrpc_is_valid_batch(payload)); +} + TEST(JSONRPC, request_id_integer) { const auto request{sourcemeta::core::parse_json( R"({ "jsonrpc": "2.0", "id": 7, "method": "ping" })")}; diff --git a/test/mcp/mcp_test.cc b/test/mcp/mcp_test.cc index 60aa86a9f4..bf62a5f97c 100644 --- a/test/mcp/mcp_test.cc +++ b/test/mcp/mcp_test.cc @@ -240,6 +240,21 @@ TEST(MCP, supports_implementation_website_url_2025_11_25) { sourcemeta::core::MCPProtocolVersion::V_2025_11_25)); } +TEST(MCP, supports_jsonrpc_batching_2025_03_26) { + EXPECT_TRUE(sourcemeta::core::mcp_supports_jsonrpc_batching( + sourcemeta::core::MCPProtocolVersion::V_2025_03_26)); +} + +TEST(MCP, supports_jsonrpc_batching_2025_06_18) { + EXPECT_FALSE(sourcemeta::core::mcp_supports_jsonrpc_batching( + sourcemeta::core::MCPProtocolVersion::V_2025_06_18)); +} + +TEST(MCP, supports_jsonrpc_batching_2025_11_25) { + EXPECT_FALSE(sourcemeta::core::mcp_supports_jsonrpc_batching( + sourcemeta::core::MCPProtocolVersion::V_2025_11_25)); +} + TEST(MCP, make_text_block) { const auto block{sourcemeta::core::mcp_make_text_block("hello")}; const auto expected{sourcemeta::core::parse_json(R"JSON({