From 7758bdca1c34463df7ddb663c48c8a4e4c625dc9 Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Wed, 23 Sep 2026 12:45:06 +0000 Subject: [PATCH 1/3] feat(rest): serve HTTP+JSON responses as application/a2a+json Spec 11.1 asks the HTTP+JSON binding to use application/a2a+json; every REST response inherited Starlette's application/json default. Success responses only. The error path runs through rest_error_handler, which the v0.3 compat binding shares, and that binding predates the media type. --- src/a2a/server/routes/rest_dispatcher.py | 42 +++++++++++++++++++----- src/a2a/utils/constants.py | 3 ++ tests/server/routes/test_rest_routes.py | 24 ++++++++++++++ 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/src/a2a/server/routes/rest_dispatcher.py b/src/a2a/server/routes/rest_dispatcher.py index 0791bf060..0b5e83d6b 100644 --- a/src/a2a/server/routes/rest_dispatcher.py +++ b/src/a2a/server/routes/rest_dispatcher.py @@ -172,7 +172,10 @@ async def _handler( return a2a_pb2.SendMessageResponse(message=task_or_message) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return JSONResponse( + content=MessageToDict(response), + media_type=constants.A2A_JSON_MEDIA_TYPE, + ) @rest_stream_error_handler async def on_message_send_stream( @@ -210,7 +213,10 @@ async def _handler(context: ServerCallContext) -> a2a_pb2.Task: raise TaskNotFoundError response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return JSONResponse( + content=MessageToDict(response), + media_type=constants.A2A_JSON_MEDIA_TYPE, + ) @rest_stream_error_handler async def on_subscribe_to_task( @@ -246,7 +252,10 @@ async def _handler(context: ServerCallContext) -> a2a_pb2.Task: raise TaskNotFoundError response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return JSONResponse( + content=MessageToDict(response), + media_type=constants.A2A_JSON_MEDIA_TYPE, + ) @rest_error_handler async def get_push_notification(self, request: Request) -> Response: @@ -268,7 +277,10 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return JSONResponse( + content=MessageToDict(response), + media_type=constants.A2A_JSON_MEDIA_TYPE, + ) @rest_error_handler async def delete_push_notification(self, request: Request) -> Response: @@ -286,7 +298,9 @@ async def _handler(context: ServerCallContext) -> None: ) await self._handle_non_streaming(request, _handler) - return JSONResponse(content={}) + return JSONResponse( + content={}, media_type=constants.A2A_JSON_MEDIA_TYPE + ) @rest_error_handler async def set_push_notification(self, request: Request) -> Response: @@ -305,7 +319,10 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return JSONResponse( + content=MessageToDict(response), + media_type=constants.A2A_JSON_MEDIA_TYPE, + ) @rest_error_handler async def list_push_notifications(self, request: Request) -> Response: @@ -323,7 +340,10 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return JSONResponse( + content=MessageToDict(response), + media_type=constants.A2A_JSON_MEDIA_TYPE, + ) @rest_error_handler async def list_tasks(self, request: Request) -> Response: @@ -341,7 +361,8 @@ async def _handler( return JSONResponse( content=serialize_list_tasks_response( response, params.include_artifacts - ) + ), + media_type=constants.A2A_JSON_MEDIA_TYPE, ) @rest_error_handler @@ -360,4 +381,7 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse(content=MessageToDict(response)) + return JSONResponse( + content=MessageToDict(response), + media_type=constants.A2A_JSON_MEDIA_TYPE, + ) diff --git a/src/a2a/utils/constants.py b/src/a2a/utils/constants.py index 5497d8a24..3d1fd9e61 100644 --- a/src/a2a/utils/constants.py +++ b/src/a2a/utils/constants.py @@ -11,6 +11,9 @@ MAX_LIST_TASKS_PAGE_SIZE = 100 """Maximum page size for the `tasks/list` method.""" +A2A_JSON_MEDIA_TYPE = 'application/a2a+json' +"""Media type for HTTP+JSON binding payloads (spec 11.1).""" + class TransportProtocol(str, Enum): """Transport protocol string constants.""" diff --git a/tests/server/routes/test_rest_routes.py b/tests/server/routes/test_rest_routes.py index 7d37762c4..c05e74962 100644 --- a/tests/server/routes/test_rest_routes.py +++ b/tests/server/routes/test_rest_routes.py @@ -92,3 +92,27 @@ def test_rest_list_tasks(agent_card, mock_handler): response = client.get('/tasks', headers={'A2A-Version': '1.0'}) assert response.status_code == 200 assert mock_handler.on_list_tasks.called + + +def test_send_message_response_uses_the_a2a_media_type( + agent_card, mock_handler +): + """Spec 11.1: HTTP+JSON responses SHOULD be application/a2a+json.""" + mock_handler.on_message_send.return_value = Task(id='123') + client = TestClient(Starlette(routes=create_rest_routes(mock_handler))) + + response = client.post( + '/message:send', json={}, headers={'A2A-Version': '1.0'} + ) + + assert response.headers['content-type'].startswith('application/a2a+json') + + +def test_list_tasks_response_uses_the_a2a_media_type(agent_card, mock_handler): + """The list path serializes separately, so it is asserted separately.""" + mock_handler.on_list_tasks.return_value = ListTasksResponse() + client = TestClient(Starlette(routes=create_rest_routes(mock_handler))) + + response = client.get('/tasks', headers={'A2A-Version': '1.0'}) + + assert response.headers['content-type'].startswith('application/a2a+json') From be249013bce5200bd6d621cda336435dc6b75d53 Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Mon, 28 Sep 2026 09:58:55 +0000 Subject: [PATCH 2/3] fix(rest): carry the A2A media type on a JSONResponse subclass --- src/a2a/server/routes/rest_dispatcher.py | 54 ++++++++---------------- src/a2a/utils/constants.py | 2 +- tests/server/routes/test_rest_routes.py | 2 +- 3 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/a2a/server/routes/rest_dispatcher.py b/src/a2a/server/routes/rest_dispatcher.py index 0b5e83d6b..af8a4229a 100644 --- a/src/a2a/server/routes/rest_dispatcher.py +++ b/src/a2a/server/routes/rest_dispatcher.py @@ -36,7 +36,8 @@ from sse_starlette.event import ServerSentEvent from sse_starlette.sse import EventSourceResponse from starlette.requests import Request - from starlette.responses import JSONResponse, Response + from starlette.responses import JSONResponse as A2AJSONResponse + from starlette.responses import Response _package_starlette_installed = True else: @@ -46,12 +47,17 @@ from starlette.requests import Request from starlette.responses import JSONResponse, Response + class A2AJSONResponse(JSONResponse): + """JSONResponse labelled with the A2A media type.""" + + media_type = constants.A2A_JSON_MEDIA_TYPE + _package_starlette_installed = True except ImportError: EventSourceResponse = Any ServerSentEvent = Any Request = Any - JSONResponse = Any + A2AJSONResponse = Any Response = Any _package_starlette_installed = False @@ -172,10 +178,7 @@ async def _handler( return a2a_pb2.SendMessageResponse(message=task_or_message) response = await self._handle_non_streaming(request, _handler) - return JSONResponse( - content=MessageToDict(response), - media_type=constants.A2A_JSON_MEDIA_TYPE, - ) + return A2AJSONResponse(content=MessageToDict(response)) @rest_stream_error_handler async def on_message_send_stream( @@ -213,10 +216,7 @@ async def _handler(context: ServerCallContext) -> a2a_pb2.Task: raise TaskNotFoundError response = await self._handle_non_streaming(request, _handler) - return JSONResponse( - content=MessageToDict(response), - media_type=constants.A2A_JSON_MEDIA_TYPE, - ) + return A2AJSONResponse(content=MessageToDict(response)) @rest_stream_error_handler async def on_subscribe_to_task( @@ -252,10 +252,7 @@ async def _handler(context: ServerCallContext) -> a2a_pb2.Task: raise TaskNotFoundError response = await self._handle_non_streaming(request, _handler) - return JSONResponse( - content=MessageToDict(response), - media_type=constants.A2A_JSON_MEDIA_TYPE, - ) + return A2AJSONResponse(content=MessageToDict(response)) @rest_error_handler async def get_push_notification(self, request: Request) -> Response: @@ -277,10 +274,7 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse( - content=MessageToDict(response), - media_type=constants.A2A_JSON_MEDIA_TYPE, - ) + return A2AJSONResponse(content=MessageToDict(response)) @rest_error_handler async def delete_push_notification(self, request: Request) -> Response: @@ -298,9 +292,7 @@ async def _handler(context: ServerCallContext) -> None: ) await self._handle_non_streaming(request, _handler) - return JSONResponse( - content={}, media_type=constants.A2A_JSON_MEDIA_TYPE - ) + return A2AJSONResponse(content={}) @rest_error_handler async def set_push_notification(self, request: Request) -> Response: @@ -319,10 +311,7 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse( - content=MessageToDict(response), - media_type=constants.A2A_JSON_MEDIA_TYPE, - ) + return A2AJSONResponse(content=MessageToDict(response)) @rest_error_handler async def list_push_notifications(self, request: Request) -> Response: @@ -340,10 +329,7 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse( - content=MessageToDict(response), - media_type=constants.A2A_JSON_MEDIA_TYPE, - ) + return A2AJSONResponse(content=MessageToDict(response)) @rest_error_handler async def list_tasks(self, request: Request) -> Response: @@ -358,11 +344,10 @@ async def _handler( return await self.request_handler.on_list_tasks(params, context) response = await self._handle_non_streaming(request, _handler) - return JSONResponse( + return A2AJSONResponse( content=serialize_list_tasks_response( response, params.include_artifacts - ), - media_type=constants.A2A_JSON_MEDIA_TYPE, + ) ) @rest_error_handler @@ -381,7 +366,4 @@ async def _handler( ) response = await self._handle_non_streaming(request, _handler) - return JSONResponse( - content=MessageToDict(response), - media_type=constants.A2A_JSON_MEDIA_TYPE, - ) + return A2AJSONResponse(content=MessageToDict(response)) diff --git a/src/a2a/utils/constants.py b/src/a2a/utils/constants.py index 3d1fd9e61..3fd9b1431 100644 --- a/src/a2a/utils/constants.py +++ b/src/a2a/utils/constants.py @@ -12,7 +12,7 @@ """Maximum page size for the `tasks/list` method.""" A2A_JSON_MEDIA_TYPE = 'application/a2a+json' -"""Media type for HTTP+JSON binding payloads (spec 11.1).""" +"""Media type for HTTP+JSON binding payloads.""" class TransportProtocol(str, Enum): diff --git a/tests/server/routes/test_rest_routes.py b/tests/server/routes/test_rest_routes.py index c05e74962..8a52bf22c 100644 --- a/tests/server/routes/test_rest_routes.py +++ b/tests/server/routes/test_rest_routes.py @@ -97,7 +97,7 @@ def test_rest_list_tasks(agent_card, mock_handler): def test_send_message_response_uses_the_a2a_media_type( agent_card, mock_handler ): - """Spec 11.1: HTTP+JSON responses SHOULD be application/a2a+json.""" + """HTTP+JSON responses are labelled application/a2a+json.""" mock_handler.on_message_send.return_value = Task(id='123') client = TestClient(Starlette(routes=create_rest_routes(mock_handler))) From 8641b2a299303d84161a571e5b601037af2821e3 Mon Sep 17 00:00:00 2001 From: JakubWorek Date: Mon, 28 Sep 2026 10:06:13 +0000 Subject: [PATCH 3/3] chore(spelling): allow AJSON --- .github/actions/spelling/allow.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index 8aa8c3923..3915b781b 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -15,6 +15,7 @@ AGrpc aio aiomysql AIP +AJSON alg amannn aproject