From f62d8136256dabb44310dd948b4b1bbbc6951560 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 14 May 2026 01:24:15 +0900 Subject: [PATCH] Reject non-Hash JSON-RPC bodies in `StreamableHTTPTransport` ## Motivation and Context MCP 2025-11-25 does not support JSON-RPC batch (batch support was removed in 2025-06-18), so request bodies are expected to be a single JSON-RPC message object. The previous code path: ```ruby body = parse_request_body(body_string) return body unless body.is_a?(Hash) ``` returned the parsed JSON value as if it were a Rack response when the body was a JSON array (former batch shape), a string, a number, or any other non-Hash JSON value. This produced a malformed Rack response instead of a proper HTTP 400. This was a pre-existing bug exposed during follow-up review of #347 (`MCP-Protocol-Version` header validation), which surfaced it but did not fix the broken response shape for non-Hash bodies sent with a valid header. ### Behavior - `parse_request_body` now raises a private `InvalidJsonError` on parse failure instead of returning a Rack response tuple. The overloaded return type and the `parse_error_tuple?` discriminator are gone. - `handle_post` rescues `InvalidJsonError` and returns the same plain JSON 400 response as before (`{"error":"Invalid JSON"}`), so parse error handling is observably unchanged. - Non-Hash parsed bodies (arrays, strings, numbers, booleans, null) now return HTTP 400 with a plain JSON body explaining that the body must be a single JSON-RPC message object, instead of producing a malformed Rack response. The error response shape stays in the existing plain JSON style for consistency with `validate_content_type`, `not_acceptable_response`, and the other transport-level error helpers. Unifying all of these to a JSON-RPC error envelope (matching the Python and TypeScript SDKs) is deferred to a separate follow-up. ### SDK Comparison - Python SDK (`src/mcp/server/streamable_http.py`): Already rejects non-Hash bodies (Array, primitive) with HTTP 400. Uses a JSON-RPC error envelope with `INVALID_PARAMS`. - TypeScript SDK (`packages/server/src/server/streamableHttp.ts`): As of `main`, still processes JSON arrays as JSON-RPC batches and has not yet caught up with the 2025-06-18 batch removal. Primitives fail schema validation and return 400 with a JSON-RPC error envelope. This change brings the Ruby SDK in line with the current MCP spec (no batch). The Ruby SDK is now closer to the spec than the TypeScript SDK's `main` branch on this specific point. ## How Has This Been Tested? Existing regression test `test "handles POST request with invalid JSON"` continues to pass unchanged (plain JSON response preserved). Added new regression tests: - POST with a JSON array body returns 400 with a clear error message - POST with a non-object JSON body (e.g. `"foo"`) returns 400 `bundle exec rake test` and `bundle exec rake rubocop` both pass. ## Breaking Changes None for compliant clients sending single JSON-RPC message objects. Clients that were sending JSON arrays (batch requests, no longer supported as of MCP 2025-11-25) or other non-object bodies will now receive a proper HTTP 400 with a JSON error body instead of the previous malformed Rack response; no client could have relied on the broken pre-existing behavior. --- .../transports/streamable_http_transport.rb | 24 +++++++-- .../streamable_http_transport_test.rb | 54 +++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 9258ec19..436758b3 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -16,6 +16,8 @@ module MCP class Server module Transports class StreamableHTTPTransport < Transport + class InvalidJsonError < StandardError; end + SSE_HEADERS = { "Content-Type" => "text/event-stream", "Cache-Control" => "no-cache", @@ -339,8 +341,11 @@ def handle_post(request) body_string = request.body.read session_id = extract_session_id(request) - body = parse_request_body(body_string) - return body if parse_error_tuple?(body) + begin + body = parse_request_body(body_string) + rescue InvalidJsonError + return invalid_json_response + end unless initialize_request?(body) return missing_session_id_response if !@stateless && !session_id @@ -349,7 +354,8 @@ def handle_post(request) return protocol_version_error if protocol_version_error end - return body unless body.is_a?(Hash) # Non-Hash JSON-RPC bodies are not supported in 2025-11-25. + # MCP 2025-11-25 does not support JSON-RPC batch, so the body must be a single message object. + return non_hash_body_response unless body.is_a?(Hash) if initialize_request?(body) handle_initialization(body_string, body) @@ -507,11 +513,19 @@ def not_acceptable_response(required_types) def parse_request_body(body_string) JSON.parse(body_string, symbolize_names: true) rescue JSON::ParserError, TypeError + raise InvalidJsonError + end + + def invalid_json_response [400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]] end - def parse_error_tuple?(body) - body.is_a?(Array) && body.size == 3 && body[0] == 400 + def non_hash_body_response + [ + 400, + { "Content-Type" => "application/json" }, + [{ error: "Bad Request: request body must be a single JSON-RPC message object" }.to_json], + ] end def initialize_request?(body) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 9d743984..f7be9bea 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -96,6 +96,60 @@ def string assert_equal "Invalid JSON", body["error"] end + test "POST request with JSON array body returns 400" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + [{ jsonrpc: "2.0", method: "tools/list", id: "list" }].to_json, + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_includes body["error"], "single JSON-RPC message object" + end + + test "POST request with non-object JSON body returns 400" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "init" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_MCP_SESSION_ID" => session_id, + }, + "\"foo\"", + ) + + response = @transport.handle_request(request) + assert_equal 400, response[0] + + body = JSON.parse(response[2][0]) + assert_includes body["error"], "single JSON-RPC message object" + end + test "handles POST request with initialize method" do request = create_rack_request( "POST",