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.
This commit is contained in:
Koichi ITO 2026-05-14 01:24:15 +09:00
commit f62d813625
2 changed files with 73 additions and 5 deletions

View file

@ -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 @@ module MCP
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 @@ module MCP
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 @@ module MCP
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)

View file

@ -96,6 +96,60 @@ module MCP
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",