Validate MCP-Protocol-Version header in StreamableHTTPTransport

## Motivation and Context

MCP 2025-11-25 (basic/transports#protocol-version-header) requires servers
to respond with `400 Bad Request` when receiving a request with an invalid
or unsupported `MCP-Protocol-Version` header:

> If the server receives a request with an invalid or unsupported
> `MCP-Protocol-Version`, it MUST respond with `400 Bad Request`.

The Ruby SDK was not reading the header at all, accepting any value
(including malformed strings like `not-a-version` and unsupported versions
like `1900-01-01`) and dispatching requests normally with HTTP 200.

## Behavior

`StreamableHTTPTransport` now validates the `MCP-Protocol-Version` header
on POST (non-initialize), GET, and DELETE requests. Missing headers are
accepted as before; the spec SHOULD requirement to default to `2025-03-26`
is intentionally left for a follow-up. The `initialize` POST is exempt
because the client does not know the negotiated version until the response
arrives, matching the Python (`src/mcp/server/streamable_http.py`) and
TypeScript (`packages/server/src/server/streamableHttp.ts`) SDKs.

The error response uses a JSON-RPC envelope with `code: -32600`
(`INVALID_REQUEST`) to match the Python SDK shape:

```json
{
  "jsonrpc": "2.0",
  "id": null,
  "error": {
    "code": -32600,
    "message": "Bad Request: Unsupported protocol version: <value>. Supported versions: ..."
  }
}
```

Validation order is session then protocol version, matching the Python and
TypeScript SDKs.

`handle_post` was reorganized so that non-Hash request bodies (e.g., JSON
arrays, which MCP 2025-11-25 no longer supports as batches) also pass
through the header check. The pre-existing broken response for Array
bodies sent with a valid header is unchanged and is tracked as a separate
follow-up.

## How Has This Been Tested?

Added regression tests covering:

- POST `initialize` ignores the header (bypass)
- POST non-initialize with unsupported / malformed / valid / missing values
- POST array body with unsupported value returns 400
- GET with unsupported / missing values
- DELETE with unsupported value in both stateful and stateless modes
- DELETE validates session before protocol version

`bundle exec rake test` and `bundle exec rake rubocop` both pass.

## Breaking Changes

Strictly additive for compliant clients. Third-party clients sending stale
or malformed `MCP-Protocol-Version` values will now receive `400` instead
of `200`, which is the intended spec behavior.

Fixes #346.
This commit is contained in:
Koichi ITO 2026-05-14 00:37:53 +09:00
commit 9c2e438d4a

View file

@ -1385,6 +1385,258 @@ module MCP
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)