diff --git a/lib/ruby_llm/mcp/native/transports/streamable_http.rb b/lib/ruby_llm/mcp/native/transports/streamable_http.rb index b43b225..0428146 100644 --- a/lib/ruby_llm/mcp/native/transports/streamable_http.rb +++ b/lib/ruby_llm/mcp/native/transports/streamable_http.rb @@ -439,6 +439,11 @@ def handle_success_response(response, request_id, _original_message) start_sse_stream if sse_fallback_available? nil elsif content_type&.include?("application/json") + # Notifications have no request_id and nothing waits on their response. The spec says + # servers respond to them with 202 and no body, but some return 200 with `body: { ok: true }` + # that is not a JSON-RPC envelope, but still a success response. + return if request_id.nil? + response_body = response.respond_to?(:body) ? response.body.to_s : "{}" if response_body == "null" # Fix related to official MCP Ruby SDK implementation response_body = "{}" @@ -447,11 +452,9 @@ def handle_success_response(response, request_id, _original_message) json_response = parse_and_validate_http_response(response_body) result = RubyLLM::MCP::Result.new(json_response, session_id: @session_id) - if request_id - @pending_mutex.synchronize do - queue = @pending_requests.delete(request_id.to_s) - queue&.push(result) - end + @pending_mutex.synchronize do + queue = @pending_requests.delete(request_id.to_s) + queue&.push(result) end result diff --git a/spec/ruby_llm/mcp/native/transports/streamable_http_spec.rb b/spec/ruby_llm/mcp/native/transports/streamable_http_spec.rb index bb1e5a5..4f9580f 100644 --- a/spec/ruby_llm/mcp/native/transports/streamable_http_spec.rb +++ b/spec/ruby_llm/mcp/native/transports/streamable_http_spec.rb @@ -70,6 +70,35 @@ def handle(body) end end + describe "#handle_success_response" do + let(:response) { instance_double(HTTPX::Response) } + + before do + allow(response).to receive(:respond_to?).with(:headers).and_return(true) + allow(response).to receive(:respond_to?).with(:body).and_return(true) + allow(response).to receive_messages( + headers: { "content-type" => "application/json" }, + body: '{"ok":true}' + ) + end + + it "ignores a non-envelope JSON body in the response to a notification" do + result = transport.send( + :handle_success_response, response, nil, { "method" => "notifications/initialized" } + ) + + expect(result).to be_nil + end + + it "still validates the envelope in the response to a request" do + expect do + transport.send( + :handle_success_response, response, 1, { "method" => "tools/list", "id" => 1 } + ) + end.to raise_error(RubyLLM::MCP::Errors::TransportError, /Invalid JSON-RPC envelope/) + end + end + describe "protocol version negotiation" do it "successfully initializes and negotiates protocol version" do client.start @@ -791,9 +820,11 @@ def handle(body) }.to_json ) - # Request without ID should be handled properly (notification) + # A request without ID is a notification: the response body is ignored, + # but the session id header is still captured result = transport.request({ "method" => "test" }, wait_for_response: false) - expect(result.session_id).to eq(session_id) + expect(result).to be_nil + expect(transport.session_id).to eq(session_id) end it "handles very large response gracefully" do