Handle non-object JSON-RPC messages without raising

## Motivation and Context

A JSON-RPC message is an object, but two stdio-facing paths assumed that and raised
on a non-object value parsed from an incoming frame:

- `JsonRpcHandler.handle` maps each element of a batch array through `process_request`,
  which reads `request[:id]` assuming a Hash. A non-object batch element (for example `[[]]`
  or `[5]`) makes `[][:id]` raise `TypeError`. `handle_json` rescued only `JSON::ParserError`,
  so the error escaped; over stdio it escaped the `StdioTransport#open` read loop.
  The HTTP transport already rejects array bodies before dispatch, so this surfaced only on stdio.
- `MCP::Client::Stdio#read_response` parses each frame from the server and calls `parsed.key?("id")`,
  which raises `NoMethodError` when the server emits a non-object frame.
  Only `JSON::ParserError` was rescued, so one malformed line from the server escaped.

Both are robustness gaps: a malformed frame should be reported or skipped, not raise
an unhandled exception out of the transport.

- `process_request` now returns an `INVALID_REQUEST` error response for a non-object request
  before touching `request[:id]`, so a malformed batch element becomes a normal per-element error.

- `handle_json` gains a last-resort `StandardError` rescue that returns an `INTERNAL_ERROR` response,
  so an unexpected handling error still produces a JSON-RPC reply.
- `read_response` skips a non-object frame the same way it skips a frame without an id.

## How Has This Been Tested?

New tests in `test/json_rpc_handler_test.rb` cover non-object batch elements
(`[[]]`, `[5]`, `["x"]`, `[nil]`, `[true]`), a valid request followed by one non-object element,
and `handle_json("[[]]")` returning an error instead of raising. A test in
`test/mcp/server/transports/stdio_transport_test.rb` drives `open` with a `[[]]` line and
asserts it emits an error response without raising. A test in `test/mcp/client/stdio_test.rb`
has the server emit non-object frames before the response and asserts the client skips them
and still returns the response.

## Breaking Changes

None. A well-formed request, batch, or response is handled exactly as before;
only a non-object message now yields a JSON-RPC error on the server or is skipped by the client
instead of raising.
This commit is contained in:
Koichi ITO 2026-07-08 13:10:40 +09:00
commit 8875990909

View file

@ -682,6 +682,37 @@ describe JsonRpcHandler do
assert_equal 3, @response.first[:result]
assert_equal(-32600, @response.last.dig(:error, :code))
end
it "returns an invalid request error for a non-object batch element instead of raising" do
[[], [1], ["x"], [nil], [true]].each do |batch|
handle batch
assert_equal(-32600, @response.dig(:error, :code))
assert_equal "Invalid Request", @response.dig(:error, :message)
assert_nil @response[:id]
end
end
it "handles a valid request followed by a non-object element without raising" do
register("add") { |params| params[:a] + params[:b] }
handle [
{ jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } },
[],
]
assert @response.is_a?(Array)
assert_equal 3, @response.first[:result]
assert_equal(-32600, @response.last.dig(:error, :code))
assert_nil @response.last[:id]
end
it "does not raise from handle_json when a batch element is not an object" do
result = handle_json("[[]]")
assert_equal(-32600, @response.dig(:error, :code))
refute_nil result
end
end
# 7 Examples