Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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?
Expand All @@ -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 },
Expand Down
76 changes: 76 additions & 0 deletions test/mcp/client/http_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading