From 7e6249e2e4921ea8f2a4745ab61c9c4677ec5afc Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Wed, 13 Aug 2025 10:26:45 -0700 Subject: [PATCH 01/10] wip --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 66 ++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index 46e79a2cc..e058801a5 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -58,6 +58,7 @@ from sse_starlette.sse import EventSourceResponse from starlette.applications import Starlette from starlette.authentication import BaseUser + from starlette.datastructures import URL from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -485,6 +486,63 @@ async def event_generator( handler_result.root.model_dump(mode='json', exclude_none=True), headers=headers, ) + + def _modify_rpc_url(self, agent_card: AgentCard, request: Request): + rpc_url = URL(agent_card.url) + rpc_path = rpc_url.path + port = None + if "X-Forwarded-Host" in request.headers: + host = request.headers["X-Forwarded-Host"] + else: + host = request.url.hostname + port = request.url.port + + if "X-Forwarded-Proto" in request.headers: + scheme = request.headers["X-Forwarded-Proto"] + else: + scheme = request.url.scheme + if not scheme: + scheme = "http" + if ":" in host: # type: ignore + comps = host.rsplit(":", 1) # type: ignore + host = comps[0] + port = comps[1] + + # Handle URL maps, + # e.g. "agents/my-agent/.well-known/agent-card.json" + if "X-Forwarded-Path" in request.headers: + forwarded_path = request.headers["X-Forwarded-Path"].strip() + if ( + forwarded_path and + request.url.path != forwarded_path + and forwarded_path.endswith(request.url.path) + ): + # "agents/my-agent" for "agents/my-agent/.well-known/agent-card.json" + extra_path = forwarded_path[:-len(request.url.path)] + new_path = extra_path + rpc_path + # If original path was just "/", + # we remove trailing "/" in the the extended one + if len(new_path) > 1 and rpc_path == "/": + new_path = new_path.rstrip("/") + rpc_path = new_path + + if port: + agent_card.url = str( + rpc_url.replace( + hostname=host, + port=port, + scheme=scheme, + path=rpc_path + ) + ) + else: + agent_card.url = str( + rpc_url.replace( + hostname=host, + scheme=scheme, + path=rpc_path + ) + ) async def _handle_get_agent_card(self, request: Request) -> JSONResponse: """Handles GET requests for the agent card endpoint. @@ -502,8 +560,12 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse: ) card_to_serve = self.agent_card + rpc_url = card_to_serve.url if self.card_modifier: card_to_serve = self.card_modifier(card_to_serve) + # If agent's RPC URL was not modified, we build it dynamically. + if rpc_url == card_to_serve.url: + self._modify_rpc_url(card_to_serve, request) return JSONResponse( card_to_serve.model_dump( @@ -528,6 +590,7 @@ async def _handle_get_authenticated_extended_agent_card( card_to_serve = self.extended_agent_card + rpc_url = card_to_serve.url if card_to_serve else None 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 @@ -535,6 +598,9 @@ async def _handle_get_authenticated_extended_agent_card( card_to_serve = self.extended_card_modifier(base_card, context) if card_to_serve: + # If agent's RPC URL was not modified, we build it dynamically. + if rpc_url == card_to_serve.url: + self._modify_rpc_url(card_to_serve, request) return JSONResponse( card_to_serve.model_dump( exclude_none=True, From 8e2352f04f53fd6e9462ca70656cebacd2eab0d1 Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Wed, 20 Aug 2025 13:53:08 -0700 Subject: [PATCH 02/10] Added tests --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 31 +++++----- src/a2a/server/apps/rest/rest_adapter.py | 61 +++++++++++++++++- tests/server/apps/jsonrpc/test_jsonrpc_app.py | 56 ++++++++++++++++- .../server/apps/rest/test_rest_fastapi_app.py | 62 ++++++++++++++++++- 4 files changed, 190 insertions(+), 20 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index e058801a5..e476968ac 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -71,6 +71,7 @@ from sse_starlette.sse import EventSourceResponse from starlette.applications import Starlette from starlette.authentication import BaseUser + from starlette.datastructures import URL from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -488,6 +489,12 @@ async def event_generator( ) def _modify_rpc_url(self, agent_card: AgentCard, request: Request): + """Modifies Agent's RPC URL based on the AgentCard request. + + Args: + agent_card (AgentCard): Original AgentCard + request (Request): AgentCard request + """ rpc_url = URL(agent_card.url) rpc_path = rpc_url.path port = None @@ -499,6 +506,7 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request): if "X-Forwarded-Proto" in request.headers: scheme = request.headers["X-Forwarded-Proto"] + port = None else: scheme = request.url.scheme if not scheme: @@ -526,23 +534,14 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request): new_path = new_path.rstrip("/") rpc_path = new_path - if port: - agent_card.url = str( - rpc_url.replace( - hostname=host, - port=port, - scheme=scheme, - path=rpc_path - ) - ) - else: - agent_card.url = str( - rpc_url.replace( - hostname=host, - scheme=scheme, - path=rpc_path - ) + agent_card.url = str( + rpc_url.replace( + hostname=host, + port=port, + scheme=scheme, + path=rpc_path ) + ) async def _handle_get_agent_card(self, request: Request) -> JSONResponse: """Handles GET requests for the agent card endpoint. diff --git a/src/a2a/server/apps/rest/rest_adapter.py b/src/a2a/server/apps/rest/rest_adapter.py index 898192854..185ea2e83 100644 --- a/src/a2a/server/apps/rest/rest_adapter.py +++ b/src/a2a/server/apps/rest/rest_adapter.py @@ -7,6 +7,7 @@ if TYPE_CHECKING: from sse_starlette.sse import EventSourceResponse + from starlette.datastructures import URL from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -15,6 +16,7 @@ else: try: from sse_starlette.sse import EventSourceResponse + from starlette.datastructures import URL from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -119,7 +121,8 @@ async def handle_get_agent_card(self, request: Request) -> JSONResponse: A JSONResponse containing the agent card data. """ # The public agent card is a direct serialization of the agent_card - # provided at initialization. + # provided at initialization except for the RPC URL. + self._modify_rpc_url(self.agent_card, request) return JSONResponse( self.agent_card.model_dump(mode='json', exclude_none=True) ) @@ -145,9 +148,65 @@ async def handle_authenticated_agent_card( message='Authenticated card not supported' ) ) + self._modify_rpc_url(self.agent_card, request) return JSONResponse( self.agent_card.model_dump(mode='json', exclude_none=True) ) + + def _modify_rpc_url(self, agent_card: AgentCard, request: Request): + """Modifies Agent's RPC URL based on the AgentCard request. + + Args: + agent_card (AgentCard): Original AgentCard + request (Request): AgentCard request + """ + rpc_url = URL(agent_card.url) + rpc_path = rpc_url.path + port = None + if "X-Forwarded-Host" in request.headers: + host = request.headers["X-Forwarded-Host"] + else: + host = request.url.hostname + port = request.url.port + + if "X-Forwarded-Proto" in request.headers: + scheme = request.headers["X-Forwarded-Proto"] + port = None + else: + scheme = request.url.scheme + if not scheme: + scheme = "http" + if ":" in host: # type: ignore + comps = host.rsplit(":", 1) # type: ignore + host = comps[0] + port = comps[1] + + # Handle URL maps, + # e.g. "agents/my-agent/.well-known/agent-card.json" + if "X-Forwarded-Path" in request.headers: + forwarded_path = request.headers["X-Forwarded-Path"].strip() + if ( + forwarded_path and + request.url.path != forwarded_path + and forwarded_path.endswith(request.url.path) + ): + # "agents/my-agent" for "agents/my-agent/.well-known/agent-card.json" + extra_path = forwarded_path[:-len(request.url.path)] + new_path = extra_path + rpc_path + # If original path was just "/", + # we remove trailing "/" in the the extended one + if len(new_path) > 1 and rpc_path == "/": + new_path = new_path.rstrip("/") + rpc_path = new_path + + agent_card.url = str( + rpc_url.replace( + hostname=host, + port=port, + scheme=scheme, + path=rpc_path + ) + ) def routes(self) -> dict[tuple[str, str], Callable[[Request], Any]]: """Constructs a dictionary of API routes and their corresponding handlers. diff --git a/tests/server/apps/jsonrpc/test_jsonrpc_app.py b/tests/server/apps/jsonrpc/test_jsonrpc_app.py index 72da73772..fe919fc33 100644 --- a/tests/server/apps/jsonrpc/test_jsonrpc_app.py +++ b/tests/server/apps/jsonrpc/test_jsonrpc_app.py @@ -26,6 +26,7 @@ RequestHandler, ) # For mock spec from a2a.types import ( + AgentCapabilities, AgentCard, Message, MessageSendParams, @@ -36,7 +37,7 @@ SendMessageSuccessResponse, TextPart, ) - +from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH # --- StarletteUserProxy Tests --- @@ -356,5 +357,58 @@ def side_effect(request, context: ServerCallContext): } +class TestAgentCardHandler: + @pytest.fixture + def agent_card(self): + return AgentCard( + name='APIKeyAgent', + description='An agent that uses API Key auth.', + url='http://localhost:8000', + version='1.0.0', + capabilities=AgentCapabilities(), + default_input_modes=['text/plain'], + default_output_modes=['text/plain'], + skills=[], + ) + + def test_agent_card_url_rewriting( + self, agent_card: AgentCard, + ): + """ + Tests that the A2AStarletteApplication endpoint correctly handles Agent URL rewriting. + """ + handler = AsyncMock() + app_instance = A2AStarletteApplication(agent_card, handler) + client = TestClient( + app_instance.build(), + base_url="https://my-agents.com:5000" + ) + + response = client.get(AGENT_CARD_WELL_KNOWN_PATH) + response.raise_for_status() + assert response.json()["url"] == "https://my-agents.com:5000" + + response = client.get( + AGENT_CARD_WELL_KNOWN_PATH, + headers={ + "X-Forwarded-Host": "my-great-agents.com:5678", + "X-Forwarded-Proto": "http", + "X-Forwarded-Path": + "/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH + } + ) + assert response.json()["url"] == "http://my-great-agents.com:5678/agents/my-agent" + + client = TestClient( + app_instance.build( + agent_card_url="/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH + ), + base_url="https://my-mighty-agents.com" + ) + + response = client.get("/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH) + assert response.json()["url"] == "https://my-mighty-agents.com/agents/my-agent" + + if __name__ == '__main__': pytest.main([__file__]) diff --git a/tests/server/apps/rest/test_rest_fastapi_app.py b/tests/server/apps/rest/test_rest_fastapi_app.py index c5ea89c40..878e26ebe 100644 --- a/tests/server/apps/rest/test_rest_fastapi_app.py +++ b/tests/server/apps/rest/test_rest_fastapi_app.py @@ -1,7 +1,7 @@ import logging from typing import Any -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -15,6 +15,7 @@ from a2a.server.apps.rest.rest_adapter import RESTAdapter from a2a.server.request_handlers.request_handler import RequestHandler from a2a.types import ( + AgentCapabilities, AgentCard, Message, Part, @@ -24,7 +25,7 @@ TaskStatus, TextPart, ) - +from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH logger = logging.getLogger(__name__) @@ -222,5 +223,62 @@ async def test_send_message_success_task( assert expected_response == actual_response +class TestAgentCardHandler: + @pytest.fixture + def agent_card(self): + return AgentCard( + name='APIKeyAgent', + description='An agent that uses API Key auth.', + url='http://localhost:8000', + version='1.0.0', + capabilities=AgentCapabilities(), + default_input_modes=['text/plain'], + default_output_modes=['text/plain'], + skills=[], + ) + + @pytest.mark.anyio + async def test_agent_card_url_rewriting( + self, agent_card: AgentCard, + ): + """ + Tests that the REST endpoint correctly handles Agent URL rewriting. + """ + app_instance = A2ARESTFastAPIApplication(agent_card, AsyncMock()) + app = app_instance.build( + agent_card_url=AGENT_CARD_WELL_KNOWN_PATH + ) + client = AsyncClient( + transport=ASGITransport(app=app), + base_url="https://my-agents.com:5000" + ) + + response = await client.get(AGENT_CARD_WELL_KNOWN_PATH) + response.raise_for_status() + assert response.json()["url"] == "https://my-agents.com:5000" + + response = await client.get( + AGENT_CARD_WELL_KNOWN_PATH, + headers={ + "X-Forwarded-Host": "my-great-agents.com:5678", + "X-Forwarded-Proto": "http", + "X-Forwarded-Path": + "/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH + } + ) + assert response.json()["url"] == "http://my-great-agents.com:5678/agents/my-agent" + + app = app_instance.build( + agent_card_url="/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH + ) + client = AsyncClient( + transport=ASGITransport(app=app), + base_url="https://my-mighty-agents.com" + ) + + response = await client.get("/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH) + assert response.json()["url"] == "https://my-mighty-agents.com/agents/my-agent" + + if __name__ == '__main__': pytest.main([__file__]) From 50fce23d732218e317175d60e0451b2f9198e935 Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Wed, 20 Aug 2025 13:58:32 -0700 Subject: [PATCH 03/10] formatting --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 37 +++--- src/a2a/server/apps/rest/rest_adapter.py | 37 +++--- tests/server/apps/jsonrpc/test_jsonrpc_app.py | 99 ++++++++------- .../server/apps/rest/test_rest_fastapi_app.py | 116 ++++++++++-------- 4 files changed, 149 insertions(+), 140 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index e476968ac..f2d15e39d 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -487,7 +487,7 @@ async def event_generator( handler_result.root.model_dump(mode='json', exclude_none=True), headers=headers, ) - + def _modify_rpc_url(self, agent_card: AgentCard, request: Request): """Modifies Agent's RPC URL based on the AgentCard request. @@ -498,48 +498,45 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request): rpc_url = URL(agent_card.url) rpc_path = rpc_url.path port = None - if "X-Forwarded-Host" in request.headers: - host = request.headers["X-Forwarded-Host"] + if 'X-Forwarded-Host' in request.headers: + host = request.headers['X-Forwarded-Host'] else: host = request.url.hostname port = request.url.port - - if "X-Forwarded-Proto" in request.headers: - scheme = request.headers["X-Forwarded-Proto"] + + if 'X-Forwarded-Proto' in request.headers: + scheme = request.headers['X-Forwarded-Proto'] port = None else: scheme = request.url.scheme if not scheme: - scheme = "http" - if ":" in host: # type: ignore - comps = host.rsplit(":", 1) # type: ignore + scheme = 'http' + if ':' in host: # type: ignore + comps = host.rsplit(':', 1) # type: ignore host = comps[0] port = comps[1] # Handle URL maps, # e.g. "agents/my-agent/.well-known/agent-card.json" - if "X-Forwarded-Path" in request.headers: - forwarded_path = request.headers["X-Forwarded-Path"].strip() + if 'X-Forwarded-Path' in request.headers: + forwarded_path = request.headers['X-Forwarded-Path'].strip() if ( - forwarded_path and - request.url.path != forwarded_path + forwarded_path + and request.url.path != forwarded_path and forwarded_path.endswith(request.url.path) ): # "agents/my-agent" for "agents/my-agent/.well-known/agent-card.json" - extra_path = forwarded_path[:-len(request.url.path)] + extra_path = forwarded_path[: -len(request.url.path)] new_path = extra_path + rpc_path # If original path was just "/", # we remove trailing "/" in the the extended one - if len(new_path) > 1 and rpc_path == "/": - new_path = new_path.rstrip("/") + if len(new_path) > 1 and rpc_path == '/': + new_path = new_path.rstrip('/') rpc_path = new_path agent_card.url = str( rpc_url.replace( - hostname=host, - port=port, - scheme=scheme, - path=rpc_path + hostname=host, port=port, scheme=scheme, path=rpc_path ) ) diff --git a/src/a2a/server/apps/rest/rest_adapter.py b/src/a2a/server/apps/rest/rest_adapter.py index 185ea2e83..18f017279 100644 --- a/src/a2a/server/apps/rest/rest_adapter.py +++ b/src/a2a/server/apps/rest/rest_adapter.py @@ -152,7 +152,7 @@ async def handle_authenticated_agent_card( return JSONResponse( self.agent_card.model_dump(mode='json', exclude_none=True) ) - + def _modify_rpc_url(self, agent_card: AgentCard, request: Request): """Modifies Agent's RPC URL based on the AgentCard request. @@ -163,48 +163,45 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request): rpc_url = URL(agent_card.url) rpc_path = rpc_url.path port = None - if "X-Forwarded-Host" in request.headers: - host = request.headers["X-Forwarded-Host"] + if 'X-Forwarded-Host' in request.headers: + host = request.headers['X-Forwarded-Host'] else: host = request.url.hostname port = request.url.port - - if "X-Forwarded-Proto" in request.headers: - scheme = request.headers["X-Forwarded-Proto"] + + if 'X-Forwarded-Proto' in request.headers: + scheme = request.headers['X-Forwarded-Proto'] port = None else: scheme = request.url.scheme if not scheme: - scheme = "http" - if ":" in host: # type: ignore - comps = host.rsplit(":", 1) # type: ignore + scheme = 'http' + if ':' in host: # type: ignore + comps = host.rsplit(':', 1) # type: ignore host = comps[0] port = comps[1] # Handle URL maps, # e.g. "agents/my-agent/.well-known/agent-card.json" - if "X-Forwarded-Path" in request.headers: - forwarded_path = request.headers["X-Forwarded-Path"].strip() + if 'X-Forwarded-Path' in request.headers: + forwarded_path = request.headers['X-Forwarded-Path'].strip() if ( - forwarded_path and - request.url.path != forwarded_path + forwarded_path + and request.url.path != forwarded_path and forwarded_path.endswith(request.url.path) ): # "agents/my-agent" for "agents/my-agent/.well-known/agent-card.json" - extra_path = forwarded_path[:-len(request.url.path)] + extra_path = forwarded_path[: -len(request.url.path)] new_path = extra_path + rpc_path # If original path was just "/", # we remove trailing "/" in the the extended one - if len(new_path) > 1 and rpc_path == "/": - new_path = new_path.rstrip("/") + if len(new_path) > 1 and rpc_path == '/': + new_path = new_path.rstrip('/') rpc_path = new_path agent_card.url = str( rpc_url.replace( - hostname=host, - port=port, - scheme=scheme, - path=rpc_path + hostname=host, port=port, scheme=scheme, path=rpc_path ) ) diff --git a/tests/server/apps/jsonrpc/test_jsonrpc_app.py b/tests/server/apps/jsonrpc/test_jsonrpc_app.py index fe919fc33..855d80b0c 100644 --- a/tests/server/apps/jsonrpc/test_jsonrpc_app.py +++ b/tests/server/apps/jsonrpc/test_jsonrpc_app.py @@ -39,6 +39,7 @@ ) from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH + # --- StarletteUserProxy Tests --- @@ -358,56 +359,62 @@ def side_effect(request, context: ServerCallContext): class TestAgentCardHandler: - @pytest.fixture - def agent_card(self): - return AgentCard( - name='APIKeyAgent', - description='An agent that uses API Key auth.', - url='http://localhost:8000', - version='1.0.0', - capabilities=AgentCapabilities(), - default_input_modes=['text/plain'], - default_output_modes=['text/plain'], - skills=[], - ) + @pytest.fixture + def agent_card(self): + return AgentCard( + name='APIKeyAgent', + description='An agent that uses API Key auth.', + url='http://localhost:8000', + version='1.0.0', + capabilities=AgentCapabilities(), + default_input_modes=['text/plain'], + default_output_modes=['text/plain'], + skills=[], + ) - def test_agent_card_url_rewriting( - self, agent_card: AgentCard, - ): - """ - Tests that the A2AStarletteApplication endpoint correctly handles Agent URL rewriting. - """ - handler = AsyncMock() - app_instance = A2AStarletteApplication(agent_card, handler) - client = TestClient( - app_instance.build(), - base_url="https://my-agents.com:5000" - ) + def test_agent_card_url_rewriting( + self, + agent_card: AgentCard, + ): + """ + Tests that the A2AStarletteApplication endpoint correctly handles Agent URL rewriting. + """ + handler = AsyncMock() + app_instance = A2AStarletteApplication(agent_card, handler) + client = TestClient( + app_instance.build(), base_url='https://my-agents.com:5000' + ) - response = client.get(AGENT_CARD_WELL_KNOWN_PATH) - response.raise_for_status() - assert response.json()["url"] == "https://my-agents.com:5000" - - response = client.get( - AGENT_CARD_WELL_KNOWN_PATH, - headers={ - "X-Forwarded-Host": "my-great-agents.com:5678", - "X-Forwarded-Proto": "http", - "X-Forwarded-Path": - "/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH - } - ) - assert response.json()["url"] == "http://my-great-agents.com:5678/agents/my-agent" + response = client.get(AGENT_CARD_WELL_KNOWN_PATH) + response.raise_for_status() + assert response.json()['url'] == 'https://my-agents.com:5000' + + response = client.get( + AGENT_CARD_WELL_KNOWN_PATH, + headers={ + 'X-Forwarded-Host': 'my-great-agents.com:5678', + 'X-Forwarded-Proto': 'http', + 'X-Forwarded-Path': '/agents/my-agent' + + AGENT_CARD_WELL_KNOWN_PATH, + }, + ) + assert ( + response.json()['url'] + == 'http://my-great-agents.com:5678/agents/my-agent' + ) - client = TestClient( - app_instance.build( - agent_card_url="/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH - ), - base_url="https://my-mighty-agents.com" - ) + client = TestClient( + app_instance.build( + agent_card_url='/agents/my-agent' + AGENT_CARD_WELL_KNOWN_PATH + ), + base_url='https://my-mighty-agents.com', + ) - response = client.get("/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH) - assert response.json()["url"] == "https://my-mighty-agents.com/agents/my-agent" + response = client.get('/agents/my-agent' + AGENT_CARD_WELL_KNOWN_PATH) + assert ( + response.json()['url'] + == 'https://my-mighty-agents.com/agents/my-agent' + ) if __name__ == '__main__': diff --git a/tests/server/apps/rest/test_rest_fastapi_app.py b/tests/server/apps/rest/test_rest_fastapi_app.py index 878e26ebe..4059647e5 100644 --- a/tests/server/apps/rest/test_rest_fastapi_app.py +++ b/tests/server/apps/rest/test_rest_fastapi_app.py @@ -27,6 +27,7 @@ ) from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH + logger = logging.getLogger(__name__) @@ -224,60 +225,67 @@ async def test_send_message_success_task( class TestAgentCardHandler: - @pytest.fixture - def agent_card(self): - return AgentCard( - name='APIKeyAgent', - description='An agent that uses API Key auth.', - url='http://localhost:8000', - version='1.0.0', - capabilities=AgentCapabilities(), - default_input_modes=['text/plain'], - default_output_modes=['text/plain'], - skills=[], - ) - - @pytest.mark.anyio - async def test_agent_card_url_rewriting( - self, agent_card: AgentCard, - ): - """ - Tests that the REST endpoint correctly handles Agent URL rewriting. - """ - app_instance = A2ARESTFastAPIApplication(agent_card, AsyncMock()) - app = app_instance.build( - agent_card_url=AGENT_CARD_WELL_KNOWN_PATH - ) - client = AsyncClient( - transport=ASGITransport(app=app), - base_url="https://my-agents.com:5000" - ) - - response = await client.get(AGENT_CARD_WELL_KNOWN_PATH) - response.raise_for_status() - assert response.json()["url"] == "https://my-agents.com:5000" - - response = await client.get( - AGENT_CARD_WELL_KNOWN_PATH, - headers={ - "X-Forwarded-Host": "my-great-agents.com:5678", - "X-Forwarded-Proto": "http", - "X-Forwarded-Path": - "/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH - } - ) - assert response.json()["url"] == "http://my-great-agents.com:5678/agents/my-agent" - - app = app_instance.build( - agent_card_url="/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH - ) - client = AsyncClient( - transport=ASGITransport(app=app), - base_url="https://my-mighty-agents.com" - ) - - response = await client.get("/agents/my-agent" + AGENT_CARD_WELL_KNOWN_PATH) - assert response.json()["url"] == "https://my-mighty-agents.com/agents/my-agent" + @pytest.fixture + def agent_card(self): + return AgentCard( + name='APIKeyAgent', + description='An agent that uses API Key auth.', + url='http://localhost:8000', + version='1.0.0', + capabilities=AgentCapabilities(), + default_input_modes=['text/plain'], + default_output_modes=['text/plain'], + skills=[], + ) + + @pytest.mark.anyio + async def test_agent_card_url_rewriting( + self, + agent_card: AgentCard, + ): + """ + Tests that the REST endpoint correctly handles Agent URL rewriting. + """ + app_instance = A2ARESTFastAPIApplication(agent_card, AsyncMock()) + app = app_instance.build(agent_card_url=AGENT_CARD_WELL_KNOWN_PATH) + client = AsyncClient( + transport=ASGITransport(app=app), + base_url='https://my-agents.com:5000', + ) + + response = await client.get(AGENT_CARD_WELL_KNOWN_PATH) + response.raise_for_status() + assert response.json()['url'] == 'https://my-agents.com:5000' + + response = await client.get( + AGENT_CARD_WELL_KNOWN_PATH, + headers={ + 'X-Forwarded-Host': 'my-great-agents.com:5678', + 'X-Forwarded-Proto': 'http', + 'X-Forwarded-Path': '/agents/my-agent' + + AGENT_CARD_WELL_KNOWN_PATH, + }, + ) + assert ( + response.json()['url'] + == 'http://my-great-agents.com:5678/agents/my-agent' + ) + + app = app_instance.build( + agent_card_url='/agents/my-agent' + AGENT_CARD_WELL_KNOWN_PATH + ) + client = AsyncClient( + transport=ASGITransport(app=app), + base_url='https://my-mighty-agents.com', + ) + + response = await client.get( + '/agents/my-agent' + AGENT_CARD_WELL_KNOWN_PATH + ) + assert ( + response.json()['url'] + == 'https://my-mighty-agents.com/agents/my-agent' + ) if __name__ == '__main__': From c8ea7877e350659d9949a60e0df83d4b1ce45202 Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Wed, 20 Aug 2025 14:25:22 -0700 Subject: [PATCH 04/10] fix typos --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 54 +++++++--------------- src/a2a/server/apps/rest/rest_adapter.py | 17 ++----- 2 files changed, 22 insertions(+), 49 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index f2d15e39d..b377bca9e 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -2,7 +2,6 @@ import json import logging import traceback - from abc import ABC, abstractmethod from collections.abc import AsyncGenerator, Callable from typing import TYPE_CHECKING, Any @@ -11,46 +10,27 @@ from a2a.auth.user import UnauthenticatedUser from a2a.auth.user import User as A2AUser -from a2a.extensions.common import ( - HTTP_EXTENSION_HEADER, - get_requested_extensions, -) +from a2a.extensions.common import (HTTP_EXTENSION_HEADER, + get_requested_extensions) from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler from a2a.server.request_handlers.request_handler import RequestHandler -from a2a.types import ( - A2AError, - A2ARequest, - AgentCard, - CancelTaskRequest, - DeleteTaskPushNotificationConfigRequest, - GetAuthenticatedExtendedCardRequest, - GetTaskPushNotificationConfigRequest, - GetTaskRequest, - InternalError, - InvalidRequestError, - JSONParseError, - JSONRPCError, - JSONRPCErrorResponse, - JSONRPCRequest, - JSONRPCResponse, - ListTaskPushNotificationConfigRequest, - SendMessageRequest, - SendStreamingMessageRequest, - SendStreamingMessageResponse, - SetTaskPushNotificationConfigRequest, - TaskResubscriptionRequest, - UnsupportedOperationError, -) -from a2a.utils.constants import ( - AGENT_CARD_WELL_KNOWN_PATH, - DEFAULT_RPC_URL, - EXTENDED_AGENT_CARD_PATH, - PREV_AGENT_CARD_WELL_KNOWN_PATH, -) +from a2a.types import (A2AError, A2ARequest, AgentCard, CancelTaskRequest, + DeleteTaskPushNotificationConfigRequest, + GetAuthenticatedExtendedCardRequest, + GetTaskPushNotificationConfigRequest, GetTaskRequest, + InternalError, InvalidRequestError, JSONParseError, + JSONRPCError, JSONRPCErrorResponse, JSONRPCRequest, + JSONRPCResponse, ListTaskPushNotificationConfigRequest, + SendMessageRequest, SendStreamingMessageRequest, + SendStreamingMessageResponse, + SetTaskPushNotificationConfigRequest, + TaskResubscriptionRequest, UnsupportedOperationError) +from a2a.utils.constants import (AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_RPC_URL, + EXTENDED_AGENT_CARD_PATH, + PREV_AGENT_CARD_WELL_KNOWN_PATH) from a2a.utils.errors import MethodNotImplementedError - logger = logging.getLogger(__name__) if TYPE_CHECKING: @@ -529,7 +509,7 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request): extra_path = forwarded_path[: -len(request.url.path)] new_path = extra_path + rpc_path # If original path was just "/", - # we remove trailing "/" in the the extended one + # we remove trailing "/" in the extended one if len(new_path) > 1 and rpc_path == '/': new_path = new_path.rstrip('/') rpc_path = new_path diff --git a/src/a2a/server/apps/rest/rest_adapter.py b/src/a2a/server/apps/rest/rest_adapter.py index 101b4e06d..d36403543 100644 --- a/src/a2a/server/apps/rest/rest_adapter.py +++ b/src/a2a/server/apps/rest/rest_adapter.py @@ -1,10 +1,8 @@ import functools import logging - from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable from typing import TYPE_CHECKING, Any - if TYPE_CHECKING: from sse_starlette.sse import EventSourceResponse from starlette.datastructures import URL @@ -29,21 +27,16 @@ _package_starlette_installed = False -from a2a.server.apps.jsonrpc import ( - CallContextBuilder, - DefaultCallContextBuilder, -) +from a2a.server.apps.jsonrpc import (CallContextBuilder, + DefaultCallContextBuilder) from a2a.server.context import ServerCallContext from a2a.server.request_handlers.request_handler import RequestHandler from a2a.server.request_handlers.rest_handler import RESTHandler from a2a.types import AgentCard, AuthenticatedExtendedCardNotConfiguredError -from a2a.utils.error_handlers import ( - rest_error_handler, - rest_stream_error_handler, -) +from a2a.utils.error_handlers import (rest_error_handler, + rest_stream_error_handler) from a2a.utils.errors import ServerError - logger = logging.getLogger(__name__) @@ -233,7 +226,7 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request): extra_path = forwarded_path[: -len(request.url.path)] new_path = extra_path + rpc_path # If original path was just "/", - # we remove trailing "/" in the the extended one + # we remove trailing "/" in the extended one if len(new_path) > 1 and rpc_path == '/': new_path = new_path.rstrip('/') rpc_path = new_path From b3ee26dc81539462f8df030a75efac077df72601 Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Wed, 20 Aug 2025 14:33:09 -0700 Subject: [PATCH 05/10] linting --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 52 +++++++++++++++------- src/a2a/server/apps/rest/rest_adapter.py | 14 +++--- 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index b377bca9e..0326fc1ec 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -10,25 +10,43 @@ from a2a.auth.user import UnauthenticatedUser from a2a.auth.user import User as A2AUser -from a2a.extensions.common import (HTTP_EXTENSION_HEADER, - get_requested_extensions) +from a2a.extensions.common import ( + HTTP_EXTENSION_HEADER, + get_requested_extensions +) from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler from a2a.server.request_handlers.request_handler import RequestHandler -from a2a.types import (A2AError, A2ARequest, AgentCard, CancelTaskRequest, - DeleteTaskPushNotificationConfigRequest, - GetAuthenticatedExtendedCardRequest, - GetTaskPushNotificationConfigRequest, GetTaskRequest, - InternalError, InvalidRequestError, JSONParseError, - JSONRPCError, JSONRPCErrorResponse, JSONRPCRequest, - JSONRPCResponse, ListTaskPushNotificationConfigRequest, - SendMessageRequest, SendStreamingMessageRequest, - SendStreamingMessageResponse, - SetTaskPushNotificationConfigRequest, - TaskResubscriptionRequest, UnsupportedOperationError) -from a2a.utils.constants import (AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_RPC_URL, - EXTENDED_AGENT_CARD_PATH, - PREV_AGENT_CARD_WELL_KNOWN_PATH) +from a2a.types import ( + A2AError, + A2ARequest, + AgentCard, + CancelTaskRequest, + DeleteTaskPushNotificationConfigRequest, + GetAuthenticatedExtendedCardRequest, + GetTaskPushNotificationConfigRequest, + GetTaskRequest, + InternalError, + InvalidRequestError, + JSONParseError, + JSONRPCError, + JSONRPCErrorResponse, + JSONRPCRequest, + JSONRPCResponse, + ListTaskPushNotificationConfigRequest, + SendMessageRequest, + SendStreamingMessageRequest, + SendStreamingMessageResponse, + SetTaskPushNotificationConfigRequest, + TaskResubscriptionRequest, + UnsupportedOperationError +) +from a2a.utils.constants import ( + AGENT_CARD_WELL_KNOWN_PATH, + DEFAULT_RPC_URL, + EXTENDED_AGENT_CARD_PATH, + PREV_AGENT_CARD_WELL_KNOWN_PATH +) from a2a.utils.errors import MethodNotImplementedError logger = logging.getLogger(__name__) @@ -468,7 +486,7 @@ async def event_generator( headers=headers, ) - def _modify_rpc_url(self, agent_card: AgentCard, request: Request): + def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: """Modifies Agent's RPC URL based on the AgentCard request. Args: diff --git a/src/a2a/server/apps/rest/rest_adapter.py b/src/a2a/server/apps/rest/rest_adapter.py index d36403543..59642c731 100644 --- a/src/a2a/server/apps/rest/rest_adapter.py +++ b/src/a2a/server/apps/rest/rest_adapter.py @@ -27,14 +27,18 @@ _package_starlette_installed = False -from a2a.server.apps.jsonrpc import (CallContextBuilder, - DefaultCallContextBuilder) +from a2a.server.apps.jsonrpc import ( + CallContextBuilder, + DefaultCallContextBuilder +) from a2a.server.context import ServerCallContext from a2a.server.request_handlers.request_handler import RequestHandler from a2a.server.request_handlers.rest_handler import RESTHandler from a2a.types import AgentCard, AuthenticatedExtendedCardNotConfiguredError -from a2a.utils.error_handlers import (rest_error_handler, - rest_stream_error_handler) +from a2a.utils.error_handlers import ( + rest_error_handler, + rest_stream_error_handler +) from a2a.utils.errors import ServerError logger = logging.getLogger(__name__) @@ -185,7 +189,7 @@ async def handle_authenticated_agent_card( return card_to_serve.model_dump(mode='json', exclude_none=True) - def _modify_rpc_url(self, agent_card: AgentCard, request: Request): + def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: """Modifies Agent's RPC URL based on the AgentCard request. Args: From 506d78277f8ebe6471bf0b0337f86ce9a0e971d3 Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Wed, 20 Aug 2025 14:38:26 -0700 Subject: [PATCH 06/10] formatting --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 8 +++++--- src/a2a/server/apps/rest/rest_adapter.py | 11 +++++++---- tests/integration/test_client_server_integration.py | 3 +++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index 0326fc1ec..6289700a3 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -2,6 +2,7 @@ import json import logging import traceback + from abc import ABC, abstractmethod from collections.abc import AsyncGenerator, Callable from typing import TYPE_CHECKING, Any @@ -12,7 +13,7 @@ from a2a.auth.user import User as A2AUser from a2a.extensions.common import ( HTTP_EXTENSION_HEADER, - get_requested_extensions + get_requested_extensions, ) from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler @@ -39,16 +40,17 @@ SendStreamingMessageResponse, SetTaskPushNotificationConfigRequest, TaskResubscriptionRequest, - UnsupportedOperationError + UnsupportedOperationError, ) from a2a.utils.constants import ( AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_RPC_URL, EXTENDED_AGENT_CARD_PATH, - PREV_AGENT_CARD_WELL_KNOWN_PATH + PREV_AGENT_CARD_WELL_KNOWN_PATH, ) from a2a.utils.errors import MethodNotImplementedError + logger = logging.getLogger(__name__) if TYPE_CHECKING: diff --git a/src/a2a/server/apps/rest/rest_adapter.py b/src/a2a/server/apps/rest/rest_adapter.py index 59642c731..49db7bf7a 100644 --- a/src/a2a/server/apps/rest/rest_adapter.py +++ b/src/a2a/server/apps/rest/rest_adapter.py @@ -1,8 +1,10 @@ import functools import logging + from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable from typing import TYPE_CHECKING, Any + if TYPE_CHECKING: from sse_starlette.sse import EventSourceResponse from starlette.datastructures import URL @@ -29,7 +31,7 @@ from a2a.server.apps.jsonrpc import ( CallContextBuilder, - DefaultCallContextBuilder + DefaultCallContextBuilder, ) from a2a.server.context import ServerCallContext from a2a.server.request_handlers.request_handler import RequestHandler @@ -37,10 +39,11 @@ from a2a.types import AgentCard, AuthenticatedExtendedCardNotConfiguredError from a2a.utils.error_handlers import ( rest_error_handler, - rest_stream_error_handler + rest_stream_error_handler, ) from a2a.utils.errors import ServerError + logger = logging.getLogger(__name__) @@ -148,7 +151,7 @@ async def handle_get_agent_card( if self.card_modifier: card_to_serve = self.card_modifier(card_to_serve) if rpc_url == card_to_serve.url: - self._modify_rpc_url(card_to_serve, request) + self._modify_rpc_url(card_to_serve, request) return card_to_serve.model_dump(mode='json', exclude_none=True) @@ -185,7 +188,7 @@ async def handle_authenticated_agent_card( base_card = card_to_serve if card_to_serve else self.agent_card card_to_serve = self.extended_card_modifier(base_card, context) if rpc_url == card_to_serve.url: - self._modify_rpc_url(card_to_serve, request) + self._modify_rpc_url(card_to_serve, request) return card_to_serve.model_dump(mode='json', exclude_none=True) diff --git a/tests/integration/test_client_server_integration.py b/tests/integration/test_client_server_integration.py index 88d4d3d11..323ff00fd 100644 --- a/tests/integration/test_client_server_integration.py +++ b/tests/integration/test_client_server_integration.py @@ -1,4 +1,5 @@ import asyncio + from collections.abc import AsyncGenerator from typing import NamedTuple from unittest.mock import ANY, AsyncMock @@ -7,6 +8,7 @@ import httpx import pytest import pytest_asyncio + from grpc.aio import Channel from a2a.client.transports import JsonRpcTransport, RestTransport @@ -36,6 +38,7 @@ TransportProtocol, ) + # --- Test Constants --- TASK_FROM_STREAM = Task( From cdd29b9db7a09d2972b886f66350ec3cbec0078a Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Wed, 20 Aug 2025 14:47:16 -0700 Subject: [PATCH 07/10] formatting and linting --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 6 +++--- src/a2a/server/apps/rest/rest_adapter.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index 6289700a3..9358be718 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -501,8 +501,8 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: if 'X-Forwarded-Host' in request.headers: host = request.headers['X-Forwarded-Host'] else: - host = request.url.hostname - port = request.url.port + host = request.url.hostname or rpc_url.hostname or 'localhost' + port = request.url.port or rpc_url.port if 'X-Forwarded-Proto' in request.headers: scheme = request.headers['X-Forwarded-Proto'] @@ -514,7 +514,7 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: if ':' in host: # type: ignore comps = host.rsplit(':', 1) # type: ignore host = comps[0] - port = comps[1] + port = int(comps[1]) if comps[1] else port # Handle URL maps, # e.g. "agents/my-agent/.well-known/agent-card.json" diff --git a/src/a2a/server/apps/rest/rest_adapter.py b/src/a2a/server/apps/rest/rest_adapter.py index 49db7bf7a..95a40fc2e 100644 --- a/src/a2a/server/apps/rest/rest_adapter.py +++ b/src/a2a/server/apps/rest/rest_adapter.py @@ -205,8 +205,8 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: if 'X-Forwarded-Host' in request.headers: host = request.headers['X-Forwarded-Host'] else: - host = request.url.hostname - port = request.url.port + host = request.url.hostname or rpc_url.hostname or 'localhost' + port = request.url.port or rpc_url.port if 'X-Forwarded-Proto' in request.headers: scheme = request.headers['X-Forwarded-Proto'] @@ -218,7 +218,7 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: if ':' in host: # type: ignore comps = host.rsplit(':', 1) # type: ignore host = comps[0] - port = comps[1] + port = int(comps[1]) if comps[1] else port # Handle URL maps, # e.g. "agents/my-agent/.well-known/agent-card.json" From a6e11ca04ec8eadad05d558f235002c4e758bde6 Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Wed, 20 Aug 2025 14:54:43 -0700 Subject: [PATCH 08/10] port fix --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 2 +- src/a2a/server/apps/rest/rest_adapter.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index 9358be718..038e099c5 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -502,7 +502,7 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: host = request.headers['X-Forwarded-Host'] else: host = request.url.hostname or rpc_url.hostname or 'localhost' - port = request.url.port or rpc_url.port + port = request.url.port if 'X-Forwarded-Proto' in request.headers: scheme = request.headers['X-Forwarded-Proto'] diff --git a/src/a2a/server/apps/rest/rest_adapter.py b/src/a2a/server/apps/rest/rest_adapter.py index 95a40fc2e..97acf1de2 100644 --- a/src/a2a/server/apps/rest/rest_adapter.py +++ b/src/a2a/server/apps/rest/rest_adapter.py @@ -206,7 +206,7 @@ def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: host = request.headers['X-Forwarded-Host'] else: host = request.url.hostname or rpc_url.hostname or 'localhost' - port = request.url.port or rpc_url.port + port = request.url.port if 'X-Forwarded-Proto' in request.headers: scheme = request.headers['X-Forwarded-Proto'] From 1024cae85b7aad96fec342168c946198cb48f159 Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Thu, 21 Aug 2025 13:34:40 -0700 Subject: [PATCH 09/10] Extracting _modify_rpc_url into a update_card_rpc_url_from_request util function --- src/a2a/server/apps/jsonrpc/jsonrpc_app.py | 60 ++--------------- src/a2a/server/apps/rest/rest_adapter.py | 59 +---------------- src/a2a/server/request_utils.py | 75 ++++++++++++++++++++++ 3 files changed, 82 insertions(+), 112 deletions(-) create mode 100644 src/a2a/server/request_utils.py diff --git a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py index 0de504f98..b84128cdc 100644 --- a/src/a2a/server/apps/jsonrpc/jsonrpc_app.py +++ b/src/a2a/server/apps/jsonrpc/jsonrpc_app.py @@ -18,6 +18,7 @@ from a2a.server.context import ServerCallContext from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler from a2a.server.request_handlers.request_handler import RequestHandler +from a2a.server.request_utils import update_card_rpc_url_from_request from a2a.types import ( A2AError, A2ARequest, @@ -58,7 +59,6 @@ from sse_starlette.sse import EventSourceResponse from starlette.applications import Starlette from starlette.authentication import BaseUser - from starlette.datastructures import URL from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -71,7 +71,6 @@ from sse_starlette.sse import EventSourceResponse from starlette.applications import Starlette from starlette.authentication import BaseUser - from starlette.datastructures import URL from starlette.exceptions import HTTPException from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -89,6 +88,7 @@ Request = Any JSONResponse = Any Response = Any + URL = Any HTTP_413_REQUEST_ENTITY_TOO_LARGE = Any @@ -492,58 +492,6 @@ async def event_generator( headers=headers, ) - def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: - """Modifies Agent's RPC URL based on the AgentCard request. - - Args: - agent_card (AgentCard): Original AgentCard - request (Request): AgentCard request - """ - rpc_url = URL(agent_card.url) - rpc_path = rpc_url.path - port = None - if 'X-Forwarded-Host' in request.headers: - host = request.headers['X-Forwarded-Host'] - else: - host = request.url.hostname or rpc_url.hostname or 'localhost' - port = request.url.port - - if 'X-Forwarded-Proto' in request.headers: - scheme = request.headers['X-Forwarded-Proto'] - port = None - else: - scheme = request.url.scheme - if not scheme: - scheme = 'http' - if ':' in host: # type: ignore - comps = host.rsplit(':', 1) # type: ignore - host = comps[0] - port = int(comps[1]) if comps[1] else port - - # Handle URL maps, - # e.g. "agents/my-agent/.well-known/agent-card.json" - if 'X-Forwarded-Path' in request.headers: - forwarded_path = request.headers['X-Forwarded-Path'].strip() - if ( - forwarded_path - and request.url.path != forwarded_path - and forwarded_path.endswith(request.url.path) - ): - # "agents/my-agent" for "agents/my-agent/.well-known/agent-card.json" - extra_path = forwarded_path[: -len(request.url.path)] - new_path = extra_path + rpc_path - # If original path was just "/", - # we remove trailing "/" in the extended one - if len(new_path) > 1 and rpc_path == '/': - new_path = new_path.rstrip('/') - rpc_path = new_path - - agent_card.url = str( - rpc_url.replace( - hostname=host, port=port, scheme=scheme, path=rpc_path - ) - ) - async def _handle_get_agent_card(self, request: Request) -> JSONResponse: """Handles GET requests for the agent card endpoint. @@ -567,7 +515,7 @@ async def _handle_get_agent_card(self, request: Request) -> JSONResponse: card_to_serve = self.card_modifier(card_to_serve) # If agent's RPC URL was not modified, we build it dynamically. if rpc_url == card_to_serve.url: - self._modify_rpc_url(card_to_serve, request) + update_card_rpc_url_from_request(card_to_serve, request) return JSONResponse( card_to_serve.model_dump( @@ -602,7 +550,7 @@ async def _handle_get_authenticated_extended_agent_card( if card_to_serve: # If agent's RPC URL was not modified, we build it dynamically. if rpc_url == card_to_serve.url: - self._modify_rpc_url(card_to_serve, request) + update_card_rpc_url_from_request(card_to_serve, request) return JSONResponse( card_to_serve.model_dump( exclude_none=True, diff --git a/src/a2a/server/apps/rest/rest_adapter.py b/src/a2a/server/apps/rest/rest_adapter.py index 97acf1de2..70e7b379f 100644 --- a/src/a2a/server/apps/rest/rest_adapter.py +++ b/src/a2a/server/apps/rest/rest_adapter.py @@ -7,7 +7,6 @@ if TYPE_CHECKING: from sse_starlette.sse import EventSourceResponse - from starlette.datastructures import URL from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -16,7 +15,6 @@ else: try: from sse_starlette.sse import EventSourceResponse - from starlette.datastructures import URL from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -36,6 +34,7 @@ from a2a.server.context import ServerCallContext from a2a.server.request_handlers.request_handler import RequestHandler from a2a.server.request_handlers.rest_handler import RESTHandler +from a2a.server.request_utils import update_card_rpc_url_from_request from a2a.types import AgentCard, AuthenticatedExtendedCardNotConfiguredError from a2a.utils.error_handlers import ( rest_error_handler, @@ -151,7 +150,7 @@ async def handle_get_agent_card( if self.card_modifier: card_to_serve = self.card_modifier(card_to_serve) if rpc_url == card_to_serve.url: - self._modify_rpc_url(card_to_serve, request) + update_card_rpc_url_from_request(card_to_serve, request) return card_to_serve.model_dump(mode='json', exclude_none=True) @@ -188,62 +187,10 @@ async def handle_authenticated_agent_card( base_card = card_to_serve if card_to_serve else self.agent_card card_to_serve = self.extended_card_modifier(base_card, context) if rpc_url == card_to_serve.url: - self._modify_rpc_url(card_to_serve, request) + update_card_rpc_url_from_request(card_to_serve, request) return card_to_serve.model_dump(mode='json', exclude_none=True) - def _modify_rpc_url(self, agent_card: AgentCard, request: Request) -> None: - """Modifies Agent's RPC URL based on the AgentCard request. - - Args: - agent_card (AgentCard): Original AgentCard - request (Request): AgentCard request - """ - rpc_url = URL(agent_card.url) - rpc_path = rpc_url.path - port = None - if 'X-Forwarded-Host' in request.headers: - host = request.headers['X-Forwarded-Host'] - else: - host = request.url.hostname or rpc_url.hostname or 'localhost' - port = request.url.port - - if 'X-Forwarded-Proto' in request.headers: - scheme = request.headers['X-Forwarded-Proto'] - port = None - else: - scheme = request.url.scheme - if not scheme: - scheme = 'http' - if ':' in host: # type: ignore - comps = host.rsplit(':', 1) # type: ignore - host = comps[0] - port = int(comps[1]) if comps[1] else port - - # Handle URL maps, - # e.g. "agents/my-agent/.well-known/agent-card.json" - if 'X-Forwarded-Path' in request.headers: - forwarded_path = request.headers['X-Forwarded-Path'].strip() - if ( - forwarded_path - and request.url.path != forwarded_path - and forwarded_path.endswith(request.url.path) - ): - # "agents/my-agent" for "agents/my-agent/.well-known/agent-card.json" - extra_path = forwarded_path[: -len(request.url.path)] - new_path = extra_path + rpc_path - # If original path was just "/", - # we remove trailing "/" in the extended one - if len(new_path) > 1 and rpc_path == '/': - new_path = new_path.rstrip('/') - rpc_path = new_path - - agent_card.url = str( - rpc_url.replace( - hostname=host, port=port, scheme=scheme, path=rpc_path - ) - ) - def routes(self) -> dict[tuple[str, str], Callable[[Request], Any]]: """Constructs a dictionary of API routes and their corresponding handlers. diff --git a/src/a2a/server/request_utils.py b/src/a2a/server/request_utils.py new file mode 100644 index 000000000..12073c7b9 --- /dev/null +++ b/src/a2a/server/request_utils.py @@ -0,0 +1,75 @@ +from typing import TYPE_CHECKING, Any + +from a2a.types import AgentCard + +if TYPE_CHECKING: + from starlette.datastructures import URL + from starlette.requests import Request + + _package_starlette_installed = True +else: + try: + from starlette.datastructures import URL + from starlette.requests import Request + + _package_starlette_installed = True + except ImportError: + _package_starlette_installed = False + URL = Any + Request = Any + + +def update_card_rpc_url_from_request( + agent_card: AgentCard, + request: Request + ) -> None: + """Modifies Agent's RPC URL based on the AgentCard request. + + Args: + agent_card (AgentCard): Original AgentCard + request (Request): AgentCard request + """ + rpc_url = URL(agent_card.url) + rpc_path = rpc_url.path + port = None + if 'X-Forwarded-Host' in request.headers: + host = request.headers['X-Forwarded-Host'] + else: + host = request.url.hostname or rpc_url.hostname or 'localhost' + port = request.url.port + + if 'X-Forwarded-Proto' in request.headers: + scheme = request.headers['X-Forwarded-Proto'] + port = None + else: + scheme = request.url.scheme + if not scheme: + scheme = 'http' + if ':' in host: # type: ignore + comps = host.rsplit(':', 1) # type: ignore + host = comps[0] + port = int(comps[1]) if comps[1] else port + + # Handle URL maps, + # e.g. "agents/my-agent/.well-known/agent-card.json" + if 'X-Forwarded-Path' in request.headers: + forwarded_path = request.headers['X-Forwarded-Path'].strip() + if ( + forwarded_path + and request.url.path != forwarded_path + and forwarded_path.endswith(request.url.path) + ): + # "agents/my-agent" for "agents/my-agent/.well-known/agent-card.json" + extra_path = forwarded_path[: -len(request.url.path)] + new_path = extra_path + rpc_path + # If original path was just "/", + # we remove trailing "/" in the extended one + if len(new_path) > 1 and rpc_path == '/': + new_path = new_path.rstrip('/') + rpc_path = new_path + + agent_card.url = str( + rpc_url.replace( + hostname=host, port=port, scheme=scheme, path=rpc_path + ) + ) From 9d6322367430a2947a0dd7f9f9573071ad296f20 Mon Sep 17 00:00:00 2001 From: Vlad Kolesnikov Date: Thu, 21 Aug 2025 14:29:01 -0700 Subject: [PATCH 10/10] formatting --- src/a2a/server/request_utils.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/a2a/server/request_utils.py b/src/a2a/server/request_utils.py index 12073c7b9..cc7f9f05f 100644 --- a/src/a2a/server/request_utils.py +++ b/src/a2a/server/request_utils.py @@ -2,6 +2,7 @@ from a2a.types import AgentCard + if TYPE_CHECKING: from starlette.datastructures import URL from starlette.requests import Request @@ -20,9 +21,8 @@ def update_card_rpc_url_from_request( - agent_card: AgentCard, - request: Request - ) -> None: + agent_card: AgentCard, request: Request +) -> None: """Modifies Agent's RPC URL based on the AgentCard request. Args: @@ -69,7 +69,5 @@ def update_card_rpc_url_from_request( rpc_path = new_path agent_card.url = str( - rpc_url.replace( - hostname=host, port=port, scheme=scheme, path=rpc_path - ) + rpc_url.replace(hostname=host, port=port, scheme=scheme, path=rpc_path) )