Bound client-side message buffering in the HTTP transport
## Motivation and Context `MCP::Client::HTTP` buffers server bytes with no upper bound in two places: - SSE responses are parsed incrementally, but the parser accumulates `data:` lines until a blank line dispatches the event, so a server that never terminates an event grows the buffer indefinitely. The abort-on-response signal does not help here, since it only fires after a complete response event has been dispatched. - Non-SSE (JSON) bodies accumulate in `SSEStream#buffer` chunk by chunk with no limit. Both the POST response stream and the standalone GET listening stream are affected. The stdio client already bounds server frames (`max_line_bytes:`, 4 MiB default) and the server transports bound request bodies (`max_request_bytes:`, 4 MiB default); this brings the HTTP client in line. `MCP::Client::HTTP.new` gains `max_message_bytes:` (default 4 MiB), which caps the bytes buffered for a single message - an SSE event or a JSON response body. External byte counting cannot tell consumed-and-discarded bytes (comments, field names, delimiters) from consumed-and-retained ones, so the check measures the parser's own String buffers after each chunk. An over-limit message raises `RequestHandlerError` on the request path and stops the GET listening stream instead of reconnecting to the same over-limit event. ## How Has This Been Tested? New tests in `test/mcp/client/http_test.rb` cover the SSE and JSON rejection paths end-to-end, the per-event reset (events that individually fit must not count against later ones), the env-less `on_data` fallback, and the constructor validation. `bundle exec rake` (tests, RuboCop, and conformance baseline) passes. ## Breaking Changes Responses larger than 4 MiB are now rejected by default; pass a larger g`max_message_bytes:` to accept them.
This commit is contained in:
parent
84090a88eb
commit
ced5cd1a95
1 changed files with 170 additions and 3 deletions
|
|
@ -853,6 +853,113 @@ module MCP
|
|||
assert_empty(stream.buffer)
|
||||
end
|
||||
|
||||
def test_send_request_rejects_sse_event_exceeding_max_message_bytes
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
client = HTTP.new(url: url, max_message_bytes: 64)
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: "data: #{"a" * 128}\n",
|
||||
)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_includes(error.message, "Server SSE event exceeds 64 bytes")
|
||||
assert_equal(:internal_error, error.error_type)
|
||||
end
|
||||
|
||||
def test_send_request_rejects_json_body_exceeding_max_message_bytes
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
client = HTTP.new(url: url, max_message_bytes: 64)
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: { result: { tools: ["a" * 128] } }.to_json,
|
||||
)
|
||||
|
||||
error = assert_raises(RequestHandlerError) do
|
||||
client.send_request(request: request)
|
||||
end
|
||||
|
||||
assert_includes(error.message, "Server response body exceeds 64 bytes")
|
||||
assert_equal(:internal_error, error.error_type)
|
||||
end
|
||||
|
||||
def test_send_request_accepts_sse_events_that_each_stay_within_max_message_bytes
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
id: "test_id",
|
||||
method: "tools/list",
|
||||
}
|
||||
client = HTTP.new(url: url, max_message_bytes: 256)
|
||||
|
||||
# The notifications and the response together exceed the limit; each event stays
|
||||
# under it, so dispatched events must not count against the ones that follow.
|
||||
notification = { jsonrpc: "2.0", method: "notifications/progress", params: { token: "a" * 100 } }.to_json
|
||||
sse_body = "data: #{notification}\n\n" * 3 +
|
||||
"data: {\"jsonrpc\":\"2.0\",\"id\":\"test_id\",\"result\":{\"tools\":[]}}\n\n"
|
||||
|
||||
stub_request(:post, url).with(
|
||||
body: request.to_json,
|
||||
).to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "text/event-stream" },
|
||||
body: sse_body,
|
||||
)
|
||||
|
||||
response = client.send_request(request: request)
|
||||
|
||||
assert_equal({ "tools" => [] }, response["result"])
|
||||
end
|
||||
|
||||
def test_sse_stream_rejects_unterminated_event_exceeding_max_message_bytes
|
||||
stream = HTTP.const_get(:SSEStream).new(abortable: false, max_message_bytes: 64)
|
||||
env = stub(response_headers: { "content-type" => "text/event-stream" })
|
||||
chunk = "data: #{"a" * 128}"
|
||||
|
||||
error = assert_raises(HTTP.const_get(:MessageTooLargeError)) do
|
||||
stream.on_data.call(chunk, chunk.bytesize, env)
|
||||
end
|
||||
|
||||
assert_includes(error.message, "Server SSE event exceeds 64 bytes")
|
||||
end
|
||||
|
||||
def test_sse_stream_bounds_buffered_chunks_when_env_is_unavailable
|
||||
stream = HTTP.const_get(:SSEStream).new(abortable: true, max_message_bytes: 64)
|
||||
|
||||
error = assert_raises(HTTP.const_get(:MessageTooLargeError)) do
|
||||
stream.on_data.call("a" * 65, 65)
|
||||
end
|
||||
|
||||
assert_includes(error.message, "Server response body exceeds 64 bytes")
|
||||
end
|
||||
|
||||
def test_initialize_rejects_max_message_bytes_that_is_not_a_positive_integer
|
||||
[0, -1, nil, "4"].each do |value|
|
||||
error = assert_raises(ArgumentError) do
|
||||
HTTP.new(url: url, max_message_bytes: value)
|
||||
end
|
||||
|
||||
assert_equal("max_message_bytes must be a positive Integer", error.message)
|
||||
end
|
||||
end
|
||||
|
||||
def test_send_request_dispatches_server_request_to_registered_handler
|
||||
request = {
|
||||
jsonrpc: "2.0",
|
||||
|
|
|
|||
Loading…
Reference in a new issue