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:
parent
9d2fdd02ed
commit
9c2e438d4a
1 changed files with 305 additions and 13 deletions
|
|
@ -340,23 +340,28 @@ module MCP
|
|||
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
|
||||
|
||||
handle_response(body, session_id: session_id)
|
||||
else
|
||||
handle_regular_request(body_string, session_id, related_request_id: body[:id])
|
||||
end
|
||||
return body unless body.is_a?(Hash) # Non-Hash JSON-RPC bodies are not supported in 2025-11-25.
|
||||
|
||||
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 })
|
||||
|
|
@ -377,6 +382,10 @@ module MCP
|
|||
|
||||
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)
|
||||
|
|
@ -386,6 +395,9 @@ module MCP
|
|||
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
|
||||
|
|
@ -393,6 +405,9 @@ module MCP
|
|||
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
|
||||
|
|
@ -495,6 +510,31 @@ module MCP
|
|||
[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
|
||||
|
|
|
|||
Loading…
Reference in a new issue