Skip to content

Commit d6b5392

Browse files
authored
Merge pull request #347 from koic/validate_mcp-protocol-version_header_in_streamable_http_transport
Validate `MCP-Protocol-Version` header in StreamableHTTPTransport
2 parents e35841a + 9c2e438 commit d6b5392

2 files changed

Lines changed: 305 additions & 13 deletions

File tree

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -340,23 +340,28 @@ def handle_post(request)
340340
session_id = extract_session_id(request)
341341

342342
body = parse_request_body(body_string)
343-
return body unless body.is_a?(Hash) # Error response
343+
return body if parse_error_tuple?(body)
344344

345-
if body[:method] == "initialize"
346-
handle_initialization(body_string, body)
347-
else
345+
unless initialize_request?(body)
348346
return missing_session_id_response if !@stateless && !session_id
349347

350-
if notification?(body)
351-
dispatch_notification(body_string, session_id)
352-
handle_accepted
353-
elsif response?(body)
354-
return session_not_found_response if !@stateless && !session_exists?(session_id)
348+
protocol_version_error = validate_protocol_version_header(request)
349+
return protocol_version_error if protocol_version_error
350+
end
351+
352+
return body unless body.is_a?(Hash) # Non-Hash JSON-RPC bodies are not supported in 2025-11-25.
355353

356-
handle_response(body, session_id: session_id)
357-
else
358-
handle_regular_request(body_string, session_id, related_request_id: body[:id])
359-
end
354+
if initialize_request?(body)
355+
handle_initialization(body_string, body)
356+
elsif notification?(body)
357+
dispatch_notification(body_string, session_id)
358+
handle_accepted
359+
elsif response?(body)
360+
return session_not_found_response if !@stateless && !session_exists?(session_id)
361+
362+
handle_response(body, session_id: session_id)
363+
else
364+
handle_regular_request(body_string, session_id, related_request_id: body[:id])
360365
end
361366
rescue StandardError => e
362367
MCP.configuration.exception_reporter.call(e, { request: body_string })
@@ -377,6 +382,10 @@ def handle_get(request)
377382

378383
error_response = validate_and_touch_session(session_id)
379384
return error_response if error_response
385+
386+
protocol_version_error = validate_protocol_version_header(request)
387+
return protocol_version_error if protocol_version_error
388+
380389
return session_already_connected_response if get_session_stream(session_id)
381390

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

388397
if @stateless
398+
protocol_version_error = validate_protocol_version_header(request)
399+
return protocol_version_error if protocol_version_error
400+
389401
# Stateless mode doesn't support sessions, so we can just return a success response
390402
return success_response
391403
end
392404

393405
return missing_session_id_response unless (session_id = request.env["HTTP_MCP_SESSION_ID"])
394406
return session_not_found_response unless session_exists?(session_id)
395407

408+
protocol_version_error = validate_protocol_version_header(request)
409+
return protocol_version_error if protocol_version_error
410+
396411
cleanup_session(session_id)
397412

398413
success_response
@@ -495,6 +510,31 @@ def parse_request_body(body_string)
495510
[400, { "Content-Type" => "application/json" }, [{ error: "Invalid JSON" }.to_json]]
496511
end
497512

513+
def parse_error_tuple?(body)
514+
body.is_a?(Array) && body.size == 3 && body[0] == 400
515+
end
516+
517+
def initialize_request?(body)
518+
body.is_a?(Hash) && body[:method] == Methods::INITIALIZE
519+
end
520+
521+
def validate_protocol_version_header(request)
522+
header_value = request.env["HTTP_MCP_PROTOCOL_VERSION"]
523+
return if header_value.nil?
524+
return if MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.include?(header_value)
525+
526+
supported = MCP::Configuration::SUPPORTED_STABLE_PROTOCOL_VERSIONS.join(", ")
527+
body = {
528+
jsonrpc: "2.0",
529+
id: nil,
530+
error: {
531+
code: JsonRpcHandler::ErrorCode::INVALID_REQUEST,
532+
message: "Bad Request: Unsupported protocol version: #{header_value}. Supported versions: #{supported}",
533+
},
534+
}
535+
[400, { "Content-Type" => "application/json" }, [body.to_json]]
536+
end
537+
498538
def notification?(body)
499539
!body[:id] && !!body[:method]
500540
end

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,6 +1385,258 @@ def string
13851385
assert_equal "text/event-stream", response[1]["Content-Type"]
13861386
end
13871387

