11import json
22import logging
3+ import posixpath
34import traceback
45
56from abc import ABC , abstractmethod
67from collections .abc import AsyncGenerator
78from typing import Any
9+ from urllib .parse import urlparse , urlunparse
810
911from pydantic import ValidationError
1012from sse_starlette .sse import EventSourceResponse
1113from starlette .applications import Starlette
1214from starlette .requests import Request
1315from 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
1619from a2a .server .context import ServerCallContext
1720from a2a .server .request_handlers .jsonrpc_handler import JSONRPCHandler
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