Skip to content

Commit fe72346

Browse files
committed
feat: Allow agent cards (default and extended) to be dynamic
1 parent 2444034 commit fe72346

File tree

8 files changed

+214
-41
lines changed

8 files changed

+214
-41
lines changed

src/a2a/server/apps/jsonrpc/fastapi_app.py

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22

3-
from collections.abc import AsyncIterator
3+
from collections.abc import AsyncIterator, Callable
44
from contextlib import asynccontextmanager
55
from typing import Any
66

@@ -10,13 +10,13 @@
1010
CallContextBuilder,
1111
JSONRPCApplication,
1212
)
13+
from a2a.server.context import ServerCallContext
1314
from a2a.server.request_handlers.jsonrpc_handler import RequestHandler
1415
from a2a.types import A2ARequest, AgentCard
1516
from a2a.utils.constants import (
1617
AGENT_CARD_WELL_KNOWN_PATH,
1718
DEFAULT_RPC_URL,
1819
EXTENDED_AGENT_CARD_PATH,
19-
PREV_AGENT_CARD_WELL_KNOWN_PATH,
2020
)
2121

2222

@@ -37,6 +37,11 @@ def __init__(
3737
http_handler: RequestHandler,
3838
extended_agent_card: AgentCard | None = None,
3939
context_builder: CallContextBuilder | None = None,
40+
card_modifier: Callable[[AgentCard], AgentCard] | None = None,
41+
extended_card_modifier: Callable[
42+
[AgentCard, ServerCallContext], AgentCard
43+
]
44+
| None = None,
4045
) -> None:
4146
"""Initializes the A2AStarletteApplication.
4247
@@ -49,12 +54,19 @@ def __init__(
4954
context_builder: The CallContextBuilder used to construct the
5055
ServerCallContext passed to the http_handler. If None, no
5156
ServerCallContext is passed.
57+
card_modifier: An optional callback to dynamically modify the public
58+
agent card before it is served.
59+
extended_card_modifier: An optional callback to dynamically modify
60+
the extended agent card before it is served. It receives the
61+
call context.
5262
"""
5363
super().__init__(
5464
agent_card=agent_card,
5565
http_handler=http_handler,
5666
extended_agent_card=extended_agent_card,
5767
context_builder=context_builder,
68+
card_modifier=card_modifier,
69+
extended_card_modifier=extended_card_modifier,
5870
)
5971

