ruby-sdk/lib/mcp/client/http.rb
Ates Goral cfc42dbfe7 Handle 202 Accepted response in HTTP client
Per the Streamable HTTP spec, a server returns 202 Accepted with no
body as the ACK for a JSON-RPC notification or response. The client
previously errored on 202 because it fell through Content-Type
dispatch. Short-circuit to return nil before Content-Type handling so
fire-and-forget messages (e.g. notifications/initialized) no longer
raise.

https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 14:06:07 -04:00

148 lines
4.8 KiB
Ruby

# frozen_string_literal: true
module MCP
class Client
class HTTP
ACCEPT_HEADER = "application/json, text/event-stream"
attr_reader :url
def initialize(url:, headers: {}, &block)
@url = url
@headers = headers
@faraday_customizer = block
end
def send_request(request:)
method = request[:method] || request["method"]
params = request[:params] || request["params"]
response = client.post("", request)
parse_response_body(response, method, params)
rescue Faraday::BadRequestError => e
raise RequestHandlerError.new(
"The #{method} request is invalid",
{ method: method, params: params },
error_type: :bad_request,
original_error: e,
)
rescue Faraday::UnauthorizedError => e
raise RequestHandlerError.new(
"You are unauthorized to make #{method} requests",
{ method: method, params: params },
error_type: :unauthorized,
original_error: e,
)
rescue Faraday::ForbiddenError => e
raise RequestHandlerError.new(
"You are forbidden to make #{method} requests",
{ method: method, params: params },
error_type: :forbidden,
original_error: e,
)
rescue Faraday::ResourceNotFound => e
raise RequestHandlerError.new(
"The #{method} request is not found",
{ method: method, params: params },
error_type: :not_found,
original_error: e,
)
rescue Faraday::UnprocessableEntityError => e
raise RequestHandlerError.new(
"The #{method} request is unprocessable",
{ method: method, params: params },
error_type: :unprocessable_entity,
original_error: e,
)
rescue Faraday::Error => e # Catch-all
raise RequestHandlerError.new(
"Internal error handling #{method} request",
{ method: method, params: params },
error_type: :internal_error,
original_error: e,
)
end
private
attr_reader :headers
def client
require_faraday!
@client ||= Faraday.new(url) do |faraday|
faraday.request(:json)
faraday.response(:json)
faraday.response(:raise_error)
faraday.headers["Accept"] = ACCEPT_HEADER
headers.each do |key, value|
faraday.headers[key] = value
end
@faraday_customizer&.call(faraday)
end
end
def require_faraday!
require "faraday"
rescue LoadError
raise LoadError, "The 'faraday' gem is required to use the MCP client HTTP transport. " \
"Add it to your Gemfile: gem 'faraday', '>= 2.0'" \
"See https://rubygems.org/gems/faraday for more details."
end
def require_event_stream_parser!
require "event_stream_parser"
rescue LoadError
raise LoadError, "The 'event_stream_parser' gem is required to parse SSE responses. " \
"Add it to your Gemfile: gem 'event_stream_parser', '>= 1.0'. " \
"See https://rubygems.org/gems/event_stream_parser for more details."
end
def parse_response_body(response, method, params)
# 202 Accepted is the server's ACK for a JSON-RPC notification or response; no body is expected.
# https://modelcontextprotocol.io/specification/2025-11-25/basic/transports#sending-messages-to-the-server
return if response.status == 202
content_type = response.headers["Content-Type"]
if content_type&.include?("text/event-stream")
parse_sse_response(response.body, method, params)
elsif content_type&.include?("application/json")
response.body
else
raise RequestHandlerError.new(
"Unsupported Content-Type: #{content_type.inspect}. Expected application/json or text/event-stream.",
{ method: method, params: params },
error_type: :unsupported_media_type,
)
end
end
def parse_sse_response(body, method, params)
require_event_stream_parser!
json_rpc_response = nil
parser = EventStreamParser::Parser.new
parser.feed(body.to_s) do |_type, data, _id|
next if data.empty?
begin
parsed = JSON.parse(data)
json_rpc_response = parsed if parsed.is_a?(Hash) && (parsed.key?("result") || parsed.key?("error"))
rescue JSON::ParserError
next
end
end
return json_rpc_response if json_rpc_response
raise RequestHandlerError.new(
"No valid JSON-RPC response found in SSE stream",
{ method: method, params: params },
error_type: :parse_error,
)
end
end
end
end