Skip to content

Commit ab8f53c

Browse files
committed
Add Accept header validation
- Set Accept header to request JSON or SSE responses - Validate response Content-Type and raise error for non-JSON responses - Add Accept header to test fixtures - Use explicit hash syntax for Ruby 2.7 compatibility
1 parent 4968d9c commit ab8f53c

File tree

5 files changed

+319
-8
lines changed

5 files changed

+319
-8
lines changed

lib/mcp/client/http.rb

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
module MCP
44
class Client
55
class HTTP
6+
ACCEPT_HEADER = "application/json, text/event-stream"
7+
68
attr_reader :url
79

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

17-
client.post("", request).body
19+
response = client.post("", request)
20+
validate_response_content_type!(response, method, params)
21+
response.body
1822
rescue Faraday::BadRequestError => e
1923
raise RequestHandlerError.new(
2024
"The #{method} request is invalid",
21-
{ method:, params: },
25+
{ method: method, params: params },
2226
error_type: :bad_request,
2327
original_error: e,
2428
)
2529
rescue Faraday::UnauthorizedError => e
2630
raise RequestHandlerError.new(
2731
"You are unauthorized to make #{method} requests",
28-
{ method:, params: },
32+
{ method: method, params: params },
2933
error_type: :unauthorized,
3034
original_error: e,
3135
)
3236
rescue Faraday::ForbiddenError => e
3337
raise RequestHandlerError.new(
3438
"You are forbidden to make #{method} requests",
35-
{ method:, params: },
39+
{ method: method, params: params },
3640
error_type: :forbidden,
3741
original_error: e,
3842
)
3943
rescue Faraday::ResourceNotFound => e
4044
raise RequestHandlerError.new(
4145
"The #{method} request is not found",
42-
{ method:, params: },
46+
{ method: method, params: params },
4347
error_type: :not_found,
4448
original_error: e,
4549
)
4650
rescue Faraday::UnprocessableEntityError => e
4751
raise RequestHandlerError.new(
4852
"The #{method} request is unprocessable",
49-
{ method:, params: },
53+
{ method: method, params: params },
5054
error_type: :unprocessable_entity,
5155
original_error: e,
5256
)
5357
rescue Faraday::Error => e # Catch-all
5458
raise RequestHandlerError.new(
5559
"Internal error handling #{method} request",
56-
{ method:, params: },
60+
{ method: method, params: params },
5761
error_type: :internal_error,
5862
original_error: e,
5963
)
@@ -70,6 +74,7 @@ def client
7074
faraday.response(:json)
7175
faraday.response(:raise_error)
7276

77+
faraday.headers["Accept"] = ACCEPT_HEADER
7378
headers.each do |key, value|
7479
faraday.headers[key] = value
7580
end
@@ -83,6 +88,17 @@ def require_faraday!
8388
"Add it to your Gemfile: gem 'faraday', '>= 2.0'" \
8489
"See https://rubygems.org/gems/faraday for more details."
8590
end
91+
92+
def validate_response_content_type!(response, method, params)
93+
content_type = response.headers["Content-Type"]
94+
return if content_type&.include?("application/json")
95+
96+
raise RequestHandlerError.new(
97+
"Unsupported Content-Type: #{content_type.inspect}. This client only supports JSON responses.",
98+
{ method: method, params: params },
99+
error_type: :unsupported_media_type,
100+
)
101+
end
86102
end
87103
end
88104
end

lib/mcp/server/transports/streamable_http_transport.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ def initialize(server, stateless: false)
1717
@stateless = stateless
1818
end
1919

20+
REQUIRED_POST_ACCEPT_TYPES = ["application/json", "text/event-stream"].freeze
21+
REQUIRED_GET_ACCEPT_TYPES = ["text/event-stream"].freeze
22+
2023
def handle_request(request)
2124
case request.env["REQUEST_METHOD"]
2225
when "POST"
@@ -105,6 +108,9 @@ def send_ping_to_stream(stream)
105108
end
106109

107110
def handle_post(request)
111+
accept_error = validate_accept_header(request, REQUIRED_POST_ACCEPT_TYPES)
112+
return accept_error if accept_error
113+
108114
body_string = request.body.read
109115
session_id = extract_session_id(request)
110116

@@ -128,6 +134,9 @@ def handle_get(request)
128134
return method_not_allowed_response
129135
end
130136

137+
accept_error = validate_accept_header(request, REQUIRED_GET_ACCEPT_TYPES)
138+
return accept_error if accept_error
139+
131140
session_id = extract_session_id(request)
132141

133142
return missing_session_id_response unless session_id
@@ -178,6 +187,31 @@ def extract_session_id(request)
178187
request.env["HTTP_MCP_SESSION_ID"]
179188
end
180189

