diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index 7b065a89..24c7831a 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -3,6 +3,8 @@ module MCP class Client class HTTP + ACCEPT_HEADER = "application/json, text/event-stream" + attr_reader :url def initialize(url:, headers: {}) @@ -14,46 +16,48 @@ def send_request(request:) method = request[:method] || request["method"] params = request[:params] || request["params"] - client.post("", request).body + response = client.post("", request) + validate_response_content_type!(response, method, params) + response.body rescue Faraday::BadRequestError => e raise RequestHandlerError.new( "The #{method} request is invalid", - { method:, params: }, + { method: method, params: params }, error_type: :bad_request, original_error: e, ) rescue Faraday::UnauthorizedError => e raise RequestHandlerError.new( "You are unauthorized to make #{method} requests", - { method:, params: }, + { method: method, params: params }, error_type: :unauthorized, original_error: e, ) rescue Faraday::ForbiddenError => e raise RequestHandlerError.new( "You are forbidden to make #{method} requests", - { method:, params: }, + { method: method, params: params }, error_type: :forbidden, original_error: e, ) rescue Faraday::ResourceNotFound => e raise RequestHandlerError.new( "The #{method} request is not found", - { method:, params: }, + { method: method, params: params }, error_type: :not_found, original_error: e, ) rescue Faraday::UnprocessableEntityError => e raise RequestHandlerError.new( "The #{method} request is unprocessable", - { method:, params: }, + { method: method, params: params }, error_type: :unprocessable_entity, original_error: e, ) rescue Faraday::Error => e # Catch-all raise RequestHandlerError.new( "Internal error handling #{method} request", - { method:, params: }, + { method: method, params: params }, error_type: :internal_error, original_error: e, ) @@ -70,6 +74,7 @@ def client faraday.response(:json) faraday.response(:raise_error) + faraday.headers["Accept"] = ACCEPT_HEADER headers.each do |key, value| faraday.headers[key] = value end @@ -83,6 +88,17 @@ def require_faraday! "Add it to your Gemfile: gem 'faraday', '>= 2.0'" \ "See https://rubygems.org/gems/faraday for more details." end + + def validate_response_content_type!(response, method, params) + content_type = response.headers["Content-Type"] + return if content_type&.include?("application/json") + + raise RequestHandlerError.new( + "Unsupported Content-Type: #{content_type.inspect}. This client only supports JSON responses.", + { method: method, params: params }, + error_type: :unsupported_media_type, + ) + end end end end diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 91f5ae3c..7e74e213 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -17,6 +17,9 @@ def initialize(server, stateless: false) @stateless = stateless end + REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze + REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze + def handle_request(request) case request.env["REQUEST_METHOD"] when "POST" @@ -105,6 +108,9 @@ def send_ping_to_stream(stream) end def handle_post(request) + accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES) + return accept_error if accept_error + body_string = request.body.read session_id = extract_session_id(request) @@ -128,6 +134,9 @@ def handle_get(request) return method_not_allowed_response end + accept_error = validate_accept_header(request, REQUIRED_GET_ACCEPT_TYPES) + return accept_error if accept_error + session_id = extract_session_id(request) return missing_session_id_response unless session_id @@ -178,6 +187,31 @@ def extract_session_id(request) request.env["HTTP_MCP_SESSION_ID"] end + def validate_accept_header(request, required_types) + accept_header = request.env["HTTP_ACCEPT"] + return not_acceptable_response(required_types) unless accept_header + + accepted_types = parse_accept_header(accept_header) + missing_types = required_types - accepted_types + return not_acceptable_response(required_types) unless missing_types.empty? + + nil + end + + def parse_accept_header(header) + header.split(",").map do |part| + part.split(";").first.strip + end + end + + def not_acceptable_response(required_types) + [ + 406, + { "Content-Type" => "application/json" }, + [{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json], + ] + end + def parse_request_body(body_string) JSON.parse(body_string) rescue JSON::ParserError, TypeError diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index e7f5b581..fffda7f6 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -40,6 +40,7 @@ def test_headers_are_added_to_the_request headers: { "Authorization" => "Bearer token", "Content-Type" => "application/json", + "Accept" => "application/json, text/event-stream", }, body: request.to_json, ) @@ -54,6 +55,53 @@ def test_headers_are_added_to_the_request client.send_request(request: request) end + def test_accept_header_is_included_in_requests + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with( + headers: { + "Accept" => "application/json, text/event-stream", + }, + ) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + client.send_request(request: request) + end + + def test_custom_accept_header_overrides_default + custom_accept = "application/json" + custom_client = HTTP.new(url: url, headers: { "Accept" => custom_accept }) + + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with( + headers: { + "Accept" => custom_accept, + }, + ) + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { result: { tools: [] } }.to_json, + ) + + custom_client.send_request(request: request) + end + def test_send_request_returns_faraday_response request = { jsonrpc: "2.0", @@ -194,6 +242,33 @@ def test_send_request_raises_internal_error assert_equal({ method: "tools/list", params: nil }, error.request) end + def test_send_request_raises_error_for_non_json_response + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "data: {}\n\n", + ) + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal( + 'Unsupported Content-Type: "text/event-stream". This client only supports JSON responses.', + error.message, + ) + assert_equal(:unsupported_media_type, error.error_type) + assert_equal({ method: "tools/list", params: nil }, error.request) + end + private def stub_request(method, url) diff --git a/test/mcp/server/transports/streamable_http_notification_integration_test.rb b/test/mcp/server/transports/streamable_http_notification_integration_test.rb index 716b167e..4e926aee 100644 --- a/test/mcp/server/transports/streamable_http_notification_integration_test.rb +++ b/test/mcp/server/transports/streamable_http_notification_integration_test.rb @@ -232,11 +232,20 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase private def create_rack_request(method, path, headers, body = nil) + default_accept = case method + when "POST" + { "HTTP_ACCEPT" => "application/json, text/event-stream" } + when "GET" + { "HTTP_ACCEPT" => "text/event-stream" } + else + {} + end + env = { "REQUEST_METHOD" => method, "PATH_INFO" => path, "rack.input" => StringIO.new(body.to_s), - }.merge(headers) + }.merge(default_accept, headers) Rack::Request.new(env) end diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index acae7b6f..d4ef7ed1 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -573,6 +573,164 @@ class StreamableHTTPTransportTest < ActiveSupport::TestCase assert_equal "Method not allowed", body["error"] end + test "POST request without Accept header returns 406" do + request = create_rack_request_without_accept( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + assert_equal({ "Content-Type" => "application/json" }, response[1]) + + body = JSON.parse(response[2][0]) + assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", + body["error"] + end + + test "POST request with Accept header missing text/event-stream returns 406" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json", + }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", + body["error"] + end + + test "POST request with Accept header missing application/json returns 406" do + request = create_rack_request_without_accept( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "text/event-stream", + }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream", + body["error"] + end + + test "POST request with valid Accept header succeeds" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "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 with Accept header containing quality values succeeds" do + request = create_rack_request( + "POST", + "/", + { + "CONTENT_TYPE" => "application/json", + "HTTP_ACCEPT" => "application/json;q=0.9, text/event-stream;q=0.8", + }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + end + + test "GET request without Accept header returns 406" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + request = create_rack_request_without_accept( + "GET", + "/", + { "HTTP_MCP_SESSION_ID" => session_id }, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"] + end + + test "GET request with Accept header missing text/event-stream returns 406" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.to_json, + ) + init_response = @transport.handle_request(init_request) + session_id = init_response[1]["Mcp-Session-Id"] + + request = create_rack_request_without_accept( + "GET", + "/", + { + "HTTP_MCP_SESSION_ID" => session_id, + "HTTP_ACCEPT" => "application/json", + }, + ) + + response = @transport.handle_request(request) + assert_equal 406, response[0] + + body = JSON.parse(response[2][0]) + assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"] + end + + test "GET request with valid Accept header succeeds" do + init_request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + { jsonrpc: "2.0", method: "initialize", id: "123" }.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_ACCEPT" => "text/event-stream", + }, + ) + + response = @transport.handle_request(request) + assert_equal 200, response[0] + assert_equal "text/event-stream", response[1]["Content-Type"] + end + test "stateless mode allows requests without session IDs, responding with no session ID" do stateless_transport = StreamableHTTPTransport.new(@server, stateless: true) @@ -771,6 +929,25 @@ class StreamableHTTPTransportTest < ActiveSupport::TestCase private def create_rack_request(method, path, headers, body = nil) + default_accept = case method + when "POST" + { "HTTP_ACCEPT" => "application/json, text/event-stream" } + when "GET" + { "HTTP_ACCEPT" => "text/event-stream" } + else + {} + end + + env = { + "REQUEST_METHOD" => method, + "PATH_INFO" => path, + "rack.input" => StringIO.new(body.to_s), + }.merge(default_accept).merge(headers) + + Rack::Request.new(env) + end + + def create_rack_request_without_accept(method, path, headers, body = nil) env = { "REQUEST_METHOD" => method, "PATH_INFO" => path,