Skip to content

Commit 7c60956

Browse files
committed
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.
1 parent d6b5392 commit 7c60956

2 files changed

Lines changed: 73 additions & 5 deletions

File tree

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ module MCP
1616
class Server
1717
module Transports
1818
class StreamableHTTPTransport < Transport
19+
class InvalidJsonError < StandardError; end
20+
1921
SSE_HEADERS = {
2022
"Content-Type" => "text/event-stream",
2123
"Cache-Control" => "no-cache",
@@ -339,8 +341,11 @@ def handle_post(request)
339341
body_string = request.body.read
340342
session_id = extract_session_id(request)
341343

342-
body = parse_request_body(body_string)
343-
return body if parse_error_tuple?(body)
344+
begin
345+
body = parse_request_body(body_string)
346+
rescue InvalidJsonError
347+
return invalid_json_response
348+
end
344349

345350
unless initialize_request?(body)
346351
return missing_session_id_response if !@stateless && !session_id
@@ -349,7 +354,8 @@ def handle_post(request)
349354
return protocol_version_error if protocol_version_error
350355
end
351356

352-
return body unless body.is_a?(Hash) # Non-Hash JSON-RPC bodies are not supported in 2025-11-25.
357+
# MCP 2025-11-25 does not support JSON-RPC batch, so the body must be a single message object.
358+
return non_hash_body_response unless body.is_a?(Hash)
353359

354360
if initialize_request?(body)
355361
handle_initialization(body_string, body)
@@ -507,11 +513,19 @@ def not_acceptable_response(required_types)
507513
def parse_request_body(body_string)
508514
JSON.parse(body_string, symbolize_names: true)
509515
rescue JSON::ParserError, TypeError
516+
raise InvalidJsonError
517+
end
518+
519+
def invalid_json_response
510520
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
511521
end
512522

513-
def parse_error_tuple?(body)
514-
body.is_a?(Array) && body.size == 3 && body[0] == 400
523+
def non_hash_body_response
524+
[
525+
400,
526+
{ "Content-Type" => "application/json" },
527+
[{ error: "Bad Request: request body must be a single JSON-RPC message object" }.to_json],
528+
]
515529
end
516530

517531
def initialize_request?(body)

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,60 @@ def string
9696
assert_equal "Invalid JSON", body["error"]
9797
end
9898

99+
test "POST request with JSON array body returns 400" do
100+
init_request = create_rack_request(
101+
"POST",
102+
"/",
103+
{ "CONTENT_TYPE" => "application/json" },
104+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
105+
)
106+
init_response = @transport.handle_request(init_request)
107+
session_id = init_response[1]["Mcp-Session-Id"]
108+
109+
request = create_rack_request(
110+
"POST",
111+
"/",
112+
{
113+
"CONTENT_TYPE" => "application/json",
114+
"HTTP_MCP_SESSION_ID" => session_id,
115+
},
116+
[{ jsonrpc: "2.0", method: "tools/list", id: "list" }].to_json,
117+
)
118+
119+
response = @transport.handle_request(request)
120+
assert_equal 400, response[0]
121+
122+
body = JSON.parse(response[2][0])
123+
assert_includes body["error"], "single JSON-RPC message object"
124+
end
125+
126+
test "POST request with non-object JSON body returns 400" do
127+
init_request = create_rack_request(
128+
"POST",
129+
"/",
130+
{ "CONTENT_TYPE" => "application/json" },
131+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
132+
)
133+
init_response = @transport.handle_request(init_request)
134+
session_id = init_response[1]["Mcp-Session-Id"]
135+
136+
request = create_rack_request(
137+
"POST",
138+
"/",
139+
{
140+
"CONTENT_TYPE" => "application/json",
141+
"HTTP_MCP_SESSION_ID" => session_id,
142+
},
143+
"\"foo\"",
144+
)
145+
146+
response = @transport.handle_request(request)
147+
assert_equal 400, response[0]
148+
149+
body = JSON.parse(response[2][0])
150+
assert_includes body["error"], "single JSON-RPC message object"
151+
end
152+
99153
test "handles POST request with initialize method" do
100154
request = create_rack_request(
101155
"POST",

0 commit comments

Comments
 (0)