190+
def validate_accept_header(request, required_types)
191+
accept_header = request.env["HTTP_ACCEPT"]
192+
return not_acceptable_response(required_types) unless accept_header
193+
194+
accepted_types = parse_accept_header(accept_header)
195+
missing_types = required_types - accepted_types
196+
return not_acceptable_response(required_types) unless missing_types.empty?
197+
198+
nil
199+
end
200+
201+
def parse_accept_header(header)
202+
header.split(",").map do |part|
203+
part.split(";").first.strip
204+
end
205+
end
206+
207+
def not_acceptable_response(required_types)
208+
[
209+
406,
210+
{ "Content-Type" => "application/json" },
211+
[{ error: "Not Acceptable: Accept header must include #{required_types.join(" and ")}" }.to_json],
212+
]
213+
end
214+
181215
def parse_request_body(body_string)
182216
JSON.parse(body_string)
183217
rescue JSON::ParserError, TypeError

test/mcp/client/http_test.rb

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def test_headers_are_added_to_the_request
4040
headers: {
4141
"Authorization" => "Bearer token",
4242
"Content-Type" => "application/json",
43+
"Accept" => "application/json, text/event-stream",
4344
},
4445
body: request.to_json,
4546
)
@@ -54,6 +55,53 @@ def test_headers_are_added_to_the_request
5455
client.send_request(request: request)
5556
end
5657

58+
def test_accept_header_is_included_in_requests
59+
request = {
60+
jsonrpc: "2.0",
61+
id: "test_id",
62+
method: "tools/list",
63+
}
64+
65+
stub_request(:post, url)
66+
.with(
67+
headers: {
68+
"Accept" => "application/json, text/event-stream",
69+
},
70+
)
71+
.to_return(
72+
status: 200,
73+
headers: { "Content-Type" => "application/json" },
74+
body: { result: { tools: [] } }.to_json,
75+
)
76+
77+
client.send_request(request: request)
78+
end
79+
80+
def test_custom_accept_header_overrides_default
81+
custom_accept = "application/json"
82+
custom_client = HTTP.new(url: url, headers: { "Accept" => custom_accept })
83+
84+
request = {
85+
jsonrpc: "2.0",
86+
id: "test_id",
87+
method: "tools/list",
88+
}
89+
90+
stub_request(:post, url)
91+
.with(
92+
headers: {
93+
"Accept" => custom_accept,
94+
},
95+
)
96+
.to_return(
97+
status: 200,
98+
headers: { "Content-Type" => "application/json" },
99+
body: { result: { tools: [] } }.to_json,
100+
)
101+
102+
custom_client.send_request(request: request)
103+
end
104+
57105
def test_send_request_returns_faraday_response
58106
request = {
59107
jsonrpc: "2.0",
@@ -194,6 +242,33 @@ def test_send_request_raises_internal_error
194242
assert_equal({ method: "tools/list", params: nil }, error.request)
195243
end
196244

245+
def test_send_request_raises_error_for_non_json_response
246+
request = {
247+
jsonrpc: "2.0",
248+
id: "test_id",
249+
method: "tools/list",
250+
}
251+
252+
stub_request(:post, url)
253+
.with(body: request.to_json)
254+
.to_return(
255+
status: 200,
256+
headers: { "Content-Type" => "text/event-stream" },
257+
body: "data: {}\n\n",
258+
)
259+
260+
error = assert_raises(RequestHandlerError) do
261+
client.send_request(request: request)
262+
end
263+
264+
assert_equal(
265+
'Unsupported Content-Type: "text/event-stream". This client only supports JSON responses.',
266+
error.message,
267+
)
268+
assert_equal(:unsupported_media_type, error.error_type)
269+
assert_equal({ method: "tools/list", params: nil }, error.request)
270+
end
271+
197272
private
198273

199274
def stub_request(method, url)

test/mcp/server/transports/streamable_http_notification_integration_test.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,11 +232,20 @@ class StreamableHTTPNotificationIntegrationTest < ActiveSupport::TestCase
232232
private
233233

234234
def create_rack_request(method, path, headers, body = nil)
235+
default_accept = case method
236+
when "POST"
237+
{ "HTTP_ACCEPT" => "application/json, text/event-stream" }
238+
when "GET"
239+
{ "HTTP_ACCEPT" => "text/event-stream" }
240+
else
241+
{}
242+
end
243+
235244
env = {
236245
"REQUEST_METHOD" => method,
237246
"PATH_INFO" => path,
238247
"rack.input" => StringIO.new(body.to_s),
239-
}.merge(headers)
248+
}.merge(default_accept, headers)
240249

241250
Rack::Request.new(env)
242251
end

0 commit comments

Comments
 (0)