From 81e81c359edf510926e87c1061b213a995bb9b68 Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Sat, 4 Apr 2026 20:52:50 +0900 Subject: [PATCH] Validate Content-Type on POST requests ## Motivation and Context The MCP Streamable HTTP specification requires POST request bodies to be JSON-RPC messages with `Content-Type: application/json`. The Ruby SDK did not validate Content-Type, accepting requests with any or no Content-Type. The Python SDK validates this and returns 415 Unsupported Media Type for non-JSON Content-Types. ## How Has This Been Tested? Added tests for Content-Type validation: - POST without Content-Type returns 415 - POST with `Content-Type: text/plain` returns 415 - POST with `Content-Type: application/json; charset=utf-8` succeeds ## Breaking Changes POST requests without `Content-Type: application/json` now return 415 Unsupported Media Type. This is a spec compliance fix. --- .../transports/streamable_http_transport.rb | 15 +++++++ .../streamable_http_transport_test.rb | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 688e38c..88a9a3d 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -267,6 +267,9 @@ def handle_post(request) accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES) return accept_error if accept_error + content_type_error = validate_content_type(request) + return content_type_error if content_type_error + body_string = request.body.read session_id = extract_session_id(request) @@ -399,6 +402,18 @@ def parse_accept_header(header) end end + def validate_content_type(request) + content_type = request.env["CONTENT_TYPE"] + media_type = content_type&.split(";")&.first&.strip&.downcase + return if media_type == "application/json" + + [ + 415, + { "Content-Type" => "application/json" }, + [{ error: "Unsupported Media Type: Content-Type must be application/json" }.to_json], + ] + end + def not_acceptable_response(required_types) [ 406, diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 7435289..a462050 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -1143,6 +1143,51 @@ def string assert_equal "Method not allowed", body["error"] end + test "POST request without Content-Type returns 415" do + request = create_rack_request_without_accept( + "POST", + "/", + { "HTTP_ACCEPT" => "application/json, text/event-stream" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 415, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Unsupported Media Type: Content-Type must be application/json", body["error"] + end + + test "POST request with wrong Content-Type returns 415" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "text/plain", + "HTTP_ACCEPT" => "application/json, text/event-stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 415, response[0] + end + + test "POST request with Content-Type including charset succeeds" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json; charset=utf-8", + "HTTP_ACCEPT" => "application/json, text/event-stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + test "POST request without Accept header returns 406" do request = create_rack_request_without_accept( "POST",