From 216ff52d0e85cb72226ee0e4785e60566b622475 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 11 May 2026 16:59:29 -0400 Subject: [PATCH] fix(server): preserve Starlette lifespan when public_url is callable create_a2a_server returned a raw ASGI callable when public_url was a PublicUrlResolver, so the unified-transport composer's a2a_inner.router.lifespan_context access raised AttributeError at startup and the process exited 0 with no requests served. Install the per-request card intercept as a Starlette middleware via add_middleware so the returned object stays a Starlette app with .router intact. The static and None public_url paths are unchanged. Fixes #676 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adcp/server/a2a_server.py | 112 ++++++++++++++++++++-------------- tests/test_unified_mcp_a2a.py | 43 +++++++++++++ 2 files changed, 108 insertions(+), 47 deletions(-) diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index f7c5dd3c..7bfe29b1 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -810,50 +810,63 @@ def _validate_card_url(url: str) -> str: return url -def _wrap_with_per_request_card( - inner: Any, - *, - resolver: PublicUrlResolver, - handler: ADCPHandler[Any], - name: str, - port: int, - description: str | None, - version: str, - extra_skills: list[pb.AgentSkill] | None, - advertise_all: bool, - push_notifications_supported: bool, - auth: BearerTokenAuth | None, -) -> Any: - """Wrap an ASGI app to serve agent-card endpoints per-request. +_CARD_PATHS: frozenset[str] = frozenset({"/.well-known/agent-card.json", "/.well-known/agent.json"}) + + +class _PerRequestCardMiddleware: + """ASGI middleware that serves agent-card endpoints per-request. Intercepts GET ``/.well-known/agent-card.json`` and - ``/.well-known/agent.json``; all other requests pass through to the - inner app unchanged. + ``/.well-known/agent.json``; all other scopes (including + ``lifespan``) pass through unchanged. - Used when :func:`create_a2a_server` receives a - :data:`PublicUrlResolver` callable — the a2a-sdk's - ``create_agent_card_routes`` bakes the card at construction time - and cannot surface per-request context. + Installed via :meth:`starlette.applications.Starlette.add_middleware` + so the wrapped object remains a Starlette app — its ``.router`` + stays reachable for lifespan composition in + :func:`adcp.server.serve._serve_mcp_and_a2a`. """ - import inspect - - from a2a.server.routes.agent_card_routes import agent_card_to_dict # type: ignore[attr-defined] - from starlette.requests import Request - from starlette.responses import JSONResponse - _card_paths: frozenset[str] = frozenset( - {"/.well-known/agent-card.json", "/.well-known/agent.json"} - ) + def __init__( + self, + app: Any, + *, + resolver: PublicUrlResolver, + handler: ADCPHandler[Any], + name: str, + port: int, + description: str | None, + version: str, + extra_skills: list[pb.AgentSkill] | None, + advertise_all: bool, + push_notifications_supported: bool, + auth: BearerTokenAuth | None, + ) -> None: + self.app = app + self.resolver = resolver + self.handler = handler + self.name = name + self.port = port + self.description = description + self.version = version + self.extra_skills = extra_skills + self.advertise_all = advertise_all + self.push_notifications_supported = push_notifications_supported + self.auth = auth + + async def __call__(self, scope: Any, receive: Any, send: Any) -> None: + import inspect + + from starlette.requests import Request + from starlette.responses import JSONResponse - async def _middleware(scope: Any, receive: Any, send: Any) -> None: if ( scope.get("type") == "http" - and scope.get("path") in _card_paths + and scope.get("path") in _CARD_PATHS and scope.get("method") == "GET" ): request = Request(scope, receive) try: - raw_url: str | Awaitable[str] = resolver(request) + raw_url: str | Awaitable[str] = self.resolver(request) if inspect.isawaitable(raw_url): raw_url = await raw_url assert isinstance(raw_url, str) @@ -866,23 +879,24 @@ async def _middleware(scope: Any, receive: Any, send: Any) -> None: await error_response(scope, receive, send) return card = _build_agent_card( - handler, - name=name, - port=port, - description=description, - version=version, - extra_skills=extra_skills, - advertise_all=advertise_all, - push_notifications_supported=push_notifications_supported, - auth=auth, + self.handler, + name=self.name, + port=self.port, + description=self.description, + version=self.version, + extra_skills=self.extra_skills, + advertise_all=self.advertise_all, + push_notifications_supported=self.push_notifications_supported, + auth=self.auth, public_url=url, ) + from a2a.server.routes import agent_card_routes as _card_routes_mod + + agent_card_to_dict = _card_routes_mod.agent_card_to_dict # type: ignore[attr-defined] card_response: Any = JSONResponse(agent_card_to_dict(card)) await card_response(scope, receive, send) return - await inner(scope, receive, send) - - return _middleware + await self.app(scope, receive, send) def create_a2a_server( @@ -1085,7 +1099,7 @@ def agent_card_url(request: Request) -> str: # DefaultRequestHandler's internal GetAgentCard RPC (buyers probe # /.well-known/agent-card.json directly; the RPC fallback is rarely # used). The well-known endpoints are served by - # _wrap_with_per_request_card which builds a fresh card per GET. + # _PerRequestCardMiddleware which builds a fresh card per GET. fallback_card = _build_agent_card( handler, name=name, @@ -1109,9 +1123,13 @@ def agent_card_url(request: Request) -> str: ) jsonrpc_kwargs["request_handler"] = request_handler routes = list(create_jsonrpc_routes(**jsonrpc_kwargs)) + # Install the per-request card intercept via ``add_middleware`` + # so ``app`` stays a Starlette instance — the unified-transport + # lifespan composer in ``serve._serve_mcp_and_a2a`` reaches + # ``a2a_inner.router.lifespan_context`` on this object. app = Starlette(routes=routes) - app = _wrap_with_per_request_card( - app, + app.add_middleware( + _PerRequestCardMiddleware, resolver=resolved_public_url, handler=handler, name=name, diff --git a/tests/test_unified_mcp_a2a.py b/tests/test_unified_mcp_a2a.py index ed58a952..91a8899b 100644 --- a/tests/test_unified_mcp_a2a.py +++ b/tests/test_unified_mcp_a2a.py @@ -155,6 +155,49 @@ def test_unified_app_builds_end_to_end() -> None: assert callable(app) +# ----- Lifespan composition with callable public_url ------------------- + + +def test_unified_app_starts_with_callable_public_url() -> None: + """Regression for #676: ``serve(transport="both", public_url=)`` + must complete lifespan startup. Previously ``create_a2a_server`` + returned a raw ASGI callable when ``public_url`` was a + ``PublicUrlResolver``, and the lifespan composer's + ``a2a_inner.router.lifespan_context(a2a_inner)`` access raised + ``AttributeError: 'function' object has no attribute 'router'`` + at startup — the process exited 0 with no requests served.""" + + def resolver(request) -> str: # type: ignore[no-untyped-def] + host = request.headers.get("host", "localhost") + return f"https://{host}/" + + app = _build_mcp_and_a2a_app( + _UnifiedTestHandler(), + name="unified-callable-url", + port=3001, + host="127.0.0.1", + instructions=None, + test_controller=None, + public_url=resolver, + ) + # ``with TestClient(app)`` enters lifespan; the bug surfaced + # here, before any request. A successful GET to the agent-card + # endpoint additionally confirms the per-request middleware + # still serves the well-known path after the refactor. + with TestClient(app) as client: + resp = client.get( + "/.well-known/agent-card.json", + headers={"host": "tenant-a.example.com"}, + ) + assert resp.status_code == 200 + body = resp.json() + interfaces = body.get("supportedInterfaces") or body.get("supported_interfaces", []) + urls = [iface.get("url", "") for iface in interfaces] + assert any( + "tenant-a.example.com" in u for u in urls + ), f"resolver URL not surfaced in card; got {urls}" + + # ----- Public surface: serve() validation -------------------------------