1388+
test "POST initialize request ignores MCP-Protocol-Version header" do
1389+
request = create_rack_request(
1390+
"POST",
1391+
"/",
1392+
{
1393+
"CONTENT_TYPE" => "application/json",
1394+
"HTTP_MCP_PROTOCOL_VERSION" => "1900-01-01",
1395+
},
1396+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
1397+
)
1398+
1399+
response = @transport.handle_request(request)
1400+
assert_equal 200, response[0]
1401+
end
1402+
1403+
test "POST request with unsupported MCP-Protocol-Version returns 400" do
1404+
init_request = create_rack_request(
1405+
"POST",
1406+
"/",
1407+
{ "CONTENT_TYPE" => "application/json" },
1408+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
1409+
)
1410+
init_response = @transport.handle_request(init_request)
1411+
session_id = init_response[1]["Mcp-Session-Id"]
1412+
1413+
request = create_rack_request(
1414+
"POST",
1415+
"/",
1416+
{
1417+
"CONTENT_TYPE" => "application/json",
1418+
"HTTP_MCP_SESSION_ID" => session_id,
1419+
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
1420+
},
1421+
{ jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json,
1422+
)
1423+
1424+
response = @transport.handle_request(request)
1425+
assert_equal 400, response[0]
1426+
assert_equal({ "Content-Type" => "application/json" }, response[1])
1427+
1428+
body = JSON.parse(response[2][0])
1429+
assert_equal "2.0", body["jsonrpc"]
1430+
assert_nil body["id"]
1431+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
1432+
assert_includes body["error"]["message"], "1999-01-01"
1433+
assert_includes body["error"]["message"], Configuration::LATEST_STABLE_PROTOCOL_VERSION
1434+
end
1435+
1436+
test "POST request with malformed MCP-Protocol-Version returns 400" do
1437+
init_request = create_rack_request(
1438+
"POST",
1439+
"/",
1440+
{ "CONTENT_TYPE" => "application/json" },
1441+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
1442+
)
1443+
init_response = @transport.handle_request(init_request)
1444+
session_id = init_response[1]["Mcp-Session-Id"]
1445+
1446+
request = create_rack_request(
1447+
"POST",
1448+
"/",
1449+
{
1450+
"CONTENT_TYPE" => "application/json",
1451+
"HTTP_MCP_SESSION_ID" => session_id,
1452+
"HTTP_MCP_PROTOCOL_VERSION" => "not-a-version",
1453+
},
1454+
{ jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json,
1455+
)
1456+
1457+
response = @transport.handle_request(request)
1458+
assert_equal 400, response[0]
1459+
1460+
body = JSON.parse(response[2][0])
1461+
assert_includes body["error"]["message"], "not-a-version"
1462+
end
1463+
1464+
test "POST request with supported MCP-Protocol-Version succeeds" do
1465+
init_request = create_rack_request(
1466+
"POST",
1467+
"/",
1468+
{ "CONTENT_TYPE" => "application/json" },
1469+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
1470+
)
1471+
init_response = @transport.handle_request(init_request)
1472+
session_id = init_response[1]["Mcp-Session-Id"]
1473+
1474+
request = create_rack_request(
1475+
"POST",
1476+
"/",
1477+
{
1478+
"CONTENT_TYPE" => "application/json",
1479+
"HTTP_MCP_SESSION_ID" => session_id,
1480+
"HTTP_MCP_PROTOCOL_VERSION" => Configuration::LATEST_STABLE_PROTOCOL_VERSION,
1481+
},
1482+
{ jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json,
1483+
)
1484+
1485+
response = @transport.handle_request(request)
1486+
assert_equal 200, response[0]
1487+
end
1488+
1489+
test "POST request without MCP-Protocol-Version header succeeds" do
1490+
init_request = create_rack_request(
1491+
"POST",
1492+
"/",
1493+
{ "CONTENT_TYPE" => "application/json" },
1494+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
1495+
)
1496+
init_response = @transport.handle_request(init_request)
1497+
session_id = init_response[1]["Mcp-Session-Id"]
1498+
1499+
request = create_rack_request(
1500+
"POST",
1501+
"/",
1502+
{
1503+
"CONTENT_TYPE" => "application/json",
1504+
"HTTP_MCP_SESSION_ID" => session_id,
1505+
},
1506+
{ jsonrpc: "2.0", method: "tools/list", id: "list" }.to_json,
1507+
)
1508+
1509+
response = @transport.handle_request(request)
1510+
assert_equal 200, response[0]
1511+
end
1512+
1513+
test "POST request with array body and unsupported MCP-Protocol-Version returns 400" do
1514+
init_request = create_rack_request(
1515+
"POST",
1516+
"/",
1517+
{ "CONTENT_TYPE" => "application/json" },
1518+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
1519+
)
1520+
init_response = @transport.handle_request(init_request)
1521+
session_id = init_response[1]["Mcp-Session-Id"]
1522+
1523+
request = create_rack_request(
1524+
"POST",
1525+
"/",
1526+
{
1527+
"CONTENT_TYPE" => "application/json",
1528+
"HTTP_MCP_SESSION_ID" => session_id,
1529+
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
1530+
},
1531+
[{ jsonrpc: "2.0", method: "tools/list", id: "list" }].to_json,
1532+
)
1533+
1534+
response = @transport.handle_request(request)
1535+
assert_equal 400, response[0]
1536+
1537+
body = JSON.parse(response[2][0])
1538+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
1539+
end
1540+
1541+
test "GET request with unsupported MCP-Protocol-Version returns 400" do
1542+
init_request = create_rack_request(
1543+
"POST",
1544+
"/",
1545+
{ "CONTENT_TYPE" => "application/json" },
1546+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
1547+
)
1548+
init_response = @transport.handle_request(init_request)
1549+
session_id = init_response[1]["Mcp-Session-Id"]
1550+
1551+
request = create_rack_request(
1552+
"GET",
1553+
"/",
1554+
{
1555+
"HTTP_MCP_SESSION_ID" => session_id,
1556+
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
1557+
},
1558+
)
1559+
1560+
response = @transport.handle_request(request)
1561+
assert_equal 400, response[0]
1562+
1563+
body = JSON.parse(response[2][0])
1564+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
1565+
end
1566+
1567+
test "GET request without MCP-Protocol-Version header succeeds" do
1568+
init_request = create_rack_request(
1569+
"POST",
1570+
"/",
1571+
{ "CONTENT_TYPE" => "application/json" },
1572+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
1573+
)
1574+
init_response = @transport.handle_request(init_request)
1575+
session_id = init_response[1]["Mcp-Session-Id"]
1576+
1577+
request = create_rack_request(
1578+
"GET",
1579+
"/",
1580+
{ "HTTP_MCP_SESSION_ID" => session_id },
1581+
)
1582+
1583+
response = @transport.handle_request(request)
1584+
assert_equal 200, response[0]
1585+
end
1586+
1587+
test "DELETE request with unsupported MCP-Protocol-Version returns 400" do
1588+
init_request = create_rack_request(
1589+
"POST",
1590+
"/",
1591+
{ "CONTENT_TYPE" => "application/json" },
1592+
{ jsonrpc: "2.0", method: "initialize", id: "init" }.to_json,
1593+
)
1594+
init_response = @transport.handle_request(init_request)
1595+
session_id = init_response[1]["Mcp-Session-Id"]
1596+
1597+
request = create_rack_request(
1598+
"DELETE",
1599+
"/",
1600+
{
1601+
"HTTP_MCP_SESSION_ID" => session_id,
1602+
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
1603+
},
1604+
)
1605+
1606+
response = @transport.handle_request(request)
1607+
assert_equal 400, response[0]
1608+
1609+
body = JSON.parse(response[2][0])
1610+
assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, body["error"]["code"]
1611+
end
1612+
1613+
test "DELETE request with unsupported MCP-Protocol-Version returns 400 in stateless mode" do
1614+
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
1615+
1616+
request = create_rack_request(
1617+
"DELETE",
1618+
"/",
1619+
{ "HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01" },
1620+
)
1621+
1622+
response = stateless_transport.handle_request(request)
1623+
assert_equal 400, response[0]
1624+
end
1625+
1626+
test "DELETE request validates session before MCP-Protocol-Version" do
1627+
request = create_rack_request(
1628+
"DELETE",
1629+
"/",
1630+
{
1631+
"HTTP_MCP_SESSION_ID" => "unknown-session-id",
1632+
"HTTP_MCP_PROTOCOL_VERSION" => "1999-01-01",
1633+
},
1634+
)
1635+
1636+
response = @transport.handle_request(request)
1637+
assert_equal 404, response[0]
1638+
end
1639+
13881640
test "stateless mode allows requests without session IDs, responding with no session ID" do
13891641
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
13901642

0 commit comments

Comments
 (0)