Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 53 additions & 13 deletions lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -340,23 +340,28 @@ def handle_post(request)
session_id = extract_session_id(request)

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"
handle_initialization(body_string, body)
else
unless initialize_request?(body)
return missing_session_id_response if !@stateless && !session_id

if notification?(body)
dispatch_notification(body_string, session_id)
handle_accepted
elsif response?(body)
return session_not_found_response if !@stateless && !session_exists?(session_id)
protocol_version_error = validate_protocol_version_header(request)
return protocol_version_error if protocol_version_error
end

return body unless body.is_a?(Hash) # Non-Hash JSON-RPC bodies are not supported in 2025-11-25.

handle_response(body, session_id: session_id)
else
handle_regular_request(body_string, session_id, related_request_id: body[:id])
end
if initialize_request?(body)
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
rescue StandardError => e
MCP.configuration.exception_reporter.call(e, { request: body_string })
Expand All @@ -377,6 +382,10 @@ def handle_get(request)

error_response = validate_and_touch_session(session_id)
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)

setup_sse_stream(session_id)
Expand All @@ -386,13 +395,19 @@ def handle_delete(request)
success_response = [200, { "Content-Type" => "application/json" }, [{ success: true }.to_json]]

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
return success_response
end

return missing_session_id_response unless (session_id = request.env["HTTP_MCP_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)

success_response
Expand Down Expand Up @@ -495,6 +510,31 @@ def parse_request_body(body_string)
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
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)
!body[:id] && !!body[:method]
end
Expand Down
252 changes: 252 additions & 0 deletions test/mcp/server/transports/streamable_http_transport_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1385,6 +1385,258 @@ def string
assert_equal "text/event-stream", response[1]["Content-Type"]
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
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)

Expand Down