diff --git a/lib/mcp/client/http.rb b/lib/mcp/client/http.rb index f224508c..0bda93db 100644 --- a/lib/mcp/client/http.rb +++ b/lib/mcp/client/http.rb @@ -427,7 +427,7 @@ def send_request(request:) nil end - body = resolve_response_body(stream, response, method, params) + body = resolve_response_body(stream, response, method, params, notification: !(request[:id] || request["id"])) # 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. @@ -1107,7 +1107,7 @@ def require_faraday! # 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) + def resolve_response_body(stream, response, method, params, notification: false) return stream.response if stream.response # 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected. @@ -1141,6 +1141,10 @@ 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 + # A notification awaits no response, so a server that acknowledges it with 200 and no body + # instead of 202 has still accepted it. The TypeScript and Python SDKs also accept this. + return if notification && 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..ef5b8962 100644 --- a/test/mcp/client/http_test.rb +++ b/test/mcp/client/http_test.rb @@ -831,6 +831,58 @@ def test_send_request_returns_nil_for_202_accepted_response assert_nil(response) end + def test_send_request_returns_nil_for_notification_acknowledged_with_empty_200 + request = { + jsonrpc: "2.0", + method: "notifications/initialized", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 200, body: "") + + response = client.send_request(request: request) + + assert_nil(response) + end + + def test_send_request_raises_error_for_notification_answered_with_empty_4xx_or_5xx + request = { + jsonrpc: "2.0", + method: "notifications/initialized", + } + + [400, 500].each do |status| + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: status, body: "") + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal(status, error.original_error.response[:status]) + end + end + + def test_send_request_raises_error_for_request_answered_with_empty_200 + request = { + jsonrpc: "2.0", + id: "test_id", + method: "tools/list", + } + + stub_request(:post, url) + .with(body: request.to_json) + .to_return(status: 200, body: "") + + error = assert_raises(RequestHandlerError) do + client.send_request(request: request) + end + + assert_equal(:unsupported_media_type, error.error_type) + end + def test_send_request_raises_error_for_sse_without_response request = { jsonrpc: "2.0", @@ -2249,6 +2301,31 @@ def test_connect_performs_initialize_handshake assert_equal({ "name" => "test-server", "version" => "1.0" }, result["serverInfo"]) end + def test_connect_accepts_initialized_notification_acknowledged_with_empty_200 + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "initialize" } + .to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: { + jsonrpc: "2.0", + result: { + protocolVersion: "2025-11-25", + capabilities: {}, + serverInfo: { name: "test-server", version: "1.0" }, + }, + }.to_json, + ) + stub_request(:post, url) + .with { |req| JSON.parse(req.body)["method"] == "notifications/initialized" } + .to_return(status: 200, body: "") + + result = client.connect + + assert_predicate(client, :connected?) + assert_equal("2025-11-25", result["protocolVersion"]) + end + def test_connect_caches_server_info stub_initialize stub_notification