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:
commit
9caba3085d
2 changed files with 73 additions and 5 deletions
|
|
@ -16,6 +16,8 @@ module MCP
|
||||||
class Server
|
class Server
|
||||||
module Transports
|
module Transports
|
||||||
class StreamableHTTPTransport < Transport
|
class StreamableHTTPTransport < Transport
|
||||||
|
class InvalidJsonError < StandardError; end
|
||||||
|
|
||||||
SSE_HEADERS = {
|
SSE_HEADERS = {
|
||||||
"Content-Type" => "text/event-stream",
|
"Content-Type" => "text/event-stream",
|
||||||
"Cache-Control" => "no-cache",
|
"Cache-Control" => "no-cache",
|
||||||
|
|
@ -339,8 +341,11 @@ module MCP
|
||||||
body_string = request.body.read
|
body_string = request.body.read
|
||||||
session_id = extract_session_id(request)
|
session_id = extract_session_id(request)
|
||||||
|
|
||||||
body = parse_request_body(body_string)
|
begin
|
||||||
return body if parse_error_tuple?(body)
|
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 `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.
|
# 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
|
return protocol_version_error if protocol_version_error
|
||||||
end
|
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)
|
if initialize_request?(body)
|
||||||
handle_initialization(body_string, body)
|
handle_initialization(body_string, body)
|
||||||
|
|
@ -510,11 +516,19 @@ module MCP
|
||||||
def parse_request_body(body_string)
|
def parse_request_body(body_string)
|
||||||
JSON.parse(body_string, symbolize_names: true)
|
JSON.parse(body_string, symbolize_names: true)
|
||||||
rescue JSON::ParserError, TypeError
|
rescue JSON::ParserError, TypeError
|
||||||
|
raise InvalidJsonError
|
||||||
|
end
|
||||||
|
|
||||||
|
def invalid_json_response
|
||||||
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
||||||
end
|
end
|
||||||
|
|
||||||
def parse_error_tuple?(body)
|
def non_hash_body_response
|
||||||
body.is_a?(Array) && body.size == 3 && body[0] == 400
|
[
|
||||||
|
400,
|
||||||
|
{ "Content-Type" => "application/json" },
|
||||||
|
[{ error: "Bad Request: request body must be a single JSON-RPC message object" }.to_json],
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def initialize_request?(body)
|
def initialize_request?(body)
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,60 @@ module MCP
|
||||||
assert_equal "Invalid JSON", body["error"]
|
assert_equal "Invalid JSON", body["error"]
|
||||||
end
|
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
|
test "handles POST request with initialize method" do
|
||||||
request = create_rack_request(
|
request = create_rack_request(
|
||||||
"POST",
|
"POST",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue