Send SEP-2243 Mcp-Method and Mcp-Name headers per MCP specification

## Motivation and Context

The MCP specification (SEP-2243) requires the Streamable HTTP transport to mirror
selected JSON-RPC body fields into HTTP headers so that intermediaries such as load balancers,
gateways, and observability tooling can route and inspect requests without parsing the body.
`Mcp-Method` carries the JSON-RPC `method` and is required on all requests and notifications,
while `Mcp-Name` carries `params.name` (`tools/call`, `prompts/get`) or `params.uri` (`resources/read`).

The Ruby SDK client did not send these headers, so servers enforcing SEP-2243 rejected
its requests with `400 Bad Request`, breaking interoperability. This aligns the Ruby SDK with
the TypeScript and Python SDKs, both of which send these headers from their Streamable HTTP clients.

Header values that cannot be transmitted safely as-is, such as non-ASCII text, control characters
(including CR/LF, which would otherwise allow header injection), or significant leading or
trailing whitespace, are wrapped per SEP-2243 as `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
The encoding uses `Array#pack("m0")` to avoid depending on the `base64` gem.

Resolves #366

## How Has This Been Tested?

Client tests cover deriving `Mcp-Name` from `params.name` (`tools/call`, `prompts/get`) and
falling back to `params.uri` (`resources/read`), both symbol- and string-keyed params,
requests and notifications without a name or uri sending only `Mcp-Method`, and non-Hash params sending
only `Mcp-Method`. Value encoding is covered for non-ASCII names, surrounding whitespace, CR/LF
(verifying no header injection), and values that already match the `=?base64?...?=` sentinel.

## Breaking Changes

None. The change is purely additive: existing requests now carry the required `Mcp-Method` and `Mcp-Name` headers
alongside the headers already sent. The `x-mcp-header` / `Mcp-Param-*` custom headers remain out of scope,
matching the current state of the TypeScript and Python SDKs.
This commit is contained in:
Koichi ITO 2026-06-07 03:06:47 +09:00
commit a284bf7314
2 changed files with 268 additions and 1 deletions

View file

@ -16,6 +16,8 @@ module MCP
ACCEPT_HEADER = "application/json, text/event-stream"
SESSION_ID_HEADER = "Mcp-Session-Id"
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
METHOD_HEADER = "Mcp-Method"
NAME_HEADER = "Mcp-Name"
# Raised when an `oauth:` provider is paired with an MCP URL that is neither HTTPS nor
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
@ -198,7 +200,7 @@ module MCP
begin
yield if block_given?
response = client.post("", request, session_headers)
response = client.post("", request, session_headers.merge(request_metadata_headers(method, params)))
body = parse_response_body(response, method, params)
capture_session_info(method, response, body)
@ -368,6 +370,39 @@ module MCP
request_headers
end
# Per SEP-2243, mirror the JSON-RPC method and target name/uri into HTTP headers so intermediaries
# can route and inspect requests without parsing the body. `Mcp-Name` comes from `params.name`
# (tools, prompts) or, when absent, `params.uri` (resources).
#
# https://modelcontextprotocol.io/specification/draft/basic/transports/streamable-http#request-metadata
def request_metadata_headers(method, params)
return {} unless method
metadata_headers = { METHOD_HEADER => method }
if params.is_a?(Hash)
name = params[:name] || params["name"]
name = params[:uri] || params["uri"] unless name.is_a?(String)
metadata_headers[NAME_HEADER] = encode_header_value(name) if name.is_a?(String)
end
metadata_headers
end
# A header value that is not safe to transmit as-is - non-ASCII, control characters (including CR/LF,
# which would otherwise allow header injection), or significant leading/trailing whitespace - is wrapped as
# `=?base64?<base64>?=`. Safe ASCII values are sent unchanged.
def encode_header_value(value)
return value if safe_header_value?(value)
"=?base64?#{[value].pack("m0")}?="
end
def safe_header_value?(value)
value.bytes.all? { |byte| byte.between?(0x20, 0x7e) } &&
(value.empty? || (!value.start_with?(" ", "\t") && !value.end_with?(" ", "\t"))) &&
!(value.start_with?("=?base64?") && value.end_with?("?="))
end
# Drives the OAuth orchestrator on a 401 from the MCP endpoint.
# The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
# URL and an optional `scope` challenge per RFC 9728 Section 5.1.

View file

@ -123,6 +123,238 @@ module MCP
custom_client.send_request(request: request)
end
def test_mcp_method_and_name_headers_for_tools_call
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/call",
params: { name: "get_weather", arguments: { city: "Tokyo" } },
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "get_weather" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_mcp_name_header_falls_back_to_uri_for_resources_read
request = {
jsonrpc: "2.0",
id: "test_id",
method: "resources/read",
params: { uri: "file:///README.md" },
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "resources/read", "Mcp-Name" => "file:///README.md" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_mcp_method_and_name_headers_for_prompts_get
request = {
jsonrpc: "2.0",
id: "test_id",
method: "prompts/get",
params: { name: "greeting" },
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "prompts/get", "Mcp-Name" => "greeting" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_mcp_method_header_without_name_when_params_lack_name_and_uri
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/list",
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "tools/list" },
) do |req|
!req.headers.key?("Mcp-Name")
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { tools: [] } }.to_json,
)
client.send_request(request: request)
end
def test_mcp_name_header_is_base64_encoded_when_unsafe
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/call",
params: { name: "café" },
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?Y2Fmw6k=?=" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_mcp_method_header_for_notification_without_id
request = {
jsonrpc: "2.0",
method: "notifications/initialized",
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "notifications/initialized" },
) do |req|
!req.headers.key?("Mcp-Name")
end.to_return(
status: 202,
headers: { "Content-Type" => "application/json" },
body: "",
)
client.send_request(request: request)
end
def test_mcp_method_header_for_initialize_without_params
request = {
jsonrpc: "2.0",
id: "test_id",
method: "initialize",
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "initialize" },
) do |req|
!req.headers.key?("Mcp-Name")
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_mcp_name_header_with_string_keyed_params
request = {
"jsonrpc" => "2.0",
"id" => "test_id",
"method" => "tools/call",
"params" => { "name" => "get_weather" },
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "get_weather" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_mcp_name_header_is_base64_encoded_when_value_has_surrounding_whitespace
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/call",
params: { name: " padded " },
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?IHBhZGRlZCA=?=" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_mcp_name_header_is_base64_encoded_when_value_has_crlf
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/call",
params: { name: "evil\r\nX-Injected: 1" },
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?ZXZpbA0KWC1JbmplY3RlZDogMQ==?=" },
) do |req|
!req.headers.key?("X-Injected")
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_mcp_name_header_re_encodes_value_matching_base64_sentinel
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/call",
params: { name: "=?base64?literal?=" },
}
stub_request(:post, url).with(
headers: { "Mcp-Method" => "tools/call", "Mcp-Name" => "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=" },
).to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_mcp_method_header_without_name_for_non_hash_params
request = {
jsonrpc: "2.0",
id: "test_id",
method: "custom/method",
params: ["positional"],
}
stub_request(:post, url).with(headers: { "Mcp-Method" => "custom/method" }) do |req|
!req.headers.key?("Mcp-Name")
end.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: {} }.to_json,
)
client.send_request(request: request)
end
def test_send_request_returns_faraday_response
request = {
jsonrpc: "2.0",