Merge pull request #354 from koic/reject_non_hash_post_body_in_streamable_http_transport

Reject non-Hash JSON-RPC bodies in `StreamableHTTPTransport`
This commit is contained in:
Koichi ITO 2026-05-18 09:23:57 +09:00 • committed by GitHub
commit 9caba3085d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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
# The `MCP-Protocol-Version` header is only meaningful after negotiation, so on `initialize`
# the JSON-RPC body `params.protocolVersion` is authoritative and the header (if any) is ignored.
@ -352,7 +357,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)
@ -510,11 +516,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",