11import contextlib
22import json
33import logging
4+ import posixpath
45import traceback
56
67from abc import ABC , abstractmethod
78from collections .abc import AsyncGenerator
89from typing import Any
10+ from urllib .parse import urlparse , urlunparse
911
1012from pydantic import ValidationError
1113from sse_starlette .sse import EventSourceResponse
1214from starlette .applications import Starlette
1315from starlette .authentication import BaseUser
1416from starlette .requests import Request
1517from starlette .responses import JSONResponse , Response
16- from starlette .routing import Route
18+ from starlette .routing import Mount , Route
19+ from typing_extensions import Self
1720
1821from a2a .auth .user import UnauthenticatedUser
1922from a2a .auth .user import User as A2AUser
2427 A2AError ,
2528 A2ARequest ,
2629 AgentCard ,
30+ AgentCatalog ,
31+ AgentLinkContext ,
32+ AgentLinkTarget ,
2733 CancelTaskRequest ,
2834 GetTaskPushNotificationConfigRequest ,
2935 GetTaskRequest ,
@@ -460,8 +466,8 @@ class A2AStarletteRouteBuilder:
460466
461467 Note:
462468 As of 2025-05-24, this class is almost functionally equivalent to
463- ` A2AStarletteApplication` , except that its ` build()` method is focused
464- on building Starlette ` Route` objects rather than constructing a full
469+ A2AStarletteApplication, except that its build() method is focused
470+ on building Starlette Route objects rather than constructing a full
465471 Starlette application.
466472 """
467473
@@ -470,6 +476,9 @@ def __init__(
470476 agent_card : AgentCard ,
471477 http_handler : RequestHandler ,
472478 extended_agent_card : AgentCard | None = None ,
479+ agent_card_path : str = '/.well-known/agent.json' ,
480+ extended_agent_card_path : str = '/agent/authenticatedExtendedCard' ,
481+ rpc_path : str = '/' ,
473482 context_builder : CallContextBuilder | None = None ,
474483 ):
475484 """Initializes the A2AStarletteRouter.
@@ -480,6 +489,9 @@ def __init__(
480489 requests via http.
481490 extended_agent_card: An optional, distinct AgentCard to be served
482491 at the authenticated extended card endpoint.
492+ agent_card_path: The URL path for the agent card endpoint.
493+ rpc_path: The URL path for the A2A JSON-RPC endpoint (POST requests).
494+ extended_agent_card_path: The URL path for the authenticated extended agent card endpoint.
483495 context_builder: The CallContextBuilder used to construct the
484496 ServerCallContext passed to the http_handler. If None, no
485497 ServerCallContext is passed.
@@ -496,6 +508,9 @@ def __init__(
496508 logger .error (
497509 'AgentCard.supportsAuthenticatedExtendedCard is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.'
498510 )
511+ self .agent_card_path = agent_card_path
512+ self .extended_agent_card_path = extended_agent_card_path
513+ self .rpc_path = rpc_path
499514 self ._context_builder = context_builder
500515
501516 def _generate_error_response (
@@ -762,31 +777,21 @@ async def _handle_get_authenticated_extended_agent_card(
762777 status_code = 404 ,
763778 )
764779
765- def build (
766- self ,
767- agent_card_path : str = '/.well-known/agent.json' ,
768- extended_agent_card_path : str = '/agent/authenticatedExtendedCard' ,
769- rpc_path : str = '/' ,
770- ) -> list [Route ]:
780+ def build (self ) -> list [Route ]:
771781 """Returns the Starlette Routes for handling A2A requests.
772782
773- Args:
774- agent_card_path: The URL path for the agent card endpoint.
775- rpc_path: The URL path for the A2A JSON-RPC endpoint (POST requests).
776- extended_agent_card_path: The URL path for the authenticated extended agent card endpoint.
777-
778783 Returns:
779784 A list of Starlette Route objects.
780785 """
781786 routes = [
782787 Route (
783- rpc_path ,
788+ self . rpc_path ,
784789 self ._handle_requests ,
785790 methods = ['POST' ],
786791 name = 'a2a_handler' ,
787792 ),
788793 Route (
789- agent_card_path ,
794+ self . agent_card_path ,
790795 self ._handle_get_agent_card ,
791796 methods = ['GET' ],
792797 name = 'agent_card' ,
@@ -795,10 +800,140 @@ def build(
795800 if self .agent_card .supportsAuthenticatedExtendedCard :
796801 routes .append (
797802 Route (
798- extended_agent_card_path ,
803+ self . extended_agent_card_path ,
799804 self ._handle_get_authenticated_extended_agent_card ,
800805 methods = ['GET' ],
801806 name = 'authenticated_extended_agent_card' ,
802807 )
803808 )
804809 return routes
810+
811+
812+ class A2AStarletteBuilder :
813+ """Builder class for assembling a Starlette application with A2A protocol routes.
814+
815+ This class enables mounting multiple A2AStarletteRouteBuilder instances under
816+ specific paths and generates a complete Starlette application that serves them.
817+ It also collects AgentLinkContext entries and exposes them as an AgentCatalog
818+ document at the standard path /.well-known/api-catalog.json.
819+ """
820+
821+ def __init__ (self ):
822+ """Initializes an empty A2AStarletteBuilder instance.
823+
824+ This sets up the internal structure to hold multiple mounted A2A route groups
825+ and the corresponding AgentLinkContext entries for inclusion in the Agent Catalog.
826+
827+ Attributes:
828+ _mounts: A list of Starlette Mount objects representing route groups
829+ mounted at specific paths.
830+ _catalog_links: A list of AgentLinkContext instances used to generate
831+ the Agent Catalog served at /.well-known/api-catalog.json.
832+ """
833+ self ._mounts : list [Mount ] = []
834+ self ._catalog_links : list [AgentLinkContext ] = []
835+
836+ @staticmethod
837+ def _join_url (base : str , * paths : str ) -> str :
838+ """Joins a base URL with one or more URL path fragments into a normalized absolute URL.
839+
840+ This method ensures that redundant slashes are removed between path segments,
841+ and that the resulting URL is correctly formatted.
842+
843+ Args:
844+ base: The base URL.
845+ *paths: One or more URL fragments to append to the base path.
846+
847+ Returns:
848+ A well-formed absolute URL with the joined path components.
849+ """
850+ parsed = urlparse (base )
851+ clean_paths = [p .strip ('/' ) for p in paths ]
852+ joined_path = posixpath .join (parsed .path .rstrip ('/' ), * clean_paths )
853+ return urlunparse (parsed ._replace (path = '/' + joined_path ))
854+
855+ async def _handle_get_api_catalog (self , request : Request ) -> JSONResponse :
856+ """Handles GET requests for the AgentCatalog endpoint.
857+
858+ Args:
859+ request: The incoming Starlette Request object.
860+
861+ Returns:
862+ A JSONResponse containing the AgentCatalog data.
863+ """
864+ catalog = AgentCatalog (links = self ._catalog_links )
865+ return JSONResponse (catalog .model_dump (mode = 'json' , exclude_none = True ))
866+
867+ def mount (
868+ self ,
869+ path : str ,
870+ route_builder : A2AStarletteRouteBuilder ,
871+ ) -> Self :
872+ """Mounts routes generated by the given builder and adds metadata to the agent catalog.
873+
874+ Raises:
875+ ValueError: If a mount for the given path already exists.
876+ """
877+ if any (
878+ posixpath .normpath (mount .path ) == posixpath .normpath (path )
879+ for mount in self ._mounts
880+ ):
881+ raise ValueError (f'A mount for path "{ path } " already exists.' )
882+ if (
883+ route_builder .extended_agent_card is not None
884+ and route_builder .agent_card .url
885+ != route_builder .extended_agent_card .url
886+ ):
887+ raise ValueError (
888+ 'agent_card.url and extended_agent_card.url must be the same '
889+ 'if extended_agent_card is provided'
890+ )
891+ routes = route_builder .build ()
892+ self ._mounts .append (Mount (path , routes = routes ))
893+ anchor = _join_url (
894+ route_builder .agent_card .url , path , route_builder .rpc_path
895+ )
896+ describedby = [
897+ AgentLinkTarget (
898+ href = _join_url (
899+ route_builder .agent_card .url ,
900+ path ,
901+ route_builder .agent_card_path ,
902+ )
903+ )
904+ ]
905+ if (
906+ route_builder .extended_agent_card is not None
907+ and route_builder .agent_card .agent_card .supportsAuthenticatedExtendedCard
908+ ):
909+ describedby .append (
910+ AgentLinkTarget (
911+ href = _join_url (
912+ route_builder .extended_agent_card .url ,
913+ path ,
914+ route_builder .extended_agent_card_path ,
915+ )
916+ )
917+ )
918+ self ._catalog_links .append (
919+ AgentLinkContext (
920+ anchor = anchor ,
921+ describedby = describedby ,
922+ )
923+ )
924+ return self
925+
926+ def build (self , ** kwargs : Any ) -> Starlette :
927+ """Builds and returns a Starlette application."""
928+ catalog_route = Route (
929+ '/.well-known/api-catalog.json' ,
930+ endpoint = self ._handle_get_api_catalog ,
931+ methods = ['GET' ],
932+ name = 'api_catalog' ,
933+ )
934+ routes = [* self ._mounts , catalog_route ]
935+ if 'routes' in kwargs :
936+ kwargs ['routes' ].extend (routes )
937+ else :
938+ kwargs ['routes' ] = routes
939+ return Starlette (** kwargs )
0 commit comments