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:
parent
d6b5392831
commit
f62d813625
1 changed files with 73 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue