Merge pull request #448 from koic/handle_non_object_json_rpc_messages

Handle non-object JSON-RPC messages without raising
This commit is contained in:
Koichi ITO 2026-07-12 02:12:46 +09:00 • committed by GitHub
commit 3d442d0a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 134 additions and 1 deletions

View file

@ -63,12 +63,29 @@ module JsonRpcHandler
message: "Parse error",
data: "Invalid JSON",
})
rescue StandardError
# Last-resort guard so an unexpected handling error returns a JSON-RPC error
# instead of escaping to the transport loop.
response = error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INTERNAL_ERROR,
message: "Internal error",
})
end
response&.to_json
end
def process_request(request, id_validation_pattern:, &method_finder)
# A batch element can be any JSON value; a non-object element cannot carry an id or a method,
# so reject it before the `request[:id]` access below.
unless request.is_a?(Hash)
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
code: ErrorCode::INVALID_REQUEST,
message: "Invalid Request",
data: "Request must be an object",
})
end
id = request[:id]
error = if !valid_version?(request[:jsonrpc])

View file

@ -259,7 +259,9 @@ module MCP
parsed = JSON.parse(line.strip)
next unless parsed.key?("id")
# A JSON-RPC message is an object; skip a non-object frame (array or scalar)
# the same way as a frame without an id.
next unless parsed.is_a?(Hash) && parsed.key?("id")
return parsed if parsed["id"] == request_id
end

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

View file

@ -146,6 +146,68 @@ module MCP
stdout_write.close
end
def test_send_request_skips_non_object_frames
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe
stderr_read, _ = IO.pipe
Open3.stubs(:popen3).returns([stdin_write, stdout_read, stderr_read, mock_wait_thread])
transport = Stdio.new(command: "ruby", args: ["server.rb"])
request = {
jsonrpc: "2.0",
id: "test-id",
method: "tools/list",
}
server_thread = Thread.new do
init_line = stdin_read.gets
init_request = JSON.parse(init_line)
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: init_request["id"],
result: {
protocolVersion: "2025-11-25",
capabilities: {},
serverInfo: { name: "test-server", version: "1.0.0" },
},
}))
stdout_write.flush
# Read initialized notification
stdin_read.gets
# Read tools/list request
stdin_read.gets
# Non-object frames are skipped like any other non-matching frame.
stdout_write.puts("[[]]")
stdout_write.puts("null")
stdout_write.flush
# Then send the actual response
stdout_write.puts(JSON.generate({
jsonrpc: "2.0",
id: "test-id",
result: { tools: [] },
}))
stdout_write.flush
end
transport.connect
response = transport.send_request(request: request)
assert_equal("test-id", response["id"])
assert_empty(response.dig("result", "tools"))
ensure
server_thread.join
stdin_read.close
stdin_write.close
stdout_read.close
stdout_write.close
end
def test_send_request_raises_error_when_process_exits
stdin_read, stdin_write = IO.pipe
stdout_read, stdout_write = IO.pipe

View file

@ -134,6 +134,27 @@ module MCP
end
end
test "open handles a malformed JSON-RPC batch element and emits an error response" do
input = StringIO.new("[[]]\n")
output = StringIO.new
original_stdin = $stdin
original_stdout = $stdout
begin
$stdin = input
$stdout = output
@transport.open
response = JSON.parse(output.string, symbolize_names: true)
assert_nil(response[:id])
assert_equal(-32600, response[:error][:code])
assert_equal("Invalid Request", response[:error][:message])
ensure
$stdin = original_stdin
$stdout = original_stdout
end
end
test "rejects duplicate initialize on the same stdio session with -32600" do
first = {
jsonrpc: "2.0",