From 243c19a562562d0c43cd95b7939a482e9f49da9e Mon Sep 17 00:00:00 2001 From: Koichi ITO Date: Tue, 13 Oct 2026 18:31:19 +0900 Subject: [PATCH] Accept any 2xx acknowledgement of a notification in the HTTP client ## Motivation and Context `MCP::Client::HTTP` treats only `202 Accepted` as the acknowledgement of a notification. Any other status goes through the response body resolution, which requires a JSON or SSE content type, so a server that answers `notifications/initialized` with an empty 200 and no `Content-Type` fails `connect` with `Unsupported Content-Type: nil` (issue #598). The spec has the server answer a notification with 202 and no body, so such a server is out of spec, but a notification awaits no response, and the TypeScript and Python clients check the content type of a response only for a request: any 2xx acknowledges a notification, whatever the body. The transport now accepts any 2xx without a JSON or SSE body the same way. A notification answered with such a 2xx is acknowledged with `nil`, and an SSE stream that ends without a response acknowledges it too, with no reconnection, since there is no response to resume for. A JSON or SSE body answering a notification is handed over as before, a 3xx keeps its handling, and a request answered with a 2xx other than 202 and without a JSON or SSE body still fails with `unsupported_media_type`. `send_notification`, which `MCP::Client#cancel` uses, never checked the response; `connect` keeps sending `notifications/initialized` through `send_request` so the 401 and 404 handling applies to it. Fixes #598. ## How Has This Been Tested? New tests in `test/mcp/client/http_test.rb`: `connect` succeeds when the notification is acknowledged with an empty 200, a 204, or a `text/plain` 200; a notification answered with an SSE stream carrying no response returns `nil` without reconnecting; a notification answered with a 302 still raises on both paths; a request answered without a JSON or SSE body still raises. Against the previous library the first raises `Unsupported Content-Type: nil` and the second reconnects. ## Breaking Changes None. --- lib/mcp/client/http.rb | 17 ++++++-- test/mcp/client/http_test.rb | 76 ++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) 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