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
38 changes: 29 additions & 9 deletions lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -593,23 +593,43 @@ def validate_modern_headers(request, body, header_version)
)
end

# `Mcp-Method` is required on every modern POST and `Mcp-Name` on the name-bearing methods:
# the spec lists a missing required standard header among the validation failures that MUST be rejected,
# and the TypeScript SDK enforces presence the same way. Absence cannot be treated as "nothing to compare":
# the headers exist so intermediaries can route without parsing bodies, and a client that omits them
# defeats that contract silently.
method_header = request.env["HTTP_MCP_METHOD"]
if method_header && method_header != body[:method]
if method_header.to_s.empty?
return header_mismatch_response(
"Mcp-Method header is required on the modern path",
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]}'",
body[:id],
)
end

name_header = request.env["HTTP_MCP_NAME"]
if name_header && NAME_BEARING_METHODS.include?(body[:method])
if NAME_BEARING_METHODS.include?(body[:method])
body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
decoded_name = decode_header_value(name_header)
if body_name && decoded_name != body_name
return header_mismatch_response(
"Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
body[:id],
)
name_header = request.env["HTTP_MCP_NAME"]
if body_name
if name_header.to_s.empty?
return header_mismatch_response(
"Mcp-Name header is required for `#{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}'",
body[:id],
)
end
end
end

Expand Down
51 changes: 44 additions & 7 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5340,6 +5340,31 @@ def string
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
end

test "modern POST without the Mcp-Method header returns 400 with -32020" do
# The header is required on every modern POST; a missing required standard header is
# a validation failure, matching the TypeScript SDK's enforcement.
response = @transport.handle_request(modern_rack_request(
modern_body("ping", {}),
method_header: nil,
))

assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32020, body.dig("error", "code"))
assert_includes body.dig("error", "message"), "Mcp-Method"
end

test "modern POST without the Mcp-Name header on a name-bearing method returns 400 with -32020" do
response = @transport.handle_request(modern_rack_request(
modern_body("tools/call", { name: "some_tool", arguments: {} }),
))

assert_equal 400, response[0]
body = JSON.parse(response[2][0])
assert_equal(-32020, body.dig("error", "code"))
assert_includes body.dig("error", "message"), "Mcp-Name"
end

test "modern POST decodes the base64 Mcp-Name sentinel and enforces the match" do
# `Mcp-Name` mirrors `params.uri` for `resources/read`; a non-ASCII value arrives
# wrapped in the `=?base64?...?=` sentinel produced by `MCP::Client::HTTP`.
Expand Down Expand Up @@ -5410,6 +5435,7 @@ def string

response = @transport.handle_request(modern_rack_request(
modern_body("tools/call", { name: "guarded_tool", arguments: {} }),
headers: { "HTTP_MCP_NAME" => "guarded_tool" },
))

assert_equal 400, response[0]
Expand Down Expand Up @@ -5600,13 +5626,24 @@ def create_rack_request_without_accept(method, path, headers, body = nil)
end

# Builds a POST request routed to the modern path via the `MCP-Protocol-Version` header.
def modern_rack_request(body, version: "2026-07-28", headers: {})
create_rack_request(
"POST",
"/",
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version }.merge(headers),
body,
)
# Mirrors what a conforming modern client sends: `Mcp-Method` is required on every modern POST,
# so it defaults to the body's method. Pass `method_header: nil` to omit it
# (for tests of the requirement itself) or a String to send a specific value.
def modern_rack_request(body, version: "2026-07-28", headers: {}, method_header: :from_body)
derived_method = if method_header == :from_body
begin
parsed = JSON.parse(body)
parsed["method"] if parsed.is_a?(Hash)
rescue JSON::ParserError
nil
end
else
method_header
end

base_headers = { "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version }
base_headers["HTTP_MCP_METHOD"] = derived_method if derived_method
create_rack_request("POST", "/", base_headers.merge(headers), body)
end

# Builds a JSON-RPC body carrying the SEP-2575 modern `_meta` envelope.
Expand Down