diff --git a/README.md b/README.md index 14a2874..744e8b2 100644 --- a/README.md +++ b/README.md @@ -1823,6 +1823,18 @@ it also records the `Origin` header at `initialize` and rejects a later request when both are present - a non-browser client that omits `Origin` (e.g. `curl` or a script) is not stopped by this check. Enforcing ownership against a determined attacker requires supplying the validator with an authenticated principal. +#### Request Size Limits + +`StreamableHTTPTransport` bounds how many bytes a single POST body may allocate, so a peer cannot exhaust memory +with one oversized message. A body larger than `max_request_bytes` (default 4 MiB) is rejected with HTTP 413, +and JSON nesting depth is capped. The 4 MiB default comfortably fits a typical JSON-RPC message (a 4 MiB JSON +string decodes to roughly 3 MiB of base64 payload) and matches the TypeScript SDK's 4 MB default; raise it only +if you exchange unusually large payloads: + +```ruby +transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, max_request_bytes: 8 * 1024 * 1024) +``` + ### Pagination The MCP Ruby SDK supports [pagination](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/pagination) diff --git a/lib/mcp/client/stdio.rb b/lib/mcp/client/stdio.rb index f7670dd..e835968 100644 --- a/lib/mcp/client/stdio.rb +++ b/lib/mcp/client/stdio.rb @@ -302,6 +302,15 @@ module MCP line = @stdout.gets("\n", @max_line_bytes) return line unless line && !line.end_with?("\n") && line.bytesize >= @max_line_bytes + # The over-limit frame leaves leftover bytes in the pipe, so the stream is desynced and + # cannot be resumed. Close before raising so a later `send_request` fails cleanly instead + # of parsing a truncated frame. + begin + close + rescue StandardError + nil + end + raise RequestHandlerError.new( "Server response frame exceeds #{@max_line_bytes} bytes without a newline", { method: method, params: params }, diff --git a/lib/mcp/server/transports/streamable_http_transport.rb b/lib/mcp/server/transports/streamable_http_transport.rb index 6f45503..90d2467 100644 --- a/lib/mcp/server/transports/streamable_http_transport.rb +++ b/lib/mcp/server/transports/streamable_http_transport.rb @@ -39,6 +39,18 @@ module MCP UNSET_IDLE_TIMEOUT = Object.new.freeze private_constant :UNSET_IDLE_TIMEOUT + # Default upper bound on the JSON-RPC request body. `handle_post` reads the whole + # body into memory and parses it, so without a cap a single unauthenticated POST + # can allocate gigabytes and OOM the worker. 4 MiB comfortably + # fits a typical JSON-RPC request (a 4 MiB JSON string decodes to ~3 MiB of base64 + # payload); raise `max_request_bytes:` for unusually large payloads. Matches the + # TypeScript SDK's 4 MB default. + DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024 + + # Conservative bound on JSON nesting depth, so a deeply nested body cannot exhaust + # the stack or amplify parse cost (complements the byte cap). + MAX_JSON_NESTING = 64 + # Creates a Streamable HTTP transport that can be mounted as a Rack app. # # @param server [MCP::Server] the server whose requests this transport dispatches. @@ -66,6 +78,8 @@ module MCP # the deploying application's responsibility (the transport never receives the authenticated identity # on its own); this is the seam to enforce ownership and mitigate session poisoning. Without a validator, # ownership is not enforced. + # @param max_request_bytes [Integer] upper bound in bytes on a POST request body; larger + # requests are rejected with HTTP 413. Defaults to 4 MiB. def initialize( server, stateless: false, @@ -75,7 +89,8 @@ module MCP allowed_origins: nil, allowed_hosts: nil, dns_rebinding_protection: true, - session_request_validator: nil + session_request_validator: nil, + max_request_bytes: DEFAULT_MAX_REQUEST_BYTES ) super(server) # Maps `session_id` to `{ get_sse_stream: stream_object, server_session: ServerSession, last_active_at: float_from_monotonic_clock, origin: origin_header }`. @@ -115,6 +130,12 @@ module MCP # The cap guards the stateful session store; stateless mode keeps none. @max_sessions = stateless ? nil : max_sessions + unless max_request_bytes.is_a?(Integer) && max_request_bytes > 0 + raise ArgumentError, "max_request_bytes must be a positive Integer" + end + + @max_request_bytes = max_request_bytes + start_reaper_thread if @session_idle_timeout end @@ -419,7 +440,9 @@ module MCP content_type_error = validate_content_type(request) return content_type_error if content_type_error - body_string = request.body.read + body_string = read_bounded_body(request) + return payload_too_large_response if body_string.nil? + session_id = extract_session_id(request) begin @@ -645,8 +668,34 @@ module MCP ) end + # Reads the request body with a hard byte cap so an unbounded POST cannot exhaust + # memory. A declared `Content-Length` over the cap is rejected + # without reading; the actual read is also bounded to one byte past the cap, so + # a missing or spoofed `Content-Length` (e.g. chunked transfer) is still caught. + # Returns `nil` when the body exceeds the cap. + def read_bounded_body(request) + content_length = request.content_length + return if content_length && content_length.to_i > @max_request_bytes + + body = request.body.read(@max_request_bytes + 1) + return "" if body.nil? + return if body.bytesize > @max_request_bytes + + body + end + + def payload_too_large_response + json_rpc_error_response( + status: 413, + code: JsonRpcHandler::ErrorCode::INVALID_REQUEST, + message: "Payload too large: request body exceeds #{@max_request_bytes} bytes", + ) + end + def parse_request_body(body_string) - JSON.parse(body_string, symbolize_names: true) + # `max_nesting` bounds parse depth; a too-deep body raises `JSON::NestingError`, + # a subclass of `JSON::ParserError`, so it is caught below as a parse error. + JSON.parse(body_string, symbolize_names: true, max_nesting: MAX_JSON_NESTING) rescue JSON::ParserError, TypeError raise InvalidJsonError end diff --git a/test/mcp/client/stdio_test.rb b/test/mcp/client/stdio_test.rb index e57aef6..205fae4 100644 --- a/test/mcp/client/stdio_test.rb +++ b/test/mcp/client/stdio_test.rb @@ -1111,6 +1111,9 @@ module MCP assert_match(/exceeds 1024 bytes without a newline/, error.message) assert_equal(:internal_error, error.error_type) + # The stream is desynced (leftover bytes in the pipe), so the transport is closed + # rather than left resumable on a corrupt stream. + refute_predicate(transport, :connected?) ensure server_thread.join stdin_read.close diff --git a/test/mcp/server/transports/streamable_http_transport_test.rb b/test/mcp/server/transports/streamable_http_transport_test.rb index 36d08bc..7e0b555 100644 --- a/test/mcp/server/transports/streamable_http_transport_test.rb +++ b/test/mcp/server/transports/streamable_http_transport_test.rb @@ -4441,6 +4441,73 @@ module MCP refute headers.frozen?, "SSE response headers should not be frozen" end + test "rejects a request body over max_request_bytes with 413 without reading it all" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 1024) + oversized = { jsonrpc: "2.0", method: "initialize", id: "1", params: { x: "A" * 4096 } }.to_json + + # Content-Length present: rejected before reading. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json", "CONTENT_LENGTH" => oversized.bytesize.to_s }, + oversized, + ) + response = transport.handle_request(request) + + assert_equal 413, response[0] + assert_equal JsonRpcHandler::ErrorCode::INVALID_REQUEST, JSON.parse(response[2][0]).dig("error", "code") + end + + test "rejects an oversized body even when Content-Length is absent" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 1024) + oversized = { jsonrpc: "2.0", method: "initialize", id: "1", params: { x: "A" * 4096 } }.to_json + + # No CONTENT_LENGTH: the bounded read must still catch it. + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + oversized, + ) + response = transport.handle_request(request) + + assert_equal 413, response[0] + end + + test "accepts a request body at the max_request_bytes boundary" do + transport = StreamableHTTPTransport.new(@server, max_request_bytes: 4096) + body = { jsonrpc: "2.0", method: "initialize", id: "1" }.to_json + assert_operator body.bytesize, :<=, 4096 + + request = create_rack_request( + "POST", + "/", + { "CONTENT_TYPE" => "application/json" }, + body, + ) + response = transport.handle_request(request) + + assert_equal 200, response[0] + end + + test "rejects deeply nested JSON beyond the nesting cap as a parse error" do + depth = StreamableHTTPTransport::MAX_JSON_NESTING + 5 + nested = ("[" * depth) + ("]" * depth) + body = %({"jsonrpc":"2.0","method":"initialize","id":"1","params":{"deep":#{nested}}}) + + request = create_rack_request("POST", "/", { "CONTENT_TYPE" => "application/json" }, body) + response = @transport.handle_request(request) + + assert_equal 400, response[0] + assert_equal JsonRpcHandler::ErrorCode::PARSE_ERROR, JSON.parse(response[2][0]).dig("error", "code") + end + + test "raises ArgumentError for an invalid max_request_bytes" do + [nil, 0, -1, 1.5, "1024"].each do |invalid| + assert_raises(ArgumentError) { StreamableHTTPTransport.new(@server, max_request_bytes: invalid) } + end + end + test "handle_request returns 403 for POST when Origin is not in allowed_origins" do transport = StreamableHTTPTransport.new(@server, allowed_origins: ["https://app.example.com"])