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:
parent
7e878827ae
commit
a284bf7314
1 changed files with 268 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue