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"
|
ACCEPT_HEADER = "application/json, text/event-stream"
|
||||||
SESSION_ID_HEADER = "Mcp-Session-Id"
|
SESSION_ID_HEADER = "Mcp-Session-Id"
|
||||||
PROTOCOL_VERSION_HEADER = "MCP-Protocol-Version"
|
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
|
# 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
|
# a loopback `http://` URL, since a bearer token sent over plain HTTP to a remote host
|
||||||
|
|
@ -198,7 +200,7 @@ module MCP
|
||||||
|
|
||||||
begin
|
begin
|
||||||
yield if block_given?
|
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)
|
body = parse_response_body(response, method, params)
|
||||||
|
|
||||||
capture_session_info(method, response, body)
|
capture_session_info(method, response, body)
|
||||||
|
|
@ -368,6 +370,39 @@ module MCP
|
||||||
request_headers
|
request_headers
|
||||||
end
|
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.
|
# Drives the OAuth orchestrator on a 401 from the MCP endpoint.
|
||||||
# The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
|
# The `WWW-Authenticate` header (when present) supplies the `resource_metadata`
|
||||||
# URL and an optional `scope` challenge per RFC 9728 Section 5.1.
|
# URL and an optional `scope` challenge per RFC 9728 Section 5.1.
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,238 @@ module MCP
|
||||||
custom_client.send_request(request: request)
|
custom_client.send_request(request: request)
|
||||||
end
|
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
|
def test_send_request_returns_faraday_response
|
||||||
request = {
|
request = {
|
||||||
jsonrpc: "2.0",
|
jsonrpc: "2.0",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue