diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 86e442a0..7dd42d04 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -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], ) @@ -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 @@ -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 @@ -1013,7 +1013,7 @@ 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 @@ -1021,7 +1021,7 @@ def validate_modern_headers(request, body, header_version) 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 @@ -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, @@ -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 diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 8a81a1b8..6a6bf855 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -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(