diff --git a/src/a2a/server/routes/jsonrpc_dispatcher.py b/src/a2a/server/routes/jsonrpc_dispatcher.py index 21b71e921..558142dff 100644 --- a/src/a2a/server/routes/jsonrpc_dispatcher.py +++ b/src/a2a/server/routes/jsonrpc_dispatcher.py @@ -233,9 +233,11 @@ async def handle_requests(self, request: Request) -> Response: # noqa: PLR0911, body = await request.json() if isinstance(body, dict): request_id = body.get('id') - # Ensure request_id is valid for JSON-RPC response (str/int/None only) - if request_id is not None and not isinstance( - request_id, str | int + # Ensure request_id is valid for JSON-RPC response (str/int/None only; + # exclude bool since bool is a subclass of int in Python) + if isinstance(request_id, bool) or ( + request_id is not None + and not isinstance(request_id, str | int) ): request_id = None logger.debug('Request body: %s', body) @@ -257,6 +259,13 @@ async def handle_requests(self, request: Request) -> Response: # noqa: PLR0911, message="Invalid request: 'jsonrpc' must be exactly '2.0'" ), ) + if isinstance(base_request._id, bool): # noqa: SLF001 + return self._generate_error_response( + request_id, + InvalidRequestError( + message='Invalid request: id must be a string, integer, or null' + ), + ) except Exception as e: logger.exception('Failed to validate base JSON-RPC request') return self._generate_error_response( diff --git a/tests/server/routes/test_jsonrpc_dispatcher.py b/tests/server/routes/test_jsonrpc_dispatcher.py index 5565b66de..ca2a6d1be 100644 --- a/tests/server/routes/test_jsonrpc_dispatcher.py +++ b/tests/server/routes/test_jsonrpc_dispatcher.py @@ -94,6 +94,41 @@ def _make_send_message_request( } +class TestJsonRpcRequestIds: + """JSON booleans are not numeric JSON-RPC request identifiers.""" + + @pytest.mark.parametrize('request_id', [True, False]) + def test_boolean_id_is_rejected( + self, client: TestClient, mock_handler: AsyncMock, request_id: bool + ) -> None: + request = _make_send_message_request() + request['id'] = request_id + + response = client.post('/', json=request) + body = response.json() + + assert response.status_code == 200 + assert body['id'] is None + assert body['error']['code'] == -32600 + mock_handler.on_message_send.assert_not_awaited() + + @pytest.mark.parametrize('request_id', [0, 1, '', 'request']) + def test_integer_and_string_ids_are_preserved( + self, client: TestClient, mock_handler: AsyncMock, request_id: int | str + ) -> None: + request = _make_send_message_request() + request['id'] = request_id + + response = client.post('/', json=request) + body = response.json() + + assert response.status_code == 200 + assert body['id'] == request_id + assert type(body['id']) is type(request_id) + assert 'result' in body + mock_handler.on_message_send.assert_awaited_once() + + class TestUnrecognizedFields: """Unrecognized fields are ignored, for forward compatibility."""