diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 77d97cc..1c246f3 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 @@ 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) diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index a753789..307b712 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 @@ 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",