diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 9258ec19..436758b3 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 @@ def handle_post(request) 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 @@ def handle_post(request) 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 @@ def not_acceptable_response(required_types) 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 9d743984..f7be9bea 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 @@ def string 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",