Skip to content
Merged
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
30 changes: 23 additions & 7 deletions lib/mcp/client/http.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
module MCP
class Client
class HTTP
ACCEPT_HEADER = "application/json, text/event-stream"

attr_reader :url

def initialize(url:, headers: {})
Expand All @@ -14,46 +16,48 @@ def send_request(request:)
method = request[:method] || request["method"]
params = request[:params] || request["params"]

client.post("", request).body
response = client.post("", request)
validate_response_content_type!(response, method, params)
response.body
rescue Faraday::BadRequestError => e
raise RequestHandlerError.new(
"The #{method} request is invalid",
{ method:, params: },
{ 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:, params: },
{ 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:, params: },
{ method: method, params: params },
error_type: :forbidden,
original_error: e,
)
rescue Faraday::ResourceNotFound => e
raise RequestHandlerError.new(
"The #{method} request is not found",
{ method:, params: },
{ method: method, params: params },
error_type: :not_found,
original_error: e,
)
rescue Faraday::UnprocessableEntityError => e
raise RequestHandlerError.new(
"The #{method} request is unprocessable",
{ method:, params: },
{ 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:, params: },
{ method: method, params: params },
error_type: :internal_error,
original_error: e,
)
Expand All @@ -70,6 +74,7 @@ def client
faraday.response(:json)
faraday.response(:raise_error)

faraday.headers["Accept"] = ACCEPT_HEADER
headers.each do |key, value|
faraday.headers[key] = value
end
Expand All @@ -83,6 +88,17 @@ def require_faraday!
"Add it to your Gemfile: gem 'faraday', '>= 2.0'" \
"See https://rubygems.org/gems/faraday for more details."
end

def validate_response_content_type!(response, method, params)
content_type = response.headers["Content-Type"]
return if content_type&.include?("application/json")

raise RequestHandlerError.new(
"Unsupported Content-Type: #{content_type.inspect}. This client only supports JSON responses.",
{ method: method, params: params },
error_type: :unsupported_media_type,
)
end
end
end
end
34 changes: 34 additions & 0 deletions lib/mcp/server/transports/streamable_http_transport.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ def initialize(server, stateless: false)
@stateless = stateless
end

REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze

def handle_request(request)
case request.env["REQUEST_METHOD"]
when "POST"
Expand Down Expand Up @@ -105,6 +108,9 @@ def send_ping_to_stream(stream)
end

def handle_post(request)
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
return accept_error if accept_error

body_string = request.body.read
session_id = extract_session_id(request)

Expand All @@ -128,6 +134,9 @@ def handle_get(request)
return method_not_allowed_response
end

accept_error = validate_accept_header(request, REQUIRED_GET_ACCEPT_TYPES)
return accept_error if accept_error

session_id = extract_session_id(request)

return missing_session_id_response unless session_id
Expand Down Expand Up @@ -178,6 +187,31 @@ def extract_session_id(request)
request.env["HTTP_MCP_SESSION_ID"]
end

def validate_accept_header(request, required_types)
accept_header = request.env["HTTP_ACCEPT"]
return not_acceptable_response(required_types) unless accept_header

accepted_types = parse_accept_header(accept_header)
missing_types = required_types - accepted_types
return not_acceptable_response(required_types) unless missing_types.empty?

nil
end

def parse_accept_header(header)
header.split(",").map do |part|
part.split(";").first.strip
end
end

def not_acceptable_response(required_types)
[
406,
{ "Content-Type" => "application/json" },
[{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json],
]
end

def parse_request_body(body_string)
JSON.parse(body_string)
rescue JSON::ParserError, TypeError
Expand Down
75 changes: 75 additions & 0 deletions test/mcp/client/http_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def test_headers_are_added_to_the_request
headers: {
"Authorization" => "Bearer token",
"Content-Type" => "application/json",
"Accept" => "application/json, text/event-stream",
},
body: request.to_json,
)
Expand All @@ -54,6 +55,53 @@ def test_headers_are_added_to_the_request
client.send_request(request: request)
end

def test_accept_header_is_included_in_requests
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/list",
}

stub_request(:post, url)
.with(
headers: {
"Accept" => "application/json, text/event-stream",
},
)
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { tools: [] } }.to_json,
)

client.send_request(request: request)
end

def test_custom_accept_header_overrides_default
custom_accept = "application/json"
custom_client = HTTP.new(url: url, headers: { "Accept" => custom_accept })

request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/list",
}

stub_request(:post, url)
.with(
headers: {
"Accept" => custom_accept,
},
)
.to_return(
status: 200,
headers: { "Content-Type" => "application/json" },
body: { result: { tools: [] } }.to_json,
)

custom_client.send_request(request: request)
end

def test_send_request_returns_faraday_response
request = {
jsonrpc: "2.0",
Expand Down Expand Up @@ -194,6 +242,33 @@ def test_send_request_raises_internal_error
assert_equal({ method: "tools/list", params: nil }, error.request)
end

def test_send_request_raises_error_for_non_json_response
request = {
jsonrpc: "2.0",
id: "test_id",
method: "tools/list",
}

stub_request(:post, url)
.with(body: request.to_json)
.to_return(
status: 200,
headers: { "Content-Type" => "text/event-stream" },
body: "data: {}\n\n",
)

error = assert_raises(RequestHandlerError) do
client.send_request(request: request)
end

assert_equal(
'Unsupported Content-Type: "text/event-stream". This client only supports JSON responses.',
error.message,
)
assert_equal(:unsupported_media_type, error.error_type)
assert_equal({ method: "tools/list", params: nil }, error.request)
end

private

def stub_request(method, url)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,11 +232,20 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
private

def create_rack_request(method, path, headers, body = nil)
default_accept = case method
when "POST"
{ "HTTP_ACCEPT" => "application/json, text/event-stream" }
when "GET"
{ "HTTP_ACCEPT" => "text/event-stream" }
else
{}
end

env = {
"REQUEST_METHOD" => method,
"PATH_INFO" => path,
"rack.input" => StringIO.new(body.to_s),
}.merge(headers)
}.merge(default_accept, headers)

Rack::Request.new(env)
end
Expand Down
Loading