Skip to content

Commit 0cfddae

Browse files
committed
Add Accept header validation
1 parent 4968d9c commit 0cfddae

File tree

5 files changed

+272
-1
lines changed

5 files changed

+272
-1
lines changed

lib/mcp/client/http.rb

Lines changed: 3 additions & 0 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: {})
@@ -70,6 +72,7 @@ def client
7072
faraday.response(:json)
7173
faraday.response(:raise_error)
7274

75+
faraday.headers["Accept"] = ACCEPT_HEADER
7376
headers.each do |key, value|
7477
faraday.headers[key] = value
7578
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: 48 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",

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).merge(headers)
240249

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

test/mcp/server/transports/streamable_http_transport_test.rb

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,164 @@ class StreamableHTTPTransportTest < ActiveSupport::TestCase
573573
assert_equal "Method not allowed", body["error"]
574574
end
575575

576+
test "POST request without Accept header returns 406" do
577+
request = create_rack_request_without_accept(
578+
"POST",
579+
"/",
580+
{ "CONTENT_TYPE" => "application/json" },
581+
{ jsonrpc: "2.0", method: "initialize", id: "123" }.to_json,
582+
)
583+
584+
response = @transport.handle_request(request)
585+
assert_equal 406, response[0]
586+
assert_equal({ "Content-Type" => "application/json" }, response[1])
587+
588+
body = JSON.parse(response[2][0])
589+
assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream",
590+
body["error"]
591+
end
592+
593+
test "POST request with Accept header missing text/event-stream returns 406" do
594+
request = create_rack_request_without_accept(
595+
"POST",
596+
"/",
597+
{
598+
"CONTENT_TYPE" => "application/json",
599+
"HTTP_ACCEPT" => "application/json",
600+
},
601+
{ jsonrpc: "2.0", method: "initialize", id: "123" }.to_json,
602+
)
603+
604+
response = @transport.handle_request(request)
605+
assert_equal 406, response[0]
606+
607+
body = JSON.parse(response[2][0])
608+
assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream",
609+
body["error"]
610+
end
611+
612+
test "POST request with Accept header missing application/json returns 406" do
613+
request = create_rack_request_without_accept(
614+
"POST",
615+
"/",
616+
{
617+
"CONTENT_TYPE" => "application/json",
618+
"HTTP_ACCEPT" => "text/event-stream",
619+
},
620+
{ jsonrpc: "2.0", method: "initialize", id: "123" }.to_json,
621+
)
622+
623+
response = @transport.handle_request(request)
624+
assert_equal 406, response[0]
625+
626+
body = JSON.parse(response[2][0])
627+
assert_equal "Not Acceptable: Accept header must include application/json and text/event-stream",
628+
body["error"]
629+
end
630+
631+
test "POST request with valid Accept header succeeds" do
632+
request = create_rack_request(
633+
"POST",
634+
"/",
635+
{
636+
"CONTENT_TYPE" => "application/json",
637+
"HTTP_ACCEPT" => "application/json, text/event-stream",
638+
},
639+
{ jsonrpc: "2.0", method: "initialize", id: "123" }.to_json,
640+
)
641+
642+
response = @transport.handle_request(request)
643+
assert_equal 200, response[0]
644+
end
645+
646+
test "POST request with Accept header containing quality values succeeds" do
647+
request = create_rack_request(
648+
"POST",
649+
"/",
650+
{
651+
"CONTENT_TYPE" => "application/json",
652+
"HTTP_ACCEPT" => "application/json;q=0.9, text/event-stream;q=0.8",
653+
},
654+
{ jsonrpc: "2.0", method: "initialize", id: "123" }.to_json,
655+
)
656+
657+
response = @transport.handle_request(request)
658+
assert_equal 200, response[0]
659+
end
660+
661+
test "GET request without Accept header returns 406" do
662+
init_request = create_rack_request(
663+
"POST",
664+
"/",
665+
{ "CONTENT_TYPE" => "application/json" },
666+
{ jsonrpc: "2.0", method: "initialize", id: "123" }.to_json,
667+
)
668+
init_response = @transport.handle_request(init_request)
669+
session_id = init_response[1]["Mcp-Session-Id"]
670+
671+
request = create_rack_request_without_accept(
672+
"GET",
673+
"/",
674+
{ "HTTP_MCP_SESSION_ID" => session_id },
675+
)
676+
677+
response = @transport.handle_request(request)
678+
assert_equal 406, response[0]
679+
680+
body = JSON.parse(response[2][0])
681+
assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]
682+
end
683+
684+
test "GET request with Accept header missing text/event-stream returns 406" do
685+
init_request = create_rack_request(
686+
"POST",
687+
"/",
688+
{ "CONTENT_TYPE" => "application/json" },
689+
{ jsonrpc: "2.0", method: "initialize", id: "123" }.to_json,
690+
)
691+
init_response = @transport.handle_request(init_request)
692+
session_id = init_response[1]["Mcp-Session-Id"]
693+
694+
request = create_rack_request_without_accept(
695+
"GET",
696+
"/",
697+
{
698+
"HTTP_MCP_SESSION_ID" => session_id,
699+
"HTTP_ACCEPT" => "application/json",
700+
},
701+
)
702+
703+
response = @transport.handle_request(request)
704+
assert_equal 406, response[0]
705+
706+
body = JSON.parse(response[2][0])
707+
assert_equal "Not Acceptable: Accept header must include text/event-stream", body["error"]
708+
end
709+
710+
test "GET request with valid Accept header succeeds" do
711+
init_request = create_rack_request(
712+
"POST",
713+
"/",
714+
{ "CONTENT_TYPE" => "application/json" },
715+
{ jsonrpc: "2.0", method: "initialize", id: "123" }.to_json,
716+
)
717+
init_response = @transport.handle_request(init_request)
718+
session_id = init_response[1]["Mcp-Session-Id"]
719+
720+
request = create_rack_request(
721+
"GET",
722+
"/",
723+
{
724+
"HTTP_MCP_SESSION_ID" => session_id,
725+
"HTTP_ACCEPT" => "text/event-stream",
726+
},
727+
)
728+
729+
response = @transport.handle_request(request)
730+
assert_equal 200, response[0]
731+
assert_equal "text/event-stream", response[1]["Content-Type"]
732+
end
733+
576734
test "stateless mode allows requests without session IDs, responding with no session ID" do
577735
stateless_transport = StreamableHTTPTransport.new(@server, stateless: true)
578736

@@ -771,6 +929,25 @@ class StreamableHTTPTransportTest < ActiveSupport::TestCase
771929
private
772930

773931
def create_rack_request(method, path, headers, body = nil)
932+
default_accept = case method
933+
when "POST"
934+
{ "HTTP_ACCEPT" => "application/json, text/event-stream" }
935+
when "GET"
936+
{ "HTTP_ACCEPT" => "text/event-stream" }
937+
else
938+
{}
939+
end
940+
941+
env = {
942+
"REQUEST_METHOD" => method,
943+
"PATH_INFO" => path,
944+
"rack.input" => StringIO.new(body.to_s),
945+
}.merge(default_accept).merge(headers)
946+
947+
Rack::Request.new(env)
948+
end
949+
950+
def create_rack_request_without_accept(method, path, headers, body = nil)
774951
env = {
775952
"REQUEST_METHOD" => method,
776953
"PATH_INFO" => path,

0 commit comments

Comments
 (0)