Require the Mcp-Method header on the modern path
## Motivation and Context The modern Streamable HTTP path validated the SEP-2243 mirror headers only when present: an absent `Mcp-Method` or `Mcp-Name` was silently tolerated. The 2026-07-28 specification requires `Mcp-Method` on every request and `Mcp-Name` on `tools/call`, `resources/read`, and `prompts/get`, and lists a missing required standard header among the validation failures a server MUST reject; the TypeScript SDK enforces presence with `-32020` the same way. Tolerating absence also defeats the headers' purpose silently: they exist so intermediaries can route and inspect requests without parsing bodies. A modern POST without `Mcp-Method`, or a name-bearing modern POST whose body carries a target name without `Mcp-Name`, now answers HTTP 400 with `-32020` naming the missing header, exactly like a mismatch. `Mcp-Name` stays unrequired when the body carries no target name, matching the TypeScript SDK. The bundled `MCP::Client::HTTP` already sends both headers on every modern request, so SDK-to-SDK traffic is unaffected. ## How Has This Been Tested? New tests in `test/mcp/server/transports/streamable_http_transport_test.rb` cover the missing `Mcp-Method` and missing `Mcp-Name` rejections, asserting the `-32020` code and the header name in the message. The `modern_rack_request` helper now mirrors a conforming client by deriving `Mcp-Method` from the body (with `method_header: nil` to omit it), so the existing modern-path tests exercise the requirement on every request. ## Breaking Changes None for conforming clients, which must already send these headers. Hand-rolled modern clients that omitted them are now rejected with `-32020` instead of being served; add the headers, or use the legacy lifecycle, to proceed.
This commit is contained in:
parent
2d8210f411
commit
5f3a131e1a
2 changed files with 73 additions and 16 deletions
|
|
@ -593,25 +593,45 @@ module MCP
|
||||||
)
|
)
|
||||||
end
|
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"]
|
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(
|
return header_mismatch_response(
|
||||||
"Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
|
"Mcp-Method header value '#{method_header}' does not match body value '#{body[:method]}'",
|
||||||
body[:id],
|
body[:id],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
name_header = request.env["HTTP_MCP_NAME"]
|
if NAME_BEARING_METHODS.include?(body[:method])
|
||||||
if name_header && NAME_BEARING_METHODS.include?(body[:method])
|
|
||||||
body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
|
body_name = params.is_a?(Hash) ? params[:name] || params[:uri] : nil
|
||||||
|
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)
|
decoded_name = decode_header_value(name_header)
|
||||||
if body_name && decoded_name != body_name
|
if decoded_name != body_name
|
||||||
return header_mismatch_response(
|
return header_mismatch_response(
|
||||||
"Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
|
"Mcp-Name header value '#{decoded_name}' does not match body value '#{body_name}'",
|
||||||
body[:id],
|
body[:id],
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5340,6 +5340,31 @@ module MCP
|
||||||
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
|
assert_equal(-32020, JSON.parse(response[2][0]).dig("error", "code"))
|
||||||
end
|
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
|
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
|
# `Mcp-Name` mirrors `params.uri` for `resources/read`; a non-ASCII value arrives
|
||||||
# wrapped in the `=?base64?...?=` sentinel produced by `MCP::Client::HTTP`.
|
# wrapped in the `=?base64?...?=` sentinel produced by `MCP::Client::HTTP`.
|
||||||
|
|
@ -5410,6 +5435,7 @@ module MCP
|
||||||
|
|
||||||
response = @transport.handle_request(modern_rack_request(
|
response = @transport.handle_request(modern_rack_request(
|
||||||
modern_body("tools/call", { name: "guarded_tool", arguments: {} }),
|
modern_body("tools/call", { name: "guarded_tool", arguments: {} }),
|
||||||
|
headers: { "HTTP_MCP_NAME" => "guarded_tool" },
|
||||||
))
|
))
|
||||||
|
|
||||||
assert_equal 400, response[0]
|
assert_equal 400, response[0]
|
||||||
|
|
@ -5600,13 +5626,24 @@ module MCP
|
||||||
end
|
end
|
||||||
|
|
||||||
# Builds a POST request routed to the modern path via the `MCP-Protocol-Version` header.
|
# 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: {})
|
# Mirrors what a conforming modern client sends: `Mcp-Method` is required on every modern POST,
|
||||||
create_rack_request(
|
# so it defaults to the body's method. Pass `method_header: nil` to omit it
|
||||||
"POST",
|
# (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)
|
||||||
{ "CONTENT_TYPE" => "application/json", "HTTP_MCP_PROTOCOL_VERSION" => version }.merge(headers),
|
derived_method = if method_header == :from_body
|
||||||
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
|
end
|
||||||
|
|
||||||
# Builds a JSON-RPC body carrying the SEP-2575 modern `_meta` envelope.
|
# Builds a JSON-RPC body carrying the SEP-2575 modern `_meta` envelope.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue