diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index f224508c..5c4ab6eb 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -400,6 +400,7 @@ def send_request(request:) ) end + request_id = request[:id] || request["id"] method = request[:method] || request["method"] params = request[:params] || request["params"] oauth_retried = false @@ -427,11 +428,11 @@ def send_request(request:) nil end - body = resolve_response_body(stream, response, method, params) + body = resolve_response_body(stream, response, request_id, method, params) # Every way a response arrives (JSON body, SSE event, resumed stream) ends here. Checked before # anything is learned from the body, and only for a request: a notification awaits no response. - reject_invalid_response!(body, method, params) if request[:id] || request["id"] + reject_invalid_response!(body, method, params) if request_id capture_session_info(method, response, body) if response capture_mcp_param_declarations(method, params, body) @@ -1106,8 +1107,11 @@ def require_faraday! end # Determines the logical JSON-RPC body of the exchange after the POST stream finished - # (or was aborted because the response already arrived). - def resolve_response_body(stream, response, method, params) + # (or was aborted because the response already arrived). A notification (no `request_id`) awaits + # no response, so any success status acknowledges it, as it does for the TypeScript and Python clients, + # which check the content type of a response only for a request; the spec has the server answer + # a notification with 202, but a server answering with another 2xx is accepted as well. + def resolve_response_body(stream, response, request_id, method, params) return stream.response if stream.response # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected. @@ -1125,6 +1129,9 @@ def resolve_response_body(stream, response, method, params) return stream.response if stream.response end + # A stream that ends without a response has acknowledged a notification; there is nothing to resume for. + return if request_id.nil? && response.success? + # SEP-1699: a graceful close after a priming event (an event with an id) but before # the response means "reconnect via GET to resume". return await_response_after_disconnect(stream, method, params) if stream.primed? @@ -1141,6 +1148,8 @@ def resolve_response_body(stream, response, method, params) # added by the Faraday customizer may have parsed it already. response.body.is_a?(String) ? parse_json_buffer(response.body, method, params) : response.body else + return if request_id.nil? && response.success? + raise RequestHandlerError.new( "Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.", { method: method, params: params }, diff --git a/test/mcp/client/http_test.rb b/test/mcp/client/http_test.rb index 682939d4..64028da8 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -2739,6 +2739,82 @@ def test_send_request_does_not_check_the_body_answering_a_notification assert_empty(response) end + def test_connect_succeeds_when_the_initialized_notification_is_acknowledged_with_any_2xx + # The spec has the server answer a notification with 202 and no body; the TypeScript and Python + # clients accept any 2xx, whatever the body, and some hosted servers answer an empty 200. + acknowledgements = [ + { status: 200, headers: {}, body: "" }, + { status: 204, headers: {}, body: "" }, + { status: 200, headers: { "Content-Type" => "text/plain" }, body: "OK" }, + ] + acknowledgements.each do |acknowledgement| + WebMock.reset! + + stub_request(:post, url).with { |req| JSON.parse(req.body)["method"] == "initialize" }.to_return do |req| + { + status: 200, + headers: { "Content-Type" => "application/json", "Mcp-Session-Id" => "session-abc" }, + body: { jsonrpc: "2.0", id: JSON.parse(req.body)["id"], result: { protocolVersion: "2025-11-25" } }.to_json, + } + end + + stub_request(:post, url).with { |req| + JSON.parse(req.body)["method"] == "notifications/initialized" + }.to_return(**acknowledgement) + + client = HTTP.new(url: url) + client.connect(mode: :legacy) + + assert_predicate(client, :connected?, acknowledgement.inspect) + assert_equal("session-abc", client.session_id) + end + end + + def test_send_request_returns_nil_for_a_notification_answered_with_an_sse_stream_carrying_no_response + # A primed stream that closes without a response would make a request reconnect; a notification has + # no response to resume for, so the stream is its acknowledgement. + stub_request(:post, url).to_return( + status: 200, + headers: { "Content-Type" => "text/event-stream" }, + body: "id: event-1\nretry: 100\ndata:\n\n", + ) + + response = client.send_request(request: { jsonrpc: "2.0", method: "notifications/initialized" }) + + assert_nil(response) + assert_not_requested(:get, url) + end + + def test_send_request_still_raises_for_a_notification_answered_with_a_3xx + # Only a success status acknowledges a notification; a redirect, which the transport does not follow, + # keeps its handling, on both the body-less and the SSE path. + answers = [ + { status: 302, headers: { "Location" => "https://example.com/elsewhere" }, body: "", error_type: :unsupported_media_type }, + { status: 302, headers: { "Content-Type" => "text/event-stream" }, body: "data:\n\n", error_type: :parse_error }, + ] + answers.each do |answer| + stub_request(:post, url).to_return(status: answer[:status], headers: answer[:headers], body: answer[:body]) + + error = assert_raises(RequestHandlerError, answer.inspect) do + client.send_request(request: { jsonrpc: "2.0", method: "notifications/initialized" }) + end + + assert_equal(answer[:error_type], error.error_type) + end + end + + def test_send_request_raises_error_for_a_request_answered_without_a_json_or_sse_body + [{ status: 200, headers: {}, body: "" }, { status: 204, headers: {}, body: "" }].each do |answer| + stub_request(:post, url).to_return(**answer) + + error = assert_raises(RequestHandlerError, answer.inspect) do + client.send_request(request: { jsonrpc: "2.0", id: "test_id", method: "tools/list" }) + end + + assert_equal(:unsupported_media_type, error.error_type) + end + end + def test_send_request_raises_error_for_an_sse_response_that_is_not_json_rpc_2_0 # The first response-shaped event settles the exchange, so the valid one after it changes nothing. sse_body = <<~SSE