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 ,
@@ -472,8 +478,8 @@ class A2AStarletteRouteBuilder:
472478
473479 Note:
474480 As of 2025-05-24, this class is almost functionally equivalent to
475- ` A2AStarletteApplication` , except that its ` build()` method is focused
476- on building Starlette ` Route` objects rather than constructing a full
481+ A2AStarletteApplication, except that its build() method is focused
482+ on building Starlette Route objects rather than constructing a full
477483 Starlette application.
478484 """
479485
@@ -482,6 +488,9 @@ def __init__(
482488 agent_card : AgentCard ,
483489 http_handler : RequestHandler ,
484490 extended_agent_card : AgentCard | None = None ,
491+ agent_card_path : str = '/.well-known/agent.json' ,
492+ extended_agent_card_path : str = '/agent/authenticatedExtendedCard' ,
493+ rpc_path : str = '/' ,
485494 context_builder : CallContextBuilder | None = None ,
486495 ):
487496 """Initializes the A2AStarletteRouter.
@@ -492,6 +501,9 @@ def __init__(
492501 requests via http.
493502 extended_agent_card: An optional, distinct AgentCard to be served
494503 at the authenticated extended card endpoint.
504+ agent_card_path: The URL path for the agent card endpoint.
505+ rpc_path: The URL path for the A2A JSON-RPC endpoint (POST requests).
506+ extended_agent_card_path: The URL path for the authenticated extended agent card endpoint.
495507 context_builder: The CallContextBuilder used to construct the
496508 ServerCallContext passed to the http_handler. If None, no
497509 ServerCallContext is passed.
@@ -508,6 +520,9 @@ def __init__(
508520 logger .error (
509521 'AgentCard.supportsAuthenticatedExtendedCard is True, but no extended_agent_card was provided. The /agent/authenticatedExtendedCard endpoint will return 404.'
510522 )
523+ self .agent_card_path = agent_card_path
524+ self .extended_agent_card_path = extended_agent_card_path
525+ self .rpc_path = rpc_path
511526 self ._context_builder = context_builder
512527
513528 def _generate_error_response (
@@ -774,31 +789,21 @@ async def _handle_get_authenticated_extended_agent_card(
774789 status_code = 404 ,
775790 )
776791
777- def build (
778- self ,
779- agent_card_path : str = '/.well-known/agent.json' ,
780- extended_agent_card_path : str = '/agent/authenticatedExtendedCard' ,
781- rpc_path : str = '/' ,
782- ) -> list [Route ]:
792+ def build (self ) -> list [Route ]:
783793 """Returns the Starlette Routes for handling A2A requests.
784794
785- Args:
786- agent_card_path: The URL path for the agent card endpoint.
787- rpc_path: The URL path for the A2A JSON-RPC endpoint (POST requests).
788- extended_agent_card_path: The URL path for the authenticated extended agent card endpoint.
789-
790795 Returns:
791796 A list of Starlette Route objects.
792797 """
793798 routes = [
794799 Route (
795- rpc_path ,
800+ self . rpc_path ,
796801 self ._handle_requests ,
797802 methods = ['POST' ],
798803 name = 'a2a_handler' ,
799804 ),
800805 Route (
801- agent_card_path ,
806+ self . agent_card_path ,
802807 self ._handle_get_agent_card ,
803808 methods = ['GET' ],
804809 name = 'agent_card' ,
@@ -807,10 +812,140 @@ def build(
807812 if self .agent_card .supportsAuthenticatedExtendedCard :
808813 routes .append (
809814 Route (
810- extended_agent_card_path ,
815+ self . extended_agent_card_path ,
811816 self ._handle_get_authenticated_extended_agent_card ,
812817 methods = ['GET' ],
813818 name = 'authenticated_extended_agent_card' ,
814819 )
815820 )
816821 return routes
822+
823+
824+ class A2AStarletteBuilder :
825+ """Builder class for assembling a Starlette application with A2A protocol routes.
826+
827+ This class enables mounting multiple A2AStarletteRouteBuilder instances under
828+ specific paths and generates a complete Starlette application that serves them.
829+ It also collects AgentLinkContext entries and exposes them as an AgentCatalog
830+ document at the standard path /.well-known/api-catalog.json.
831+ """
832+
833+ def __init__ (self ):
834+ """Initializes an empty A2AStarletteBuilder instance.
835+
836+ This sets up the internal structure to hold multiple mounted A2A route groups
837+ and the corresponding AgentLinkContext entries for inclusion in the Agent Catalog.
838+
839+ Attributes:
840+ _mounts: A list of Starlette Mount objects representing route groups
841+ mounted at specific paths.
842+ _catalog_links: A list of AgentLinkContext instances used to generate
843+ the Agent Catalog served at /.well-known/api-catalog.json.
844+ """
845+ self ._mounts : list [Mount ] = []
846+ self ._catalog_links : list [AgentLinkContext ] = []
847+
848+ @staticmethod
849+ def _join_url (base : str , * paths : str ) -> str :
850+ """Joins a base URL with one or more URL path fragments into a normalized absolute URL.
851+
852+ This method ensures that redundant slashes are removed between path segments,
853+ and that the resulting URL is correctly formatted.
854+
855+ Args:
856+ base: The base URL.
857+ *paths: One or more URL fragments to append to the base path.
858+
859+ Returns:
860+ A well-formed absolute URL with the joined path components.
861+ """
862+ parsed = urlparse (base )
863+ clean_paths = [p .strip ('/' ) for p in paths ]
864+ joined_path = posixpath .join (parsed .path .rstrip ('/' ), * clean_paths )
865+ return urlunparse (parsed ._replace (path = '/' + joined_path ))
866+
867+ async def _handle_get_api_catalog (self , request : Request ) -> JSONResponse :
868+ """Handles GET requests for the AgentCatalog endpoint.
869+
870+ Args:
871+ request: The incoming Starlette Request object.
872+
873+ Returns:
874+ A JSONResponse containing the AgentCatalog data.
875+ """
876+ catalog = AgentCatalog (links = self ._catalog_links )
877+ return JSONResponse (catalog .model_dump (mode = 'json' , exclude_none = True ))
878+
879+ def mount (
880+ self ,
881+ path : str ,
882+ route_builder : A2AStarletteRouteBuilder ,
883+ ) -> Self :
884+ """Mounts routes generated by the given builder and adds metadata to the agent catalog.
885+
886+ Raises:
887+ ValueError: If a mount for the given path already exists.
888+ """
889+ if any (
890+ posixpath .normpath (mount .path ) == posixpath .normpath (path )
891+ for mount in self ._mounts
892+ ):
893+ raise ValueError (f'A mount for path "{ path } " already exists.' )
894+ if (
895+ route_builder .extended_agent_card is not None
896+ and route_builder .agent_card .url
897+ != route_builder .extended_agent_card .url
898+ ):
899+ raise ValueError (
900+ 'agent_card.url and extended_agent_card.url must be the same '
901+ 'if extended_agent_card is provided'
902+ )
903+ routes = route_builder .build ()
904+ self ._mounts .append (Mount (path , routes = routes ))
905+ anchor = _join_url (
906+ route_builder .agent_card .url , path , route_builder .rpc_path
907+ )
908+ describedby = [
909+ AgentLinkTarget (
910+ href = _join_url (
911+ route_builder .agent_card .url ,
912+ path ,
913+ route_builder .agent_card_path ,
914+ )
915+ )
916+ ]
917+ if (
918+ route_builder .extended_agent_card is not None
919+ and route_builder .agent_card .agent_card .supportsAuthenticatedExtendedCard
920+ ):
921+ describedby .append (
922+ AgentLinkTarget (
923+ href = _join_url (
924+ route_builder .extended_agent_card .url ,
925+ path ,
926+ route_builder .extended_agent_card_path ,
927+ )
928+ )
929+ )
930+ self ._catalog_links .append (
931+ AgentLinkContext (
932+ anchor = anchor ,
933+ describedby = describedby ,
934+ )
935+ )
936+ return self
937+
938+ def build (self , ** kwargs : Any ) -> Starlette :
939+ """Builds and returns a Starlette application."""
940+ catalog_route = Route (
941+ '/.well-known/api-catalog.json' ,
942+ endpoint = self ._handle_get_api_catalog ,
943+ methods = ['GET' ],
944+ name = 'api_catalog' ,
945+ )
946+ routes = [* self ._mounts , catalog_route ]
947+ if 'routes' in kwargs :
948+ kwargs ['routes' ].extend (routes )
949+ else :
950+ kwargs ['routes' ] = routes
951+ return Starlette (** kwargs )
0 commit comments