Skip to content

Commit b2032c3

Browse files
committed
Preserve the Request ID in Invalid Request Error Responses
## Motivation and Context Resolves #398. `JsonRpcHandler` returned `"id": null` for JSON-RPC envelope errors (wrong or missing `jsonrpc` version, non-string `method`) even when the incoming payload carried a concrete, valid request id. From the client's perspective the original request stayed pending forever, since no response with the matching id ever arrived: ``` {"jsonrpc":"1.0","id":3,"method":"ping","params":{}} => {"jsonrpc":"2.0","id":null,"error":{"code":-32600,...}} ``` Per JSON-RPC 2.0 (Response object, `id`), the response id MUST be the same as the request's id; null is reserved for requests whose id could not be detected (e.g. Parse error). The envelope validation in `process_request` runs after JSON parsing with the request hash in hand, so the id is detectable - it was simply discarded by passing the `:unknown_id` sentinel unconditionally. The fix passes the request's id to `error_response` whenever the request carries one. The existing guards keep every other case intact: `error_response` already nils out ids that fail validation (so a type-invalid or pattern-violating id still yields `"id": null`, preserving the XSS-prevention policy on echoed ids), and the `:unknown_id` sentinel is kept for requests without an id so an Invalid Request error still produces a null-id response, matching the spec's own example. Parse errors and non-object requests are unchanged (null is correct there), and batch entries pick up the fix automatically since each entry goes through `process_request`. For reference, the TypeScript and Python SDKs currently respond with `"id": null` in these cases as well, because both validate the whole message against a schema (zod / pydantic) before extracting the id. This change is driven by the JSON-RPC 2.0 requirement rather than parity; the Ruby transport layer already echoes the request id where recoverable (`invalid_request_response(request_id:)` in `StreamableHTTPTransport`), and this closes the remaining gap in `JsonRpcHandler`. ## How Has This Been Tested? - `bundle exec rake` (tests, RuboCop, and conformance baseline all green; 1064 runs, 0 failures) - Reproduced the three payloads from #398 before and after the fix; they now return `"id":3`, `"id":4`, and `"id":8` respectively - New and updated unit tests covering: id preserved for wrong version, missing `jsonrpc`, numeric `method`, and `rpc.`-prefixed `method`; id preserved for an invalid entry inside a batch; null id kept for requests without an id, with an explicit null id, and with an id that fails validation ## Breaking Changes None in the protocol sense - this aligns the error responses with JSON-RPC 2.0. Clients that matched `"id": null` on these envelope errors will now see the request's own id, which is what allows them to correlate the error with the pending request.
1 parent 9193745 commit b2032c3

2 files changed

Lines changed: 81 additions & 3 deletions

File tree

lib/json_rpc_handler.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,12 @@ def process_request(request, id_validation_pattern:, &method_finder)
7979
'Method name must be a string and not start with "rpc."'
8080
end
8181

