From 9c2e438d4aec6816748ec542d9903b24caf0a139 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Thu, 14 May 2026 00:37:53 +0900 Subject: [PATCH] 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: . 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. --- .../transports/streamable_http_transport.rb | 66 ++++- .../streamable_http_transport_test.rb | 252 ++++++++++++++++++ 2 files changed, 305 insertions(+), 13 deletions(-) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 8dc8af03..9258ec19 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -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 }) @@ -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) @@ -386,6 +395,9 @@ 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 @@ -393,6 +405,9 @@ def handle_delete(request) 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 @@ 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 diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index b2de1f4b..9d743984 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -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)