Skip to content

Commit 0f0f931

Browse files
committed
feat: introduce A2AStarletteBuilder to construct routes and serve api-catalog.json
Signed-off-by: Shingo OKAWA <[email protected]>
1 parent 0a33497 commit 0f0f931

File tree

1 file changed

+152
-17
lines changed

1 file changed

+152
-17
lines changed

src/a2a/server/apps/starlette_app.py

Lines changed: 152 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
import json
22
import logging
3+
import posixpath
34
import traceback
45

56
from abc import ABC, abstractmethod
67
from collections.abc import AsyncGenerator
78
from typing import Any
9+
from urllib.parse import urlparse, urlunparse
810

911
from pydantic import ValidationError
1012
from sse_starlette.sse import EventSourceResponse
1113
from starlette.applications import Starlette
1214
from starlette.requests import Request
1315
from starlette.responses import JSONResponse, Response
14-
from starlette.routing import Route
16+
from starlette.routing import Mount, Route
17+
from typing_extensions import Self
1518

1619
from a2a.server.context import ServerCallContext
1720
from a2a.server.request_handlers.jsonrpc_handler import JSONRPCHandler
@@ -20,6 +23,9 @@
2023
A2AError,
2124
A2ARequest,
2225
AgentCard,
26+
AgentCatalog,
27+
AgentLinkContext,
28+
AgentLinkTarget,
2329
CancelTaskRequest,
2430
GetTaskPushNotificationConfigRequest,
2531
GetTaskRequest,
@@ -445,8 +451,8 @@ class A2AStarletteRouteBuilder:
445451
446452
Note:
447453
As of 2025-05-24, this class is almost functionally equivalent to
448-
`A2AStarletteApplication`, except that its `build()` method is focused
449-
on building Starlette `Route` objects rather than constructing a full
454+
A2AStarletteApplication, except that its build() method is focused
455+
on building Starlette Route objects rather than constructing a full
450456
Starlette application.
451457
"""
452458

@@ -455,6 +461,9 @@ def __init__(
455461
agent_card: AgentCard,
456462
http_handler: RequestHandler,
457463
extended_agent_card: AgentCard | None = None,
464+
agent_card_path: str = '/.well-known/agent.json',
465+
extended_agent_card_path: str = '/agent/authenticatedExtendedCard',
466+
rpc_path: str = '/',
458467
context_builder: CallContextBuilder | None = None,
459468
):
460469
"""Initializes the A2AStarletteRouter.
@@ -465,6 +474,9 @@ def __init__(
465474
requests via http.
466475
extended_agent_card: An optional, distinct AgentCard to be served
467476
at the authenticated extended card endpoint.
477+
agent_card_path: The URL path for the agent card endpoint.
478+
rpc_path: The URL path for the A2A JSON-RPC endpoint (POST requests).
479+
extended_agent_card_path: The URL path for the authenticated extended agent card endpoint.
468480
context_builder: The CallContextBuilder used to construct the
469481
ServerCallContext passed to the http_handler. If None, no
470482
ServerCallContext is passed.
@@ -481,6 +493,9 @@ def __init__(
481493
logger.error(
482494
'AgentCard.supportsAuthenticatedExtendedCard is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.'
483495
)
496+
self.agent_card_path = agent_card_path
497+
self.extended_agent_card_path = extended_agent_card_path
498+
self.rpc_path = rpc_path
484499
self._context_builder = context_builder
485500

486501
def _generate_error_response(
@@ -747,31 +762,21 @@ async def _handle_get_authenticated_extended_agent_card(
747762
status_code=404,
748763
)
749764

750-
def build(
751-
self,
752-
agent_card_path: str = '/.well-known/agent.json',
753-
extended_agent_card_path: str = '/agent/authenticatedExtendedCard',
754-
rpc_path: str = '/',
755-
) -> list[Route]:
765+
def build(self) -> list[Route]:
756766
"""Returns the Starlette Routes for handling A2A requests.
757767
758-
Args:
759-
agent_card_path: The URL path for the agent card endpoint.
760-
rpc_path: The URL path for the A2A JSON-RPC endpoint (POST requests).
761-
extended_agent_card_path: The URL path for the authenticated extended agent card endpoint.
762-
763768
Returns:
764769
A list of Starlette Route objects.
765770
"""
766771
routes = [
767772
Route(
768-
rpc_path,
773+
self.rpc_path,
769774
self._handle_requests,
770775
methods=['POST'],
771776
name='a2a_handler',
772777
),
773778
Route(
774-
agent_card_path,
779+
self.agent_card_path,
775780
self._handle_get_agent_card,
776781
methods=['GET'],
777782
name='agent_card',
@@ -780,10 +785,140 @@ def build(
780785
if self.agent_card.supportsAuthenticatedExtendedCard:
781786
routes.append(
782787
Route(
783-
extended_agent_card_path,
788+
self.extended_agent_card_path,
784789
self._handle_get_authenticated_extended_agent_card,
785790
methods=['GET'],
786791
name='authenticated_extended_agent_card',
787792
)
788793
)
789794
return routes
795+
796+
797+
class A2AStarletteBuilder:
798+
"""Builder class for assembling a Starlette application with A2A protocol routes.
799+
800+
This class enables mounting multiple A2AStarletteRouteBuilder instances under
801+
specific paths and generates a complete Starlette application that serves them.
802+
It also collects AgentLinkContext entries and exposes them as an AgentCatalog
803+
document at the standard path /.well-known/api-catalog.json.
804+
"""
805+
806+
def __init__(self):
807+
"""Initializes an empty A2AStarletteBuilder instance.
808+
809+
This sets up the internal structure to hold multiple mounted A2A route groups
810+
and the corresponding AgentLinkContext entries for inclusion in the Agent Catalog.
811+
812+
Attributes:
813+
_mounts: A list of Starlette Mount objects representing route groups
814+
mounted at specific paths.
815+
_catalog_links: A list of AgentLinkContext instances used to generate
816+
the Agent Catalog served at /.well-known/api-catalog.json.
817+
"""
818+
self._mounts: list[Mount] = []
819+
self._catalog_links: list[AgentLinkContext] = []
820+
821+
@staticmethod
822+
def _join_url(base: str, *paths: str) -> str:
823+
"""Joins a base URL with one or more URL path fragments into a normalized absolute URL.
824+
825+
This method ensures that redundant slashes are removed between path segments,
826+
and that the resulting URL is correctly formatted.
827+
828+
Args:
829+
base: The base URL.
830+
*paths: One or more URL fragments to append to the base path.
831+
832+
Returns:
833+
A well-formed absolute URL with the joined path components.
834+
"""
835+
parsed = urlparse(base)
836+
clean_paths = [p.strip('/') for p in paths]
837+
joined_path = posixpath.join(parsed.path.rstrip('/'), *clean_paths)
838+
return urlunparse(parsed._replace(path='/' + joined_path))
839+
840+
async def _handle_get_api_catalog(self, request: Request) -> JSONResponse:
841+
"""Handles GET requests for the AgentCatalog endpoint.
842+
843+
Args:
844+
request: The incoming Starlette Request object.
845+
846+
Returns:
847+
A JSONResponse containing the AgentCatalog data.
848+
"""
849+
catalog = AgentCatalog(links=self._catalog_links)
850+
return JSONResponse(catalog.model_dump(mode='json', exclude_none=True))
851+
852+
def mount(
853+
self,
854+
path: str,
855+
route_builder: A2AStarletteRouteBuilder,
856+
) -> Self:
857+
"""Mounts routes generated by the given builder and adds metadata to the agent catalog.
858+
859+
Raises:
860+
ValueError: If a mount for the given path already exists.
861+
"""
862+
if any(
863+
posixpath.normpath(mount.path) == posixpath.normpath(path)
864+
for mount in self._mounts
865+
):
866+
raise ValueError(f'A mount for path "{path}" already exists.')
867+
if (
868+
route_builder.extended_agent_card is not None
869+
and route_builder.agent_card.url
870+
!= route_builder.extended_agent_card.url
871+
):
872+
raise ValueError(
873+
'agent_card.url and extended_agent_card.url must be the same '
874+
'if extended_agent_card is provided'
875+
)
876+
routes = route_builder.build()
877+
self._mounts.append(Mount(path, routes=routes))
878+
anchor = _join_url(
879+
route_builder.agent_card.url, path, route_builder.rpc_path
880+
)
881+
describedby = [
882+
AgentLinkTarget(
883+
href=_join_url(
884+
route_builder.agent_card.url,
885+
path,
886+
route_builder.agent_card_path,
887+
)
888+
)
889+
]
890+
if (
891+
route_builder.extended_agent_card is not None
892+
and route_builder.agent_card.agent_card.supportsAuthenticatedExtendedCard
893+
):
894+
describedby.append(
895+
AgentLinkTarget(
896+
href=_join_url(
897+
route_builder.extended_agent_card.url,
898+
path,
899+
route_builder.extended_agent_card_path,
900+
)
901+
)
902+
)
903+
self._catalog_links.append(
904+
AgentLinkContext(
905+
anchor=anchor,
906+
describedby=describedby,
907+
)
908+
)
909+
return self
910+
911+
def build(self, **kwargs: Any) -> Starlette:
912+
"""Builds and returns a Starlette application."""
913+
catalog_route = Route(
914+
'/.well-known/api-catalog.json',
915+
endpoint=self._handle_get_api_catalog,
916+
methods=['GET'],
917+
name='api_catalog',
918+
)
919+
routes = [*self._mounts, catalog_route]
920+
if 'routes' in kwargs:
921+
kwargs['routes'].extend(routes)
922+
else:
923+
kwargs['routes'] = routes
924+
return Starlette(**kwargs)

0 commit comments

Comments
 (0)