82-
return error_response(id: :unknown_id, id_validation_pattern: id_validation_pattern, error: {
82+
# Per JSON-RPC 2.0 (Response object, `id`), the error response must carry
83+
# the same id as the request when the id could be detected; null is only
84+
# for requests whose id could not be determined. `error_response` nils out
85+
# ids that fail validation, and the `:unknown_id` sentinel keeps a response
86+
# (with a null id) being emitted when the request carried no id at all.
87+
return error_response(id: id.nil? ? :unknown_id : id, id_validation_pattern: id_validation_pattern, error: {
8388
code: ErrorCode::INVALID_REQUEST,
8489
message: "Invalid Request",
8590
data: error,

test/json_rpc_handler_test.rb

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,18 @@
4343
message: "Invalid Request",
4444
data: "JSON-RPC version must be 2.0",
4545
}
46+
assert_equal 1, @response[:id]
47+
end
48+
49+
it "returns an error preserving the request id when jsonrpc is missing" do
50+
handle id: 4, method: "add", params: { a: 1, b: 2 }
51+
52+
assert_rpc_error expected_error: {
53+
code: -32600,
54+
message: "Invalid Request",
55+
data: "JSON-RPC version must be 2.0",
56+
}
57+
assert_equal 4, @response[:id]
4658
end
4759

4860
# method
@@ -58,6 +70,7 @@
5870
message: "Invalid Request",
5971
data: 'Method name must be a string and not start with "rpc."',
6072
}
73+
assert_equal 1, @response[:id]
6174
end
6275

6376
it "returns an error when method begins with 'rpc.'" do
@@ -68,6 +81,7 @@
6881
message: "Invalid Request",
6982
data: 'Method name must be a string and not start with "rpc."',
7083
}
84+
assert_equal 1, @response[:id]
7185
end
7286

7387
# params
@@ -315,6 +329,50 @@
315329
assert_nil @response[:id]
316330
end
317331

332+
it "returns the same request id on an Invalid Request error when the id is detectable" do
333+
handle jsonrpc: "1.0", id: 3, method: "ping", params: {}
334+
335+
assert_rpc_error expected_error: {
336+
code: -32600,
337+
message: "Invalid Request",
338+
data: "JSON-RPC version must be 2.0",
339+
}
340+
assert_equal 3, @response[:id]
341+
end
342+
343+
it "returns nil for id on an Invalid Request error when the request has no id" do
344+
handle jsonrpc: "1.0", method: "ping", params: {}
345+
346+
assert_rpc_error expected_error: {
347+
code: -32600,
348+
message: "Invalid Request",
349+
data: "JSON-RPC version must be 2.0",
350+
}
351+
assert_nil @response[:id]
352+
end
353+
354+
it "returns nil for id on an Invalid Request error when the request id is explicitly null" do
355+
handle jsonrpc: "1.0", id: nil, method: "ping", params: {}
356+
357+
assert_rpc_error expected_error: {
358+
code: -32600,
359+
message: "Invalid Request",
360+
data: "JSON-RPC version must be 2.0",
361+
}
362+
assert_nil @response[:id]
363+
end
364+
365+
it "returns nil for id on an Invalid Request error when the id fails validation" do
366+
handle jsonrpc: "1.0", id: "<script>alert('xss')</script>", method: "ping", params: {}
367+
368+
assert_rpc_error expected_error: {
369+
code: -32600,
370+
message: "Invalid Request",
371+
data: "JSON-RPC version must be 2.0",
372+
}
373+
assert_nil @response[:id]
374+
end
375+
318376
# 5.1 Error object
319377
#
320378
# When a rpc call encounters an error, the Response Object MUST contain the error member with a value that is a
@@ -610,6 +668,20 @@
610668

611669
assert_nil @response
612670
end
671+
672+
it "preserves the request id of an invalid entry within a batch" do
673+
register("add") { |params| params[:a] + params[:b] }
674+
675+
handle [
676+
{ jsonrpc: "2.0", id: 100, method: "add", params: { a: 1, b: 2 } },
677+
{ jsonrpc: "1.0", id: 200, method: "add", params: { a: 3, b: 4 } },
678+
]
679+
680+
assert @response.is_a?(Array)
681+
assert_equal [100, 200], @response.map { |result| result[:id] }
682+
assert_equal 3, @response.first[:result]
683+
assert_equal(-32600, @response.last.dig(:error, :code))
684+
end
613685
end
614686

615687
# 7 Examples
@@ -756,10 +828,11 @@
756828
assert_nil @response
757829
end
758830

759-
it "returns an error with the id set to nil when the request is invalid" do
831+
it "returns an error preserving the request id when the request is invalid" do
760832
handle_json({ jsonrpc: "0.0", id: 1, method: "add", params: { a: 1, b: 2 } }.to_json)
761833

762-
assert_nil @response[:id]
834+
assert_equal 1, @response[:id]
835+
assert_equal(-32600, @response.dig(:error, :code))
763836
end
764837
end
765838

0 commit comments

Comments
 (0)