diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 86e442a0..b5ec6019 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -905,8 +905,8 @@ 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: utf8_header_value(header_version), + }.compact, id: body[:id], ) end @@ -1000,6 +1000,13 @@ def validate_modern_headers(request, body, header_version) body[:id], ) end + method_header = utf8_header_value(method_header) + unless method_header + return header_mismatch_response( + "Mcp-Method header value is not valid UTF-8", + body[:id], + ) + end if method_header != body[:method] return header_mismatch_response( "Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'", @@ -1019,6 +1026,12 @@ def validate_modern_headers(request, body, header_version) end decoded_name = decode_header_value(name_header) + unless decoded_name + return header_mismatch_response( + "Mcp-Name header value is not valid UTF-8", + body[:id], + ) + end if decoded_name != body_name return header_mismatch_response( "Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'", @@ -1580,14 +1593,23 @@ def header_mismatch_response(message, id) ) end + def utf8_header_value(value) + decoded = value.dup.force_encoding(Encoding::UTF_8) + decoded if decoded.valid_encoding? + end + # Mirrors `MCP::Client::HTTP#encode_header_value`: a value wrapped as `=?base64??=` decodes to # its original UTF-8 string; anything else is taken verbatim. Duplicated here because the client transport # requires faraday, which servers do not depend on. def decode_header_value(value) + value = utf8_header_value(value) + return unless value + match = value.match(/\A=\?base64\?(.*)\?=\z/m) return value unless match - match[1].unpack1("m0").force_encoding(Encoding::UTF_8) + decoded = match[1].unpack1("m0").force_encoding(Encoding::UTF_8) + decoded if decoded.valid_encoding? rescue ArgumentError value 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..fa82a75e 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -6327,6 +6327,44 @@ def string assert_equal "2027-01-01", body.dig("error", "data", "requested") end + test "modern POST rejects standard headers that are not valid UTF-8" do + reported = [] + original_reporter = MCP.configuration.exception_reporter + MCP.configuration.exception_reporter = ->(error, _context) { reported << error } + @server.expects(:handle).never + + cases = [ + ["HTTP_MCP_PROTOCOL_VERSION", "2026-07-28\xFF", -32022, "Unsupported protocol version"], + ["HTTP_MCP_METHOD", "tools/call\xFF", -32020, "Header mismatch: Mcp-Method header value is not valid UTF-8"], + ["HTTP_MCP_NAME", "some_tool\xFF", -32020, "Header mismatch: Mcp-Name header value is not valid UTF-8"], + ["HTTP_MCP_NAME", "=?base64?/w==?=", -32020, "Header mismatch: Mcp-Name header value is not valid UTF-8"], + ] + cases.each do |header_name, value, code, message| + [Encoding::BINARY, Encoding::UTF_8].each do |encoding| + header_value = value.dup.force_encoding(encoding).freeze + response = @transport.handle_request(modern_rack_request( + modern_body("tools/call", { name: "some_tool", arguments: {} }), + headers: { header_name => header_value }, + )) + + expected_error = { "code" => code, "message" => message } + if code == -32022 + expected_error["data"] = { "supported" => Configuration::SUPPORTED_MODERN_PROTOCOL_VERSIONS } + end + + assert_equal 400, response[0], "#{header_name} (#{encoding})" + assert_equal({ "content-type" => "application/json" }, response[1]) + assert_equal( + { "jsonrpc" => "2.0", "id" => 1, "error" => expected_error }, + JSON.parse(response[2][0]), + ) + assert_empty reported + end + end + 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(