Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,7 @@ def handle_modern(request, header_version, body_string: nil)
message: "Unsupported protocol version",
data: {
supported: MCP::Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS,
requested: header_version,
requested: printable_value(header_version),
},
id: body[:id],
)
Expand Down Expand Up @@ -983,7 +983,7 @@ def validate_modern_headers(request, body, header_version)
meta_version = params.is_a?(Hash) && params[:_meta].is_a?(Hash) ? params.dig(:_meta, :"io.modelcontextprotocol/protocolVersion") : nil
if meta_version && meta_version != header_version
return header_mismatch_response(
"MCP-Protocol-Version header value '#{header_version}' does not match body value '#{meta_version}'",
"MCP-Protocol-Version header value '#{printable_value(header_version)}' does not match body value '#{printable_value(meta_version)}'",
body[:id],
)
end
Expand All @@ -1002,7 +1002,7 @@ def validate_modern_headers(request, body, header_version)
end
if method_header != body[:method]
return header_mismatch_response(
"Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
"Mcp-Method header value '#{printable_value(method_header)}' does not match body value '#{printable_value(body[:method])}'",
body[:id],
)
end
Expand All @@ -1013,15 +1013,15 @@ def validate_modern_headers(request, body, header_version)
if body_name
if name_header.to_s.empty?
return header_mismatch_response(
"Mcp-Name header is required for `#{body[:method]}`",
"Mcp-Name header is required for `#{printable_value(body[:method])}`",
body[:id],
)
end

decoded_name = decode_header_value(name_header)
if decoded_name != body_name
return header_mismatch_response(
"Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
"Mcp-Name header value '#{printable_value(decoded_name)}' does not match body value '#{printable_value(body_name)}'",
body[:id],
)
end
Expand Down Expand Up @@ -1571,6 +1571,15 @@ def write_listen_closing_result(subscription)
end
end

# A client-supplied value for an error message or `data` member. A header value reaches the transport as
# ASCII-8BIT from a Rack server, or tagged UTF-8 from a test request or a framework, and a body value comes
# out of the JSON parser; a value holding bytes that are not valid UTF-8 would make the response's `to_json`
# raise and turn the intended 400 into a 500 with an exception report. Such bytes are replaced with U+FFFD;
# a valid value comes through unchanged.
def printable_value(value)
value.to_s.dup.force_encoding(Encoding::UTF_8).scrub
end

def header_mismatch_response(message, id)
json_rpc_error_response(
status: 400,
Expand Down Expand Up @@ -2055,7 +2064,7 @@ def validate_protocol_version_header(request)
json_rpc_error_response(
status: 400,
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
message: "Bad Request: Unsupported protocol version: #{printable_value(header_value)}. Supported versions: #{supported}",
)
end

Expand Down
84 changes: 84 additions & 0 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6327,6 +6327,90 @@ def string
assert_equal "2027-01-01", body.dig("error", "data", "requested")
end

test "modern POST answers 400 with -32022 when the requested version is not valid UTF-8" do
# The echoed `requested` member is scrubbed, where `to_json` on the raw value raised and the rescue answered 500 with an exception report.
reported = []
original_reporter = MCP.configuration.exception_reporter
MCP.configuration.exception_reporter = ->(error, _context) { reported << error }
version = "2026-07-28\xFF".dup.force_encoding(Encoding::UTF_8)

response = @transport.handle_request(modern_rack_request(modern_body("ping", {}), version: version))

assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32022, body.dig("error", "code"))
assert_equal "2026-07-28\uFFFD", body.dig("error", "data", "requested")
assert_empty reported
ensure
MCP.configuration.exception_reporter = original_reporter
end

test "modern POST answers 400 with -32020 when a mirror header that mismatches is not valid UTF-8" do
reported = []
original_reporter = MCP.configuration.exception_reporter
MCP.configuration.exception_reporter = ->(error, _context) { reported << error }
invalid_method = "tools/call\xFF".dup.force_encoding(Encoding::UTF_8)
invalid_name = "=?base64?#{["file:///\xFF".b].pack("m0")}?="

by_method = @transport.handle_request(modern_rack_request(
modern_body("ping", {}),
headers: { "HTTP_MCP_METHOD" => invalid_method },
))
by_name = @transport.handle_request(modern_rack_request(
modern_body("resources/read", { uri: "file:///a.txt" }),
headers: { "HTTP_MCP_NAME" => invalid_name },
))

[by_method, by_name].each do |response|
assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32020, body.dig("error", "code"))
assert_includes body.dig("error", "message"), "\uFFFD"
end
assert_empty reported
ensure
MCP.configuration.exception_reporter = original_reporter
end

test "modern POST answers 400 with -32020 when the body's envelope version is not valid UTF-8" do
# The body side of the mismatch message comes out of the JSON parser, which passes such bytes through.
reported = []
original_reporter = MCP.configuration.exception_reporter
MCP.configuration.exception_reporter = ->(error, _context) { reported << error }
body_string = modern_body("ping", {}, version: "2026-07-28X").b.sub("2026-07-28X", "2026-07-28\xFF".b)

response = @transport.handle_request(modern_rack_request(body_string))

assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32020, body.dig("error", "code"))
assert_includes body.dig("error", "message"), "\uFFFD"
assert_empty reported
ensure
MCP.configuration.exception_reporter = original_reporter
end

test "modern POST echoes a valid non-ASCII mirror header given as bytes in its -32020 message" do
# A Rack server hands the header over as ASCII-8BIT; json 3.1 refuses to serialize such a string
# when it holds bytes above 0x7F, valid UTF-8 or not, so the mismatch answered 500 there.
reported = []
original_reporter = MCP.configuration.exception_reporter
MCP.configuration.exception_reporter = ->(error, _context) { reported << error }

response = @transport.handle_request(modern_rack_request(
modern_body("ping", {}),
headers: { "HTTP_MCP_METHOD" => "p\xC3\xAFng".b },
))

assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32020, body.dig("error", "code"))
assert_includes body.dig("error", "message"), "p\u00EFng"
assert_empty reported
ensure
MCP.configuration.exception_reporter = original_reporter
end

test "modern POST with a header/body version mismatch returns 400 with -32020" do
# Header names the supported modern version; the body envelope claims another.
response = @transport.handle_request(modern_rack_request(
Expand Down
Loading