Merge pull request #347 from koic/validate_mcp-protocol-version_header_in_streamable_http_transport
Validate `MCP-Protocol-Version` header in StreamableHTTPTransport
This commit is contained in:
commit
d6b5392831
2 changed files with 305 additions and 13 deletions
|
|
@ -340,23 +340,28 @@ module MCP
|
||||||
session_id = extract_session_id(request)
|
session_id = extract_session_id(request)
|
||||||
|
|
||||||
body = parse_request_body(body_string)
|
body = parse_request_body(body_string)
|
||||||
return body unless body.is_a?(Hash) # Error response
|
return body if parse_error_tuple?(body)
|
||||||
|
|
||||||
if body[:method] == "initialize"
|
unless initialize_request?(body)
|
||||||
handle_initialization(body_string, body)
|
|
||||||
else
|
|
||||||
return missing_session_id_response if !@stateless && !session_id
|
return missing_session_id_response if !@stateless && !session_id
|
||||||
|
|
||||||
if notification?(body)
|
protocol_version_error = validate_protocol_version_header(request)
|
||||||
dispatch_notification(body_string, session_id)
|
return protocol_version_error if protocol_version_error
|
||||||
handle_accepted
|
end
|
||||||
elsif response?(body)
|
|
||||||
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
|
||||||
|
|
||||||
handle_response(body, session_id: session_id)
|
return body unless body.is_a?(Hash) # Non-Hash JSON-RPC bodies are not supported in 2025-11-25.
|
||||||
else
|
|
||||||
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
if initialize_request?(body)
|
||||||
end
|
handle_initialization(body_string, body)
|
||||||
|
elsif notification?(body)
|
||||||
|
dispatch_notification(body_string, session_id)
|
||||||
|
handle_accepted
|
||||||
|
elsif response?(body)
|
||||||
|
return session_not_found_response if !@stateless && !session_exists?(session_id)
|
||||||
|
|
||||||
|
handle_response(body, session_id: session_id)
|
||||||
|
else
|
||||||
|
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
||||||
end
|
end
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
MCP.configuration.exception_reporter.call(e, { request: body_string })
|
||||||
|
|
@ -377,6 +382,10 @@ module MCP
|
||||||
|
|
||||||
error_response = validate_and_touch_session(session_id)
|
error_response = validate_and_touch_session(session_id)
|
||||||
return error_response if error_response
|
return error_response if error_response
|
||||||
|
|
||||||
|
protocol_version_error = validate_protocol_version_header(request)
|
||||||
|
return protocol_version_error if protocol_version_error
|
||||||
|
|
||||||
return session_already_connected_response if get_session_stream(session_id)
|
return session_already_connected_response if get_session_stream(session_id)
|
||||||
|
|
||||||
setup_sse_stream(session_id)
|
setup_sse_stream(session_id)
|
||||||
|
|
@ -386,6 +395,9 @@ module MCP
|
||||||
success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
|
success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]
|
||||||
|
|
||||||
if @stateless
|
if @stateless
|
||||||
|
protocol_version_error = validate_protocol_version_header(request)
|
||||||
|
return protocol_version_error if protocol_version_error
|
||||||
|
|
||||||
# Stateless mode doesn't support sessions, so we can just return a success response
|
# Stateless mode doesn't support sessions, so we can just return a success response
|
||||||
return success_response
|
return success_response
|
||||||
end
|
end
|
||||||
|
|
@ -393,6 +405,9 @@ module MCP
|
||||||
return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"])
|
return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"])
|
||||||
return session_not_found_response unless session_exists?(session_id)
|
return session_not_found_response unless session_exists?(session_id)
|
||||||
|
|
||||||
|
protocol_version_error = validate_protocol_version_header(request)
|
||||||
|
return protocol_version_error if protocol_version_error
|
||||||
|
|
||||||
cleanup_session(session_id)
|
cleanup_session(session_id)
|
||||||
|
|
||||||
success_response
|
success_response
|
||||||
|
|
@ -495,6 +510,31 @@ module MCP
|
||||||
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def parse_error_tuple?(body)
|
||||||
|
body.is_a?(Array) && body.size == 3 && body[0] == 400
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize_request?(body)
|
||||||
|
body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_protocol_version_header(request)
|
||||||
|
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"]
|
||||||
|
return if header_value.nil?
|
||||||
|
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
|
||||||
|
|
||||||
|
supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ")
|
||||||
|
body = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: nil,
|
||||||
|
error: {
|
||||||
|
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
|
||||||
|
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
[400, { "Content-Type" => "application/json" }, [body.to_json]]
|
||||||
|
end
|
||||||
|
|
||||||
def notification?(body)
|
def notification?(body)
|
||||||
!body[:id] && !!body[:method]
|
!body[:id] && !!body[:method]
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1385,6 +1385,258 @@ module MCP
|
||||||
assert_equal "text/event-stream", response[1]["Content-Type"]
|
assert_equal "text/event-stream", response[1]["Content-Type"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "POST initialize request ignores MCP-Protocol-Version header" do
|
||||||
|
request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
"HTTP_MCP_PROTOCOL_VERSION" => "1900-01-01",
|
||||||
|
},
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 200, response[0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "POST request with unsupported MCP-Protocol-Version returns 400" do
|
||||||
|
init_request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{ "CONTENT_TYPE" => "application/json" },
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||||
|
)
|
||||||
|
init_response = @transport.handle_request(init_request)
|
||||||
|
session_id = init_response[1]["Mcp-Session-Id"]
|
||||||
|
|
||||||
|
request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
"HTTP_MCP_SESSION_ID" => session_id,
|
||||||
|
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
|
||||||
|
},
|
||||||
|
{ jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 400, response[0]
|
||||||
|
assert_equal({ "Content-Type" => "application/json" }, response[1])
|
||||||
|
|
||||||
|
body = JSON.parse(response[2][0])
|
||||||
|
assert_equal "2.0", body["jsonrpc"]
|
||||||
|
assert_nil body["id"]
|
||||||
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||||
|
assert_includes body["error"]["message"], "1999-01-01"
|
||||||
|
assert_includes body["error"]["message"], Configuration::LATEST_STABLE_PROTOCOL_VERSION
|
||||||
|
end
|
||||||
|
|
||||||
|
test "POST request with malformed MCP-Protocol-Version returns 400" do
|
||||||
|
init_request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{ "CONTENT_TYPE" => "application/json" },
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||||
|
)
|
||||||
|
init_response = @transport.handle_request(init_request)
|
||||||
|
session_id = init_response[1]["Mcp-Session-Id"]
|
||||||
|
|
||||||
|
request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
"HTTP_MCP_SESSION_ID" => session_id,
|
||||||
|
"HTTP_MCP_PROTOCOL_VERSION" => "not-a-version",
|
||||||
|
},
|
||||||
|
{ jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 400, response[0]
|
||||||
|
|
||||||
|
body = JSON.parse(response[2][0])
|
||||||
|
assert_includes body["error"]["message"], "not-a-version"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "POST request with supported MCP-Protocol-Version succeeds" do
|
||||||
|
init_request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{ "CONTENT_TYPE" => "application/json" },
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||||
|
)
|
||||||
|
init_response = @transport.handle_request(init_request)
|
||||||
|
session_id = init_response[1]["Mcp-Session-Id"]
|
||||||
|
|
||||||
|
request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
"HTTP_MCP_SESSION_ID" => session_id,
|
||||||
|
"HTTP_MCP_PROTOCOL_VERSION" => Configuration::LATEST_STABLE_PROTOCOL_VERSION,
|
||||||
|
},
|
||||||
|
{ jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 200, response[0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "POST request without MCP-Protocol-Version header succeeds" do
|
||||||
|
init_request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{ "CONTENT_TYPE" => "application/json" },
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||||
|
)
|
||||||
|
init_response = @transport.handle_request(init_request)
|
||||||
|
session_id = init_response[1]["Mcp-Session-Id"]
|
||||||
|
|
||||||
|
request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
"HTTP_MCP_SESSION_ID" => session_id,
|
||||||
|
},
|
||||||
|
{ jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 200, response[0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "POST request with array body and unsupported MCP-Protocol-Version returns 400" do
|
||||||
|
init_request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{ "CONTENT_TYPE" => "application/json" },
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||||
|
)
|
||||||
|
init_response = @transport.handle_request(init_request)
|
||||||
|
session_id = init_response[1]["Mcp-Session-Id"]
|
||||||
|
|
||||||
|
request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
"CONTENT_TYPE" => "application/json",
|
||||||
|
"HTTP_MCP_SESSION_ID" => session_id,
|
||||||
|
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
|
||||||
|
},
|
||||||
|
[{ jsonrpc: "2.0", method: "tools/list", id: "list" }].to_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 400, response[0]
|
||||||
|
|
||||||
|
body = JSON.parse(response[2][0])
|
||||||
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "GET request with unsupported MCP-Protocol-Version returns 400" do
|
||||||
|
init_request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{ "CONTENT_TYPE" => "application/json" },
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||||
|
)
|
||||||
|
init_response = @transport.handle_request(init_request)
|
||||||
|
session_id = init_response[1]["Mcp-Session-Id"]
|
||||||
|
|
||||||
|
request = create_rack_request(
|
||||||
|
"GET",
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
"HTTP_MCP_SESSION_ID" => session_id,
|
||||||
|
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 400, response[0]
|
||||||
|
|
||||||
|
body = JSON.parse(response[2][0])
|
||||||
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "GET request without MCP-Protocol-Version header succeeds" do
|
||||||
|
init_request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{ "CONTENT_TYPE" => "application/json" },
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||||
|
)
|
||||||
|
init_response = @transport.handle_request(init_request)
|
||||||
|
session_id = init_response[1]["Mcp-Session-Id"]
|
||||||
|
|
||||||
|
request = create_rack_request(
|
||||||
|
"GET",
|
||||||
|
"/",
|
||||||
|
{ "HTTP_MCP_SESSION_ID" => session_id },
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 200, response[0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "DELETE request with unsupported MCP-Protocol-Version returns 400" do
|
||||||
|
init_request = create_rack_request(
|
||||||
|
"POST",
|
||||||
|
"/",
|
||||||
|
{ "CONTENT_TYPE" => "application/json" },
|
||||||
|
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
|
||||||
|
)
|
||||||
|
init_response = @transport.handle_request(init_request)
|
||||||
|
session_id = init_response[1]["Mcp-Session-Id"]
|
||||||
|
|
||||||
|
request = create_rack_request(
|
||||||
|
"DELETE",
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
"HTTP_MCP_SESSION_ID" => session_id,
|
||||||
|
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 400, response[0]
|
||||||
|
|
||||||
|
body = JSON.parse(response[2][0])
|
||||||
|
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "DELETE request with unsupported MCP-Protocol-Version returns 400 in stateless mode" do
|
||||||
|
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
|
||||||
|
|
||||||
|
request = create_rack_request(
|
||||||
|
"DELETE",
|
||||||
|
"/",
|
||||||
|
{ "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01" },
|
||||||
|
)
|
||||||
|
|
||||||
|
response = stateless_transport.handle_request(request)
|
||||||
|
assert_equal 400, response[0]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "DELETE request validates session before MCP-Protocol-Version" do
|
||||||
|
request = create_rack_request(
|
||||||
|
"DELETE",
|
||||||
|
"/",
|
||||||
|
{
|
||||||
|
"HTTP_MCP_SESSION_ID" => "unknown-session-id",
|
||||||
|
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = @transport.handle_request(request)
|
||||||
|
assert_equal 404, response[0]
|
||||||
|
end
|
||||||
|
|
||||||
test "stateless mode allows requests without session IDs, responding with no session ID" do
|
test "stateless mode allows requests without session IDs, responding with no session ID" do
|
||||||
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
|
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue