From fe7234692e5ddca0fcc11a6590da4a97d86fce9e Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar <11888634+dmandar@users.noreply.github.com> Date: Wed, 30 Jul 2025 19:29:05 +0530 Subject: [PATCH 1/6] feat: Allow agent cards (default and extended) to be dynamic --- src/a2a/server/apps/jsonrpc/fastapi_app.py | 22 +++-- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 57 +++++++---- src/a2a/server/apps/jsonrpc/starlette_app.py | 19 +++- .../server/request_handlers/grpc_handler.py | 11 ++- .../server/apps/jsonrpc/test_serialization.py | 6 +- .../request_handlers/test_grpc_handler.py | 28 ++++++ ...database_push_notification_config_store.py | 16 ++-- tests/server/test_integration.py | 96 +++++++++++++++++++ 8 files changed, 214 insertions(+), 41 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/fastapi_app.py b/src/a2a/server/apps/jsonrpc/fastapi_app.py index 6d258cc9..24f16f28 100644 --- a/src/a2a/server/apps/jsonrpc/fastapi_app.py +++ b/src/a2a/server/apps/jsonrpc/fastapi_app.py @@ -1,6 +1,6 @@ import logging -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Callable from contextlib import asynccontextmanager from typing import Any @@ -10,13 +10,13 @@ CallContextBuilder, JSONRPCApplication, ) +from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import RequestHandler from a2a.types import A2ARequest, AgentCard from a2a.utils.constants import ( AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_RPC_URL, EXTENDED_AGENT_CARD_PATH, - PREV_AGENT_CARD_WELL_KNOWN_PATH, ) @@ -37,6 +37,11 @@ def __init__( http_handler: RequestHandler, extended_agent_card: AgentCard | None = None, context_builder: CallContextBuilder | None = None, + card_modifier: Callable[[AgentCard], AgentCard] | None = None, + extended_card_modifier: Callable[ + [AgentCard, ServerCallContext], AgentCard + ] + | None = None, ) -> None: """Initializes the A2AStarletteApplication. @@ -49,12 +54,19 @@ def __init__( context_builder: The CallContextBuilder used to construct the ServerCallContext passed to the http_handler. If None, no ServerCallContext is passed. + card_modifier: An optional callback to dynamically modify the public + agent card before it is served. + extended_card_modifier: An optional callback to dynamically modify + the extended agent card before it is served. It receives the + call context. """ super().__init__( agent_card=agent_card, http_handler=http_handler, extended_agent_card=extended_agent_card, context_builder=context_builder, + card_modifier=card_modifier, + extended_card_modifier=extended_card_modifier, ) def add_routes_to_app( @@ -90,12 +102,6 @@ def add_routes_to_app( )(self._handle_requests) app.get(agent_card_url)(self._handle_get_agent_card) - # add deprecated path only if the agent_card_url uses default well-known path - if agent_card_url == AGENT_CARD_WELL_KNOWN_PATH: - app.get(PREV_AGENT_CARD_WELL_KNOWN_PATH, include_in_schema=False)( - self.handle_deprecated_agent_card_path - ) - if self.agent_card.supports_authenticated_extended_card: app.get(extended_agent_card_url)( self._handle_get_authenticated_extended_agent_card diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index b2e9ad4d..04f03143 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -4,7 +4,7 @@ import traceback from abc import ABC, abstractmethod -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Callable from typing import Any from fastapi import FastAPI @@ -127,6 +127,11 @@ def __init__( http_handler: RequestHandler, extended_agent_card: AgentCard | None = None, context_builder: CallContextBuilder | None = None, + card_modifier: Callable[[AgentCard], AgentCard] | None = None, + extended_card_modifier: Callable[ + [AgentCard, ServerCallContext], AgentCard + ] + | None = None, ) -> None: """Initializes the A2AStarletteApplication. @@ -139,15 +144,23 @@ def __init__( context_builder: The CallContextBuilder used to construct the ServerCallContext passed to the http_handler. If None, no ServerCallContext is passed. + card_modifier: An optional callback to dynamically modify the public + agent card before it is served. + extended_card_modifier: An optional callback to dynamically modify + the extended agent card before it is served. It receives the + call context. """ self.agent_card = agent_card self.extended_agent_card = extended_agent_card + self.card_modifier = card_modifier + self.extended_card_modifier = extended_card_modifier self.handler = JSONRPCHandler( agent_card=agent_card, request_handler=http_handler ) if ( self.agent_card.supports_authenticated_extended_card and self.extended_agent_card is None + and self.extended_card_modifier is None ): logger.error( 'AgentCard.supports_authenticated_extended_card is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.' @@ -428,24 +441,23 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse: Returns: A JSONResponse containing the agent card data. """ - # The public agent card is a direct serialization of the agent_card - # provided at initialization. + if request.url.path == PREV_AGENT_CARD_WELL_KNOWN_PATH: + logger.warning( + f"Deprecated agent card endpoint '{PREV_AGENT_CARD_WELL_KNOWN_PATH}' accessed. " + f"Please use '{AGENT_CARD_WELL_KNOWN_PATH}' instead. This endpoint will be removed in a future version." + ) + + card_to_serve = self.agent_card + if self.card_modifier: + card_to_serve = self.card_modifier(card_to_serve) + return JSONResponse( - self.agent_card.model_dump( + card_to_serve.model_dump( exclude_none=True, by_alias=True, ) ) - async def handle_deprecated_agent_card_path( - self, request: Request - ) -> JSONResponse: - """Handles GET requests for the deprecated agent card endpoint.""" - logger.warning( - f"Deprecated agent card endpoint '{PREV_AGENT_CARD_WELL_KNOWN_PATH}' accessed. Please use '{AGENT_CARD_WELL_KNOWN_PATH}' instead. This endpoint will be removed in a future version." - ) - return await self._handle_get_agent_card(request) - async def _handle_get_authenticated_extended_agent_card( self, request: Request ) -> JSONResponse: @@ -456,17 +468,24 @@ async def _handle_get_authenticated_extended_agent_card( status_code=404, ) - # If an explicit extended_agent_card is provided, serve that. - if self.extended_agent_card: + card_to_serve = self.extended_agent_card + + if self.extended_card_modifier: + context = self._context_builder.build(request) + # If no base extended card is provided, pass the public card to the modifier + base_card = card_to_serve if card_to_serve else self.agent_card + card_to_serve = self.extended_card_modifier(base_card, context) + + if card_to_serve: return JSONResponse( - self.extended_agent_card.model_dump( + card_to_serve.model_dump( exclude_none=True, by_alias=True, ) ) - # If supports_authenticated_extended_card is true, but no specific - # extended_agent_card was provided during server initialization, - # return a 404 + # If supports_authenticated_extended_card is true, but no + # extended_agent_card was provided, and no modifier produced a card, + # return a 404. return JSONResponse( { 'error': 'Authenticated extended agent card is supported but not configured on the server.' diff --git a/src/a2a/server/apps/jsonrpc/starlette_app.py b/src/a2a/server/apps/jsonrpc/starlette_app.py index b1a5d6e6..f715b8c7 100644 --- a/src/a2a/server/apps/jsonrpc/starlette_app.py +++ b/src/a2a/server/apps/jsonrpc/starlette_app.py @@ -1,5 +1,6 @@ import logging +from collections.abc import Callable from typing import Any from starlette.applications import Starlette @@ -9,6 +10,7 @@ CallContextBuilder, JSONRPCApplication, ) +from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import RequestHandler from a2a.types import AgentCard from a2a.utils.constants import ( @@ -36,6 +38,11 @@ def __init__( http_handler: RequestHandler, extended_agent_card: AgentCard | None = None, context_builder: CallContextBuilder | None = None, + card_modifier: Callable[[AgentCard], AgentCard] | None = None, + extended_card_modifier: Callable[ + [AgentCard, ServerCallContext], AgentCard + ] + | None = None, ) -> None: """Initializes the A2AStarletteApplication. @@ -48,12 +55,19 @@ def __init__( context_builder: The CallContextBuilder used to construct the ServerCallContext passed to the http_handler. If None, no ServerCallContext is passed. + card_modifier: An optional callback to dynamically modify the public + agent card before it is served. + extended_card_modifier: An optional callback to dynamically modify + the extended agent card before it is served. It receives the + call context. """ super().__init__( agent_card=agent_card, http_handler=http_handler, extended_agent_card=extended_agent_card, context_builder=context_builder, + card_modifier=card_modifier, + extended_card_modifier=extended_card_modifier, ) def routes( @@ -87,14 +101,13 @@ def routes( ), ] - # add deprecated path only if the agent_card_url uses default well-known path if agent_card_url == AGENT_CARD_WELL_KNOWN_PATH: app_routes.append( Route( PREV_AGENT_CARD_WELL_KNOWN_PATH, - self.handle_deprecated_agent_card_path, + self._handle_get_agent_card, methods=['GET'], - name='agent_card_path_deprecated', + name='deprecated_agent_card', ) ) diff --git a/src/a2a/server/request_handlers/grpc_handler.py b/src/a2a/server/request_handlers/grpc_handler.py index 2761ed33..27224c8e 100644 --- a/src/a2a/server/request_handlers/grpc_handler.py +++ b/src/a2a/server/request_handlers/grpc_handler.py @@ -18,6 +18,8 @@ "'pip install a2a-sdk[grpc]'" ) from e +from collections.abc import Callable + import a2a.grpc.a2a_pb2_grpc as a2a_grpc from a2a import types @@ -87,6 +89,7 @@ def __init__( agent_card: AgentCard, request_handler: RequestHandler, context_builder: CallContextBuilder | None = None, + card_modifier: Callable[[AgentCard], AgentCard] | None = None, ): """Initializes the GrpcHandler. @@ -96,10 +99,13 @@ def __init__( delegate requests to. context_builder: The CallContextBuilder object. If none the DefaultCallContextBuilder is used. + card_modifier: An optional callback to dynamically modify the public + agent card before it is served. """ self.agent_card = agent_card self.request_handler = request_handler self.context_builder = context_builder or DefaultCallContextBuilder() + self.card_modifier = card_modifier async def SendMessage( self, @@ -331,7 +337,10 @@ async def GetAgentCard( context: grpc.aio.ServicerContext, ) -> a2a_pb2.AgentCard: """Get the agent card for the agent served.""" - return proto_utils.ToProto.agent_card(self.agent_card) + card_to_serve = self.agent_card + if self.card_modifier: + card_to_serve = self.card_modifier(card_to_serve) + return proto_utils.ToProto.agent_card(card_to_serve) async def abort_context( self, error: ServerError, context: grpc.aio.ServicerContext diff --git a/tests/server/apps/jsonrpc/test_serialization.py b/tests/server/apps/jsonrpc/test_serialization.py index 0b88730a..1bb4ae28 100644 --- a/tests/server/apps/jsonrpc/test_serialization.py +++ b/tests/server/apps/jsonrpc/test_serialization.py @@ -58,7 +58,8 @@ def test_starlette_agent_card_with_api_key_scheme_alias( app_instance = A2AStarletteApplication(agent_card_with_api_key, handler) client = TestClient(app_instance.build()) - response = client.get('/.well-known/agent.json') + response = client.get('/.well-known/agent-card.json') + print(response.status_code, response.content) assert response.status_code == 200 response_data = response.json() @@ -90,7 +91,8 @@ def test_fastapi_agent_card_with_api_key_scheme_alias( app_instance = A2AFastAPIApplication(agent_card_with_api_key, handler) client = TestClient(app_instance.build()) - response = client.get('/.well-known/agent.json') + response = client.get('/.well-known/agent-card.json') + print(response.status_code, response.content) assert response.status_code == 200 response_data = response.json() diff --git a/tests/server/request_handlers/test_grpc_handler.py b/tests/server/request_handlers/test_grpc_handler.py index eb0a3459..b877d82c 100644 --- a/tests/server/request_handlers/test_grpc_handler.py +++ b/tests/server/request_handlers/test_grpc_handler.py @@ -201,6 +201,34 @@ async def test_get_agent_card( assert response.version == sample_agent_card.version +@pytest.mark.asyncio +async def test_get_agent_card_with_modifier( + mock_request_handler: AsyncMock, + sample_agent_card: types.AgentCard, + mock_grpc_context: AsyncMock, +): + """Test GetAgentCard call with a card_modifier.""" + + def modifier(card: types.AgentCard) -> types.AgentCard: + modified_card = card.model_copy() + modified_card.name = 'Modified gRPC Agent' + return modified_card + + grpc_handler_modified = GrpcHandler( + agent_card=sample_agent_card, + request_handler=mock_request_handler, + card_modifier=modifier, + ) + + request_proto = a2a_pb2.GetAgentCardRequest() + response = await grpc_handler_modified.GetAgentCard( + request_proto, mock_grpc_context + ) + + assert response.name == 'Modified gRPC Agent' + assert response.version == sample_agent_card.version + + @pytest.mark.asyncio @pytest.mark.parametrize( 'server_error, grpc_status_code, error_message_part', diff --git a/tests/server/tasks/test_database_push_notification_config_store.py b/tests/server/tasks/test_database_push_notification_config_store.py index 8ec7f1d4..8e3b20dc 100644 --- a/tests/server/tasks/test_database_push_notification_config_store.py +++ b/tests/server/tasks/test_database_push_notification_config_store.py @@ -3,6 +3,14 @@ from collections.abc import AsyncGenerator import pytest + +# Skip entire test module if SQLAlchemy is not installed +pytest.importorskip('sqlalchemy', reason='Database tests require SQLAlchemy') +pytest.importorskip( + 'cryptography', + reason='Database tests require Cryptography. Install extra encryption', +) + import pytest_asyncio from _pytest.mark.structures import ParameterSet @@ -12,14 +20,6 @@ create_async_engine, ) - -# Skip entire test module if SQLAlchemy is not installed -pytest.importorskip('sqlalchemy', reason='Database tests require SQLAlchemy') -pytest.importorskip( - 'cryptography', - reason='Database tests require Cryptography. Install extra encryption', -) - # Now safe to import SQLAlchemy-dependent modules from cryptography.fernet import Fernet from sqlalchemy.inspection import inspect diff --git a/tests/server/test_integration.py b/tests/server/test_integration.py index 84280821..4a79a1d1 100644 --- a/tests/server/test_integration.py +++ b/tests/server/test_integration.py @@ -22,6 +22,7 @@ A2AFastAPIApplication, A2AStarletteApplication, ) +from a2a.server.context import ServerCallContext from a2a.types import ( AgentCapabilities, AgentCard, @@ -47,6 +48,7 @@ from a2a.utils import ( AGENT_CARD_WELL_KNOWN_PATH, PREV_AGENT_CARD_WELL_KNOWN_PATH, + EXTENDED_AGENT_CARD_PATH, ) from a2a.utils.errors import MethodNotImplementedError @@ -845,6 +847,100 @@ def test_invalid_request_structure(client: TestClient): assert data['error']['code'] == InvalidRequestError().code +# === DYNAMIC CARD MODIFIER TESTS === + + +def test_dynamic_agent_card_modifier( + agent_card: AgentCard, handler: mock.AsyncMock +): + """Test that the card_modifier dynamically alters the public agent card.""" + + def modifier(card: AgentCard) -> AgentCard: + modified_card = card.model_copy(deep=True) + modified_card.name = 'Dynamically Modified Agent' + return modified_card + + app_instance = A2AStarletteApplication( + agent_card, handler, card_modifier=modifier + ) + client = TestClient(app_instance.build()) + + response = client.get(AGENT_CARD_WELL_KNOWN_PATH) + assert response.status_code == 200 + data = response.json() + assert data['name'] == 'Dynamically Modified Agent' + assert ( + data['version'] == agent_card.version + ) # Ensure other fields are intact + + +def test_dynamic_extended_agent_card_modifier( + agent_card: AgentCard, + extended_agent_card_fixture: AgentCard, + handler: mock.AsyncMock, +): + """Test that the extended_card_modifier dynamically alters the extended agent card.""" + agent_card.supports_authenticated_extended_card = True + + def modifier(card: AgentCard, context: ServerCallContext) -> AgentCard: + modified_card = card.model_copy(deep=True) + modified_card.description = 'Dynamically Modified Extended Description' + return modified_card + + # Test with a base extended card + app_instance = A2AStarletteApplication( + agent_card, + handler, + extended_agent_card=extended_agent_card_fixture, + extended_card_modifier=modifier, + ) + client = TestClient(app_instance.build()) + + response = client.get(EXTENDED_AGENT_CARD_PATH) + assert response.status_code == 200 + data = response.json() + assert data['name'] == extended_agent_card_fixture.name + assert data['description'] == 'Dynamically Modified Extended Description' + + # Test without a base extended card (modifier should receive public card) + app_instance_no_base = A2AStarletteApplication( + agent_card, + handler, + extended_agent_card=None, + extended_card_modifier=modifier, + ) + client_no_base = TestClient(app_instance_no_base.build()) + response_no_base = client_no_base.get(EXTENDED_AGENT_CARD_PATH) + assert response_no_base.status_code == 200 + data_no_base = response_no_base.json() + assert data_no_base['name'] == agent_card.name + assert ( + data_no_base['description'] + == 'Dynamically Modified Extended Description' + ) + + +def test_fastapi_dynamic_agent_card_modifier( + agent_card: AgentCard, handler: mock.AsyncMock +): + """Test that the card_modifier dynamically alters the public agent card for FastAPI.""" + + def modifier(card: AgentCard) -> AgentCard: + modified_card = card.model_copy(deep=True) + modified_card.name = 'Dynamically Modified Agent' + return modified_card + + app_instance = A2AFastAPIApplication( + agent_card, handler, card_modifier=modifier + ) + client = TestClient(app_instance.build()) + + response = client.get(AGENT_CARD_WELL_KNOWN_PATH) + assert response.status_code == 200 + data = response.json() + assert data['name'] == 'Dynamically Modified Agent' + + def test_method_not_implemented(client: TestClient, handler: mock.AsyncMock): """Test handling MethodNotImplementedError.""" handler.on_get_task.side_effect = MethodNotImplementedError() From 49eb498555fe57bca920f27dbdf3df8f1c042c62 Mon Sep 17 00:00:00 2001 From: Holt Skinner Date: Wed, 30 Jul 2025 16:35:52 +0100 Subject: [PATCH 2/6] Formatting --- .../tasks/test_database_push_notification_config_store.py | 7 ++++--- tests/server/test_integration.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/server/tasks/test_database_push_notification_config_store.py b/tests/server/tasks/test_database_push_notification_config_store.py index 8e3b20dc..0c3bd468 100644 --- a/tests/server/tasks/test_database_push_notification_config_store.py +++ b/tests/server/tasks/test_database_push_notification_config_store.py @@ -4,6 +4,7 @@ import pytest + # Skip entire test module if SQLAlchemy is not installed pytest.importorskip('sqlalchemy', reason='Database tests require SQLAlchemy') pytest.importorskip( @@ -14,14 +15,14 @@ import pytest_asyncio from _pytest.mark.structures import ParameterSet + +# Now safe to import SQLAlchemy-dependent modules +from cryptography.fernet import Fernet from sqlalchemy import select from sqlalchemy.ext.asyncio import ( async_sessionmaker, create_async_engine, ) - -# Now safe to import SQLAlchemy-dependent modules -from cryptography.fernet import Fernet from sqlalchemy.inspection import inspect from a2a.server.models import ( diff --git a/tests/server/test_integration.py b/tests/server/test_integration.py index 484490b8..edb0b404 100644 --- a/tests/server/test_integration.py +++ b/tests/server/test_integration.py @@ -47,8 +47,8 @@ ) from a2a.utils import ( AGENT_CARD_WELL_KNOWN_PATH, - PREV_AGENT_CARD_WELL_KNOWN_PATH, EXTENDED_AGENT_CARD_PATH, + PREV_AGENT_CARD_WELL_KNOWN_PATH, ) from a2a.utils.errors import MethodNotImplementedError From 905637fe980e51d6eb7f8b401be2d95a181e8a24 Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar <11888634+dmandar@users.noreply.github.com> Date: Thu, 31 Jul 2025 01:37:30 +0530 Subject: [PATCH 3/6] Re-add accidently removed method and other minor fixes --- src/a2a/server/apps/jsonrpc/fastapi_app.py | 7 +++++++ tests/server/request_handlers/test_grpc_handler.py | 2 +- tests/server/test_integration.py | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/fastapi_app.py b/src/a2a/server/apps/jsonrpc/fastapi_app.py index 24f16f28..87657f1c 100644 --- a/src/a2a/server/apps/jsonrpc/fastapi_app.py +++ b/src/a2a/server/apps/jsonrpc/fastapi_app.py @@ -17,6 +17,7 @@ AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_RPC_URL, EXTENDED_AGENT_CARD_PATH, + PREV_AGENT_CARD_WELL_KNOWN_PATH, ) @@ -102,6 +103,12 @@ def add_routes_to_app( )(self._handle_requests) app.get(agent_card_url)(self._handle_get_agent_card) + if agent_card_url == AGENT_CARD_WELL_KNOWN_PATH: + # For backward compatibility, serve the agent card at the deprecated path as well. + app.get(PREV_AGENT_CARD_WELL_KNOWN_PATH)( + self._handle_get_agent_card + ) + if self.agent_card.supports_authenticated_extended_card: app.get(extended_agent_card_url)( self._handle_get_authenticated_extended_agent_card diff --git a/tests/server/request_handlers/test_grpc_handler.py b/tests/server/request_handlers/test_grpc_handler.py index b877d82c..1d3b7e95 100644 --- a/tests/server/request_handlers/test_grpc_handler.py +++ b/tests/server/request_handlers/test_grpc_handler.py @@ -210,7 +210,7 @@ async def test_get_agent_card_with_modifier( """Test GetAgentCard call with a card_modifier.""" def modifier(card: types.AgentCard) -> types.AgentCard: - modified_card = card.model_copy() + modified_card = card.model_copy(deep=True) modified_card.name = 'Modified gRPC Agent' return modified_card diff --git a/tests/server/test_integration.py b/tests/server/test_integration.py index edb0b404..f135349b 100644 --- a/tests/server/test_integration.py +++ b/tests/server/test_integration.py @@ -228,7 +228,7 @@ def test_authenticated_extended_agent_card_endpoint_supported_with_specific_exte agent_card.supports_authenticated_extended_card = ( True # Main card must support it ) - print(agent_card) + app_instance = A2AStarletteApplication( agent_card, handler, extended_agent_card=extended_agent_card_fixture ) From ad3681b4032eab6553a0c377e4144aee890b9b5c Mon Sep 17 00:00:00 2001 From: Mandar Deolalikar <11888634+dmandar@users.noreply.github.com> Date: Thu, 31 Jul 2025 21:41:44 +0530 Subject: [PATCH 4/6] Add support for extended card modifier in the JSONRPChandler --- .../request_handlers/jsonrpc_handler.py | 25 +++++++-- .../request_handlers/test_jsonrpc_handler.py | 53 ++++++++++++++++++- 2 files changed, 74 insertions(+), 4 deletions(-) diff --git a/src/a2a/server/request_handlers/jsonrpc_handler.py b/src/a2a/server/request_handlers/jsonrpc_handler.py index a0657859..97cff496 100644 --- a/src/a2a/server/request_handlers/jsonrpc_handler.py +++ b/src/a2a/server/request_handlers/jsonrpc_handler.py @@ -1,6 +1,6 @@ import logging -from collections.abc import AsyncIterable +from collections.abc import AsyncIterable, Callable from a2a.server.context import ServerCallContext from a2a.server.request_handlers.request_handler import RequestHandler @@ -62,6 +62,10 @@ def __init__( agent_card: AgentCard, request_handler: RequestHandler, extended_agent_card: AgentCard | None = None, + extended_card_modifier: Callable[ + [AgentCard, ServerCallContext], AgentCard + ] + | None = None, ): """Initializes the JSONRPCHandler. @@ -69,10 +73,14 @@ def __init__( agent_card: The AgentCard describing the agent's capabilities. request_handler: The underlying `RequestHandler` instance to delegate requests to. extended_agent_card: An optional, distinct Extended AgentCard to be served + extended_card_modifier: An optional callback to dynamically modify + the extended agent card before it is served. It receives the + call context. """ self.agent_card = agent_card self.request_handler = request_handler self.extended_agent_card = extended_agent_card + self.extended_card_modifier = extended_card_modifier async def on_message_send( self, @@ -417,7 +425,10 @@ async def get_authenticated_extended_card( Returns: A `GetAuthenticatedExtendedCardResponse` object containing the config or a JSON-RPC error. """ - if self.extended_agent_card is None: + if ( + self.extended_agent_card is None + and self.extended_card_modifier is None + ): return GetAuthenticatedExtendedCardResponse( root=JSONRPCErrorResponse( id=request.id, @@ -425,8 +436,16 @@ async def get_authenticated_extended_card( ) ) + base_card = self.extended_agent_card + if base_card is None: + base_card = self.agent_card + + card_to_serve = base_card + if self.extended_card_modifier and context: + card_to_serve = self.extended_card_modifier(base_card, context) + return GetAuthenticatedExtendedCardResponse( root=GetAuthenticatedExtendedCardSuccessResponse( - id=request.id, result=self.extended_agent_card + id=request.id, result=card_to_serve ) ) diff --git a/tests/server/request_handlers/test_jsonrpc_handler.py b/tests/server/request_handlers/test_jsonrpc_handler.py index ef43b05f..b460b2f3 100644 --- a/tests/server/request_handlers/test_jsonrpc_handler.py +++ b/tests/server/request_handlers/test_jsonrpc_handler.py @@ -1212,6 +1212,7 @@ async def test_get_authenticated_extended_card_success(self) -> None: self.mock_agent_card, mock_request_handler, extended_agent_card=mock_extended_card, + extended_card_modifier=None, ) request = GetAuthenticatedExtendedCardRequest(id='ext-card-req-1') call_context = ServerCallContext(state={'foo': 'bar'}) @@ -1233,7 +1234,10 @@ async def test_get_authenticated_extended_card_not_configured(self) -> None: # Arrange mock_request_handler = AsyncMock(spec=DefaultRequestHandler) handler = JSONRPCHandler( - self.mock_agent_card, mock_request_handler, extended_agent_card=None + self.mock_agent_card, + mock_request_handler, + extended_agent_card=None, + extended_card_modifier=None, ) request = GetAuthenticatedExtendedCardRequest(id='ext-card-req-2') call_context = ServerCallContext(state={'foo': 'bar'}) @@ -1249,3 +1253,50 @@ async def test_get_authenticated_extended_card_not_configured(self) -> None: self.assertIsInstance( response.root.error, AuthenticatedExtendedCardNotConfiguredError ) + + async def test_get_authenticated_extended_card_with_modifier(self) -> None: + """Test successful retrieval of a dynamically modified extended agent card.""" + # Arrange + mock_request_handler = AsyncMock(spec=DefaultRequestHandler) + mock_base_card = AgentCard( + name='Base Card', + description='Base details', + url='http://agent.example.com/api', + version='1.0', + capabilities=AgentCapabilities(), + default_input_modes=['text/plain'], + default_output_modes=['application/json'], + skills=[], + ) + + def modifier(card: AgentCard, context: ServerCallContext) -> AgentCard: + modified_card = card.model_copy(deep=True) + modified_card.name = 'Modified Card' + modified_card.description = ( + f'Modified for context: {context.state.get("foo")}' + ) + return modified_card + + handler = JSONRPCHandler( + self.mock_agent_card, + mock_request_handler, + extended_agent_card=mock_base_card, + extended_card_modifier=modifier, + ) + request = GetAuthenticatedExtendedCardRequest(id='ext-card-req-mod') + call_context = ServerCallContext(state={'foo': 'bar'}) + + # Act + response: GetAuthenticatedExtendedCardResponse = ( + await handler.get_authenticated_extended_card(request, call_context) + ) + + # Assert + self.assertIsInstance( + response.root, GetAuthenticatedExtendedCardSuccessResponse + ) + self.assertEqual(response.root.id, 'ext-card-req-mod') + modified_card = response.root.result + self.assertEqual(modified_card.name, 'Modified Card') + self.assertEqual(modified_card.description, 'Modified for context: bar') + self.assertEqual(modified_card.version, '1.0') From 4fc5be1bcb5d319ed1d854955f4402459c3c21ed Mon Sep 17 00:00:00 2001 From: Holt Skinner Date: Thu, 31 Jul 2025 18:11:22 +0100 Subject: [PATCH 5/6] Lint/JSCPD Cleanup --- src/a2a/server/apps/jsonrpc/fastapi_app.py | 45 +------------------ src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 2 +- src/a2a/server/apps/jsonrpc/starlette_app.py | 43 ------------------ .../request_handlers/test_grpc_handler.py | 2 +- 4 files changed, 4 insertions(+), 88 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/fastapi_app.py b/src/a2a/server/apps/jsonrpc/fastapi_app.py index 87657f1c..da152983 100644 --- a/src/a2a/server/apps/jsonrpc/fastapi_app.py +++ b/src/a2a/server/apps/jsonrpc/fastapi_app.py @@ -1,18 +1,15 @@ import logging -from collections.abc import AsyncIterator, Callable +from collections.abc import AsyncIterator from contextlib import asynccontextmanager from typing import Any from fastapi import FastAPI from a2a.server.apps.jsonrpc.jsonrpc_app import ( - CallContextBuilder, JSONRPCApplication, ) -from a2a.server.context import ServerCallContext -from a2a.server.request_handlers.jsonrpc_handler import RequestHandler -from a2a.types import A2ARequest, AgentCard +from a2a.types import A2ARequest from a2a.utils.constants import ( AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_RPC_URL, @@ -32,44 +29,6 @@ class A2AFastAPIApplication(JSONRPCApplication): (SSE). """ - def __init__( - self, - agent_card: AgentCard, - http_handler: RequestHandler, - extended_agent_card: AgentCard | None = None, - context_builder: CallContextBuilder | None = None, - card_modifier: Callable[[AgentCard], AgentCard] | None = None, - extended_card_modifier: Callable[ - [AgentCard, ServerCallContext], AgentCard - ] - | None = None, - ) -> None: - """Initializes the A2AStarletteApplication. - - Args: - agent_card: The AgentCard describing the agent's capabilities. - http_handler: The handler instance responsible for processing A2A - requests via http. - extended_agent_card: An optional, distinct AgentCard to be served - at the authenticated extended card endpoint. - context_builder: The CallContextBuilder used to construct the - ServerCallContext passed to the http_handler. If None, no - ServerCallContext is passed. - card_modifier: An optional callback to dynamically modify the public - agent card before it is served. - extended_card_modifier: An optional callback to dynamically modify - the extended agent card before it is served. It receives the - call context. - """ - super().__init__( - agent_card=agent_card, - http_handler=http_handler, - extended_agent_card=extended_agent_card, - context_builder=context_builder, - card_modifier=card_modifier, - extended_card_modifier=extended_card_modifier, - ) - def add_routes_to_app( self, app: FastAPI, diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index 1857ce5a..3a4fe0d4 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -123,7 +123,7 @@ class JSONRPCApplication(ABC): (SSE). """ - def __init__( + def __init__( # noqa: PLR0913 self, agent_card: AgentCard, http_handler: RequestHandler, diff --git a/src/a2a/server/apps/jsonrpc/starlette_app.py b/src/a2a/server/apps/jsonrpc/starlette_app.py index 659c8fc1..71f769ec 100644 --- a/src/a2a/server/apps/jsonrpc/starlette_app.py +++ b/src/a2a/server/apps/jsonrpc/starlette_app.py @@ -1,18 +1,13 @@ import logging -from collections.abc import Callable from typing import Any from starlette.applications import Starlette from starlette.routing import Route from a2a.server.apps.jsonrpc.jsonrpc_app import ( - CallContextBuilder, JSONRPCApplication, ) -from a2a.server.context import ServerCallContext -from a2a.server.request_handlers.jsonrpc_handler import RequestHandler -from a2a.types import AgentCard from a2a.utils.constants import ( AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_RPC_URL, @@ -32,44 +27,6 @@ class A2AStarletteApplication(JSONRPCApplication): (SSE). """ - def __init__( - self, - agent_card: AgentCard, - http_handler: RequestHandler, - extended_agent_card: AgentCard | None = None, - context_builder: CallContextBuilder | None = None, - card_modifier: Callable[[AgentCard], AgentCard] | None = None, - extended_card_modifier: Callable[ - [AgentCard, ServerCallContext], AgentCard - ] - | None = None, - ) -> None: - """Initializes the A2AStarletteApplication. - - Args: - agent_card: The AgentCard describing the agent's capabilities. - http_handler: The handler instance responsible for processing A2A - requests via http. - extended_agent_card: An optional, distinct AgentCard to be served - at the authenticated extended card endpoint. - context_builder: The CallContextBuilder used to construct the - ServerCallContext passed to the http_handler. If None, no - ServerCallContext is passed. - card_modifier: An optional callback to dynamically modify the public - agent card before it is served. - extended_card_modifier: An optional callback to dynamically modify - the extended agent card before it is served. It receives the - call context. - """ - super().__init__( - agent_card=agent_card, - http_handler=http_handler, - extended_agent_card=extended_agent_card, - context_builder=context_builder, - card_modifier=card_modifier, - extended_card_modifier=extended_card_modifier, - ) - def routes( self, agent_card_url: str = AGENT_CARD_WELL_KNOWN_PATH, diff --git a/tests/server/request_handlers/test_grpc_handler.py b/tests/server/request_handlers/test_grpc_handler.py index a27e168a..05af6cda 100644 --- a/tests/server/request_handlers/test_grpc_handler.py +++ b/tests/server/request_handlers/test_grpc_handler.py @@ -295,7 +295,7 @@ def modifier(card: types.AgentCard) -> types.AgentCard: ), ], ) -async def test_abort_context_error_mapping( +async def test_abort_context_error_mapping( # noqa: PLR0913 grpc_handler: GrpcHandler, mock_request_handler: AsyncMock, mock_grpc_context: AsyncMock, From eae95c61df848d2ec2d0970f9799b641390b8edf Mon Sep 17 00:00:00 2001 From: Krishna Thota Date: Thu, 31 Jul 2025 10:30:08 -0700 Subject: [PATCH 6/6] fix: pass modifier to handler --- src/a2a/server/apps/jsonrpc/fastapi_app.py | 1 + src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 1 + src/a2a/server/apps/jsonrpc/starlette_app.py | 2 ++ 3 files changed, 4 insertions(+) diff --git a/src/a2a/server/apps/jsonrpc/fastapi_app.py b/src/a2a/server/apps/jsonrpc/fastapi_app.py index da152983..c18cde3d 100644 --- a/src/a2a/server/apps/jsonrpc/fastapi_app.py +++ b/src/a2a/server/apps/jsonrpc/fastapi_app.py @@ -64,6 +64,7 @@ def add_routes_to_app( if agent_card_url == AGENT_CARD_WELL_KNOWN_PATH: # For backward compatibility, serve the agent card at the deprecated path as well. + # TODO: remove in a future release app.get(PREV_AGENT_CARD_WELL_KNOWN_PATH)( self._handle_get_agent_card ) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index 3a4fe0d4..95aa8079 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -160,6 +160,7 @@ def __init__( # noqa: PLR0913 agent_card=agent_card, request_handler=http_handler, extended_agent_card=extended_agent_card, + extended_card_modifier=extended_card_modifier, ) if ( self.agent_card.supports_authenticated_extended_card diff --git a/src/a2a/server/apps/jsonrpc/starlette_app.py b/src/a2a/server/apps/jsonrpc/starlette_app.py index 71f769ec..0f7de3df 100644 --- a/src/a2a/server/apps/jsonrpc/starlette_app.py +++ b/src/a2a/server/apps/jsonrpc/starlette_app.py @@ -59,6 +59,8 @@ def routes( ] if agent_card_url == AGENT_CARD_WELL_KNOWN_PATH: + # For backward compatibility, serve the agent card at the deprecated path as well. + # TODO: remove in a future release app_routes.append( Route( PREV_AGENT_CARD_WELL_KNOWN_PATH,