Skip to content

Commit 4f279e2

Browse files
committed
fix: apply max_message_bytes to remaining HTTP responses
Apply the configured response limit to notification acknowledgements, replies to server-to-client requests, and session DELETE responses. Reuse the existing bounded response reader for these requests. Report an over-limit body as RequestHandlerError, as send_request does. Keep clearing local session state when close raises.
1 parent 5bd9b7a commit 4f279e2

3 files changed

Lines changed: 82 additions & 4 deletions

File tree

‎docs/_client/transports.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ See the [Progress](/server/progress/) page.
131131

132132
`MCP::Client::HTTP.new` accepts an optional `max_message_bytes:` keyword that caps the bytes buffered in memory for a single message from the server -
133133
an SSE event or a JSON response body. A message that reaches this limit before completing is rejected as a transport error, preventing unbounded memory growth from
134-
a server that never terminates an SSE event. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses.
134+
a server that never terminates an SSE event. The same limit applies to the bodies the client reads and drops: the acknowledgement of a notification or of a response
135+
to a server-to-client request, and the answer to the session DELETE. It defaults to `4 * 1024 * 1024` (4 MiB); raise it if your server returns larger responses.
135136

136137
`MCP::Client::HTTP.new` also accepts `max_reconnection_wait:`, a budget in seconds for resuming a closed SSE stream. It gates every wait between reconnection attempts,
137138
and what is left of it becomes the read timeout of each resumed stream. The server chooses that wait through the SSE `retry:` field, and resuming happens on the calling thread,

‎lib/mcp/client/http.rb‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -545,8 +545,12 @@ def send_request(request:)
545545
def send_notification(notification:)
546546
method = notification[:method] || notification["method"]
547547

548-
redacting_authorization_header { client.post("", notification, session_headers) }
548+
redacting_authorization_header do
549+
client.post("", notification, session_headers) { |req| req.options.on_data = bounded_on_data }
550+
end
549551
nil
552+
rescue MessageTooLargeError => e
553+
raise RequestHandlerError.new(e.message, { method: method }, error_type: :internal_error)
550554
rescue Faraday::Error => e
551555
raise RequestHandlerError.new(
552556
"Failed to send #{method} notification",
@@ -573,7 +577,11 @@ def close
573577
end
574578

575579
begin
576-
redacting_authorization_header { client.delete("", nil, session_headers) }
580+
redacting_authorization_header do
581+
client.delete("", nil, session_headers) { |req| req.options.on_data = bounded_on_data }
582+
end
583+
rescue MessageTooLargeError => e
584+
raise RequestHandlerError.new(e.message, {}, error_type: :internal_error)
577585
rescue Faraday::ClientError => e
578586
raise unless [404, 405].include?(e.response&.dig(:status))
579587
ensure
@@ -1234,7 +1242,14 @@ def dispatch_server_request(message)
12341242
end
12351243

12361244
def send_client_response(response)
1237-
redacting_authorization_header { client.post("", response, session_headers) }
1245+
redacting_authorization_header do
1246+
client.post("", response, session_headers) { |req| req.options.on_data = bounded_on_data }
1247+
end
1248+
end
1249+
1250+
# Applies the same response-reading limit as `send_request` to bodies the client does not use.
1251+
def bounded_on_data
1252+
SSEStream.new(abortable: false, max_message_bytes: @max_message_bytes).on_data
12381253
end
12391254

12401255
def parse_json_buffer(buffer, method, params)

‎test/mcp/client/http_test.rb‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1338,6 +1338,68 @@ def test_send_request_accepts_sse_events_that_each_stay_within_max_message_bytes
13381338
assert_equal({ "tools" => [] }, response["result"])
13391339
end
13401340

1341+
def test_send_notification_rejects_response_body_exceeding_max_message_bytes
1342+
notification = {
1343+
jsonrpc: "2.0",
1344+
method: MCP::Methods::NOTIFICATIONS_CANCELLED,
1345+
params: { requestId: "req-1" },
1346+
}
1347+
client = HTTP.new(url: url, max_message_bytes: 64)
1348+
1349+
stub_request(:post, url).with(body: notification.to_json).to_return(status: 202, body: "a" * 128)
1350+
1351+
error = assert_raises(RequestHandlerError) do
1352+
client.send_notification(notification: notification)
1353+
end
1354+
1355+
assert_includes(error.message, "Server response body exceeds 64 bytes")
1356+
assert_equal(:internal_error, error.error_type)
1357+
end
1358+
1359+
def test_send_request_rejects_pong_acknowledgement_exceeding_max_message_bytes
1360+
request = {
1361+
jsonrpc: "2.0",
1362+
id: "test_id",
1363+
method: "tools/list",
1364+
}
1365+
client = HTTP.new(url: url, max_message_bytes: 128)
1366+
1367+
server_ping = { jsonrpc: "2.0", id: 7, method: "ping" }
1368+
tools_result = { jsonrpc: "2.0", id: "test_id", result: { tools: [] } }
1369+
stub_request(:post, url).with(
1370+
body: request.to_json,
1371+
).to_return(
1372+
status: 200,
1373+
headers: { "Content-Type" => "text/event-stream" },
1374+
body: "data: #{server_ping.to_json}\n\ndata: #{tools_result.to_json}\n\n",
1375+
)
1376+
1377+
pong = { jsonrpc: "2.0", id: 7, result: {} }
1378+
stub_request(:post, url).with(body: pong.to_json).to_return(status: 202, body: "a" * 256)
1379+
1380+
error = assert_raises(RequestHandlerError) do
1381+
client.send_request(request: request)
1382+
end
1383+
1384+
assert_includes(error.message, "Server response body exceeds 128 bytes")
1385+
assert_equal(:internal_error, error.error_type)
1386+
end
1387+
1388+
def test_close_rejects_response_body_exceeding_max_message_bytes_and_still_clears_state
1389+
client = HTTP.new(url: url, max_message_bytes: 64)
1390+
stub_initialize
1391+
client.send_request(request: { jsonrpc: "2.0", id: "1", method: "initialize" })
1392+
1393+
stub_request(:delete, url).to_return(status: 200, body: "a" * 128)
1394+
1395+
error = assert_raises(RequestHandlerError) do
1396+
client.close
1397+
end
1398+
1399+
assert_includes(error.message, "Server response body exceeds 64 bytes")
1400+
assert_nil(client.session_id)
1401+
end
1402+
13411403
def test_sse_stream_rejects_unterminated_event_exceeding_max_message_bytes
13421404
stream = HTTP.const_get(:SSEStream).new(abortable: false, max_message_bytes: 64)
13431405
env = stub(response_headers: { "content-type" => "text/event-stream" })

0 commit comments

Comments
 (0)