Merge pull request #423 from koic/sep_2243_mcp_method_name_headers
Send SEP-2243 `Mcp-Method` and `Mcp-Name` headers per MCP specification
This commit is contained in:
commit
0694e386db
2 changed files with 268 additions and 1 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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