6072
def add_routes_to_app(
@@ -90,12 +102,6 @@ def add_routes_to_app(
90102
)(self._handle_requests)
91103
app.get(agent_card_url)(self._handle_get_agent_card)
92104

93-
# add deprecated path only if the agent_card_url uses default well-known path
94-
if agent_card_url == AGENT_CARD_WELL_KNOWN_PATH:
95-
app.get(PREV_AGENT_CARD_WELL_KNOWN_PATH, include_in_schema=False)(
96-
self.handle_deprecated_agent_card_path
97-
)
98-
99105
if self.agent_card.supports_authenticated_extended_card:
100106
app.get(extended_agent_card_url)(
101107
self._handle_get_authenticated_extended_agent_card

src/a2a/server/apps/jsonrpc/jsonrpc_app.py

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import traceback
55

66
from abc import ABC, abstractmethod
7-
from collections.abc import AsyncGenerator
7+
from collections.abc import AsyncGenerator, Callable
88
from typing import Any
99

1010
from fastapi import FastAPI
@@ -127,6 +127,11 @@ def __init__(
127127
http_handler: RequestHandler,
128128
extended_agent_card: AgentCard | None = None,
129129
context_builder: CallContextBuilder | None = None,
130+
card_modifier: Callable[[AgentCard], AgentCard] | None = None,
131+
extended_card_modifier: Callable[
132+
[AgentCard, ServerCallContext], AgentCard
133+
]
134+
| None = None,
130135
) -> None:
131136
"""Initializes the A2AStarletteApplication.
132137
@@ -139,15 +144,23 @@ def __init__(
139144
context_builder: The CallContextBuilder used to construct the
140145
ServerCallContext passed to the http_handler. If None, no
141146
ServerCallContext is passed.
147+
card_modifier: An optional callback to dynamically modify the public
148+
agent card before it is served.
149+
extended_card_modifier: An optional callback to dynamically modify
150+
the extended agent card before it is served. It receives the
151+
call context.
142152
"""
143153
self.agent_card = agent_card
144154
self.extended_agent_card = extended_agent_card
155+
self.card_modifier = card_modifier
156+
self.extended_card_modifier = extended_card_modifier
145157
self.handler = JSONRPCHandler(
146158
agent_card=agent_card, request_handler=http_handler
147159
)
148160
if (
149161
self.agent_card.supports_authenticated_extended_card
150162
and self.extended_agent_card is None
163+
and self.extended_card_modifier is None
151164
):
152165
logger.error(
153166
'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:
428441
Returns:
429442
A JSONResponse containing the agent card data.
430443
"""
431-
# The public agent card is a direct serialization of the agent_card
432-
# provided at initialization.
444+
if request.url.path == PREV_AGENT_CARD_WELL_KNOWN_PATH:
445+
logger.warning(
446+
f"Deprecated agent card endpoint '{PREV_AGENT_CARD_WELL_KNOWN_PATH}' accessed. "
447+
f"Please use '{AGENT_CARD_WELL_KNOWN_PATH}' instead. This endpoint will be removed in a future version."
448+
)
449+
450+
card_to_serve = self.agent_card
451+
if self.card_modifier:
452+
card_to_serve = self.card_modifier(card_to_serve)
453+
433454
return JSONResponse(
434-
self.agent_card.model_dump(
455+
card_to_serve.model_dump(
435456
exclude_none=True,
436457
by_alias=True,
437458
)
438459
)
439460

440-
async def handle_deprecated_agent_card_path(
441-
self, request: Request
442-
) -> JSONResponse:
443-
"""Handles GET requests for the deprecated agent card endpoint."""
444-
logger.warning(
445-
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."
446-
)
447-
return await self._handle_get_agent_card(request)
448-
449461
async def _handle_get_authenticated_extended_agent_card(
450462
self, request: Request
451463
) -> JSONResponse:
@@ -456,17 +468,24 @@ async def _handle_get_authenticated_extended_agent_card(
456468
status_code=404,
457469
)
458470

459-
# If an explicit extended_agent_card is provided, serve that.
460-
if self.extended_agent_card:
471+
card_to_serve = self.extended_agent_card
472+
473+
if self.extended_card_modifier:
474+
context = self._context_builder.build(request)
475+
# If no base extended card is provided, pass the public card to the modifier
476+
base_card = card_to_serve if card_to_serve else self.agent_card
477+
card_to_serve = self.extended_card_modifier(base_card, context)
478+
479+
if card_to_serve:
461480
return JSONResponse(
462-
self.extended_agent_card.model_dump(
481+
card_to_serve.model_dump(
463482
exclude_none=True,
464483
by_alias=True,
465484
)
466485
)
467-
# If supports_authenticated_extended_card is true, but no specific
468-
# extended_agent_card was provided during server initialization,
469-
# return a 404
486+
# If supports_authenticated_extended_card is true, but no
487+
# extended_agent_card was provided, and no modifier produced a card,
488+
# return a 404.
470489
return JSONResponse(
471490
{
472491
'error': 'Authenticated extended agent card is supported but not configured on the server.'

src/a2a/server/apps/jsonrpc/starlette_app.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22

3+
from collections.abc import Callable
34
from typing import Any
45

56
from starlette.applications import Starlette
@@ -9,6 +10,7 @@
910
CallContextBuilder,
1011
JSONRPCApplication,
1112
)
13+
from a2a.server.context import ServerCallContext
1214
from a2a.server.request_handlers.jsonrpc_handler import RequestHandler
1315
from a2a.types import AgentCard
1416
from a2a.utils.constants import (
@@ -36,6 +38,11 @@ def __init__(
3638
http_handler: RequestHandler,
3739
extended_agent_card: AgentCard | None = None,
3840
context_builder: CallContextBuilder | None = None,
41+
card_modifier: Callable[[AgentCard], AgentCard] | None = None,
42+
extended_card_modifier: Callable[
43+
[AgentCard, ServerCallContext], AgentCard
44+
]
45+
| None = None,
3946
) -> None:
4047
"""Initializes the A2AStarletteApplication.
4148
@@ -48,12 +55,19 @@ def __init__(
4855
context_builder: The CallContextBuilder used to construct the
4956
ServerCallContext passed to the http_handler. If None, no
5057
ServerCallContext is passed.
58+
card_modifier: An optional callback to dynamically modify the public
59+
agent card before it is served.
60+
extended_card_modifier: An optional callback to dynamically modify
61+
the extended agent card before it is served. It receives the
62+
call context.
5163
"""
5264
super().__init__(
5365
agent_card=agent_card,
5466
http_handler=http_handler,
5567
extended_agent_card=extended_agent_card,
5668
context_builder=context_builder,
69+
card_modifier=card_modifier,
70+
extended_card_modifier=extended_card_modifier,
5771
)
5872

5973
def routes(
@@ -87,14 +101,13 @@ def routes(
87101
),
88102
]
89103

90-
# add deprecated path only if the agent_card_url uses default well-known path
91104
if agent_card_url == AGENT_CARD_WELL_KNOWN_PATH:
92105
app_routes.append(
93106
Route(
94107
PREV_AGENT_CARD_WELL_KNOWN_PATH,
95-
self.handle_deprecated_agent_card_path,
108+
self._handle_get_agent_card,
96109
methods=['GET'],
97-
name='agent_card_path_deprecated',
110+
name='deprecated_agent_card',
98111
)
99112
)
100113

src/a2a/server/request_handlers/grpc_handler.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
"'pip install a2a-sdk[grpc]'"
1919
) from e
2020

21+
from collections.abc import Callable
22+
2123
import a2a.grpc.a2a_pb2_grpc as a2a_grpc
2224

2325
from a2a import types
@@ -87,6 +89,7 @@ def __init__(
8789
agent_card: AgentCard,
8890
request_handler: RequestHandler,
8991
context_builder: CallContextBuilder | None = None,
92+
card_modifier: Callable[[AgentCard], AgentCard] | None = None,
9093
):
9194
"""Initializes the GrpcHandler.
9295
@@ -96,10 +99,13 @@ def __init__(
9699
delegate requests to.
97100
context_builder: The CallContextBuilder object. If none the
98101
DefaultCallContextBuilder is used.
102+
card_modifier: An optional callback to dynamically modify the public
103+
agent card before it is served.
99104
"""
100105
self.agent_card = agent_card
101106
self.request_handler = request_handler
102107
self.context_builder = context_builder or DefaultCallContextBuilder()
108+
self.card_modifier = card_modifier
103109

104110
async def SendMessage(
105111
self,
@@ -331,7 +337,10 @@ async def GetAgentCard(
331337
context: grpc.aio.ServicerContext,
332338
) -> a2a_pb2.AgentCard:
333339
"""Get the agent card for the agent served."""
334-
return proto_utils.ToProto.agent_card(self.agent_card)
340+
card_to_serve = self.agent_card
341+
if self.card_modifier:
342+
card_to_serve = self.card_modifier(card_to_serve)
343+
return proto_utils.ToProto.agent_card(card_to_serve)
335344

336345
async def abort_context(
337346
self, error: ServerError, context: grpc.aio.ServicerContext

tests/server/apps/jsonrpc/test_serialization.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ def test_starlette_agent_card_with_api_key_scheme_alias(
5858
app_instance = A2AStarletteApplication(agent_card_with_api_key, handler)
5959
client = TestClient(app_instance.build())
6060

61-
response = client.get('/.well-known/agent.json')
61+
response = client.get('/.well-known/agent-card.json')
62+
print(response.status_code, response.content)
6263
assert response.status_code == 200
6364
response_data = response.json()
6465

@@ -90,7 +91,8 @@ def test_fastapi_agent_card_with_api_key_scheme_alias(
9091
app_instance = A2AFastAPIApplication(agent_card_with_api_key, handler)
9192
client = TestClient(app_instance.build())
9293

93-
response = client.get('/.well-known/agent.json')
94+
response = client.get('/.well-known/agent-card.json')
95+
print(response.status_code, response.content)
9496
assert response.status_code == 200
9597
response_data = response.json()
9698

tests/server/request_handlers/test_grpc_handler.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,34 @@ async def test_get_agent_card(
201201
assert response.version == sample_agent_card.version
202202

203203

204+
@pytest.mark.asyncio
205+
async def test_get_agent_card_with_modifier(
206+
mock_request_handler: AsyncMock,
207+
sample_agent_card: types.AgentCard,
208+
mock_grpc_context: AsyncMock,
209+
):
210+
"""Test GetAgentCard call with a card_modifier."""
211+
212+
def modifier(card: types.AgentCard) -> types.AgentCard:
213+
modified_card = card.model_copy()
214+
modified_card.name = 'Modified gRPC Agent'
215+
return modified_card
216+
217+
grpc_handler_modified = GrpcHandler(
218+
agent_card=sample_agent_card,
219+
request_handler=mock_request_handler,
220+
card_modifier=modifier,
221+
)
222+
223+
request_proto = a2a_pb2.GetAgentCardRequest()
224+
response = await grpc_handler_modified.GetAgentCard(
225+
request_proto, mock_grpc_context
226+
)
227+
228+
assert response.name == 'Modified gRPC Agent'
229+
assert response.version == sample_agent_card.version
230+
231+
204232
@pytest.mark.asyncio
205233
@pytest.mark.parametrize(
206234
'server_error, grpc_status_code, error_message_part',

tests/server/tasks/test_database_push_notification_config_store.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
from collections.abc import AsyncGenerator
44

55
import pytest
6+
7+
# Skip entire test module if SQLAlchemy is not installed
8+
pytest.importorskip('sqlalchemy', reason='Database tests require SQLAlchemy')
9+
pytest.importorskip(
10+
'cryptography',
11+
reason='Database tests require Cryptography. Install extra encryption',
12+
)
13+
614
import pytest_asyncio
715

816
from _pytest.mark.structures import ParameterSet
@@ -12,14 +20,6 @@
1220
create_async_engine,
1321
)
1422

15-
16-
# Skip entire test module if SQLAlchemy is not installed
17-
pytest.importorskip('sqlalchemy', reason='Database tests require SQLAlchemy')
18-
pytest.importorskip(
19-
'cryptography',
20-
reason='Database tests require Cryptography. Install extra encryption',
21-
)
22-
2323
# Now safe to import SQLAlchemy-dependent modules
2424
from cryptography.fernet import Fernet
2525
from sqlalchemy.inspection import inspect

0 commit comments

Comments
 (0)