Skip to content

Commit 216ff52

Browse files
bokelleyclaude
andcommitted
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) <noreply@anthropic.com>
1 parent abac33b commit 216ff52

2 files changed

Lines changed: 108 additions & 47 deletions

File tree

src/adcp/server/a2a_server.py

Lines changed: 65 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -810,50 +810,63 @@ def _validate_card_url(url: str) -> str:
810810
return url
811811

812812

813-
def _wrap_with_per_request_card(
814-
inner: Any,
815-
*,
816-
resolver: PublicUrlResolver,
817-
handler: ADCPHandler[Any],
818-
name: str,
819-
port: int,
820-
description: str | None,
821-
version: str,
822-
extra_skills: list[pb.AgentSkill] | None,
823-
advertise_all: bool,
824-
push_notifications_supported: bool,
825-
auth: BearerTokenAuth | None,
826-
) -> Any:
827-
"""Wrap an ASGI app to serve agent-card endpoints per-request.
813+
_CARD_PATHS: frozenset[str] = frozenset({"/.well-known/agent-card.json", "/.well-known/agent.json"})
814+
815+
816+
class _PerRequestCardMiddleware:
817+
"""ASGI middleware that serves agent-card endpoints per-request.
828818
829819
Intercepts GET ``/.well-known/agent-card.json`` and
830-
``/.well-known/agent.json``; all other requests pass through to the
831-
inner app unchanged.
820+
``/.well-known/agent.json``; all other scopes (including
821+
``lifespan``) pass through unchanged.
832822
833-
Used when :func:`create_a2a_server` receives a
834-
:data:`PublicUrlResolver` callable — the a2a-sdk's
835-
``create_agent_card_routes`` bakes the card at construction time
836-
and cannot surface per-request context.
823+
Installed via :meth:`starlette.applications.Starlette.add_middleware`
824+
so the wrapped object remains a Starlette app — its ``.router``
825+
stays reachable for lifespan composition in
826+
:func:`adcp.server.serve._serve_mcp_and_a2a`.
837827
"""
838-
import inspect
839-
840-
from a2a.server.routes.agent_card_routes import agent_card_to_dict # type: ignore[attr-defined]
841-
from starlette.requests import Request
842-
from starlette.responses import JSONResponse
843828

844-
_card_paths: frozenset[str] = frozenset(
845-
{"/.well-known/agent-card.json", "/.well-known/agent.json"}
846-
)
829+
def __init__(
830+
self,
831+
app: Any,
832+
*,
833+
resolver: PublicUrlResolver,
834+
handler: ADCPHandler[Any],
835+
name: str,
836+
port: int,
837+
description: str | None,
838+
version: str,
839+
extra_skills: list[pb.AgentSkill] | None,
840+
advertise_all: bool,
841+
push_notifications_supported: bool,
842+
auth: BearerTokenAuth | None,
843+
) -> None:
844+
self.app = app
845+
self.resolver = resolver
846+
self.handler = handler
847+
self.name = name
848+
self.port = port
849+
self.description = description
850+
self.version = version
851+
self.extra_skills = extra_skills
852+
self.advertise_all = advertise_all
853+
self.push_notifications_supported = push_notifications_supported
854+
self.auth = auth
855+
856+
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
857+
import inspect
858+
859+
from starlette.requests import Request
860+
from starlette.responses import JSONResponse
847861

848-
async def _middleware(scope: Any, receive: Any, send: Any) -> None:
849862
if (
850863
scope.get("type") == "http"
851-
and scope.get("path") in _card_paths
864+
and scope.get("path") in _CARD_PATHS
852865
and scope.get("method") == "GET"
853866
):
854867
request = Request(scope, receive)
855868
try:
856-
raw_url: str | Awaitable[str] = resolver(request)
869+
raw_url: str | Awaitable[str] = self.resolver(request)
857870
if inspect.isawaitable(raw_url):
858871
raw_url = await raw_url
859872
assert isinstance(raw_url, str)
@@ -866,23 +879,24 @@ async def _middleware(scope: Any, receive: Any, send: Any) -> None:
866879
await error_response(scope, receive, send)
867880
return
868881
card = _build_agent_card(
869-
handler,
870-
name=name,
871-
port=port,
872-
description=description,
873-
version=version,
874-
extra_skills=extra_skills,
875-
advertise_all=advertise_all,
876-
push_notifications_supported=push_notifications_supported,
877-
auth=auth,
882+
self.handler,
883+
name=self.name,
884+
port=self.port,
885+
description=self.description,
886+
version=self.version,
887+
extra_skills=self.extra_skills,
888+
advertise_all=self.advertise_all,
889+
push_notifications_supported=self.push_notifications_supported,
890+
auth=self.auth,
878891
public_url=url,
879892
)
893+
from a2a.server.routes import agent_card_routes as _card_routes_mod
894+
895+
agent_card_to_dict = _card_routes_mod.agent_card_to_dict # type: ignore[attr-defined]
880896
card_response: Any = JSONResponse(agent_card_to_dict(card))
881897
await card_response(scope, receive, send)
882898
return
883-
await inner(scope, receive, send)
884-
885-
return _middleware
899+
await self.app(scope, receive, send)
886900

887901

888902
def create_a2a_server(
@@ -1085,7 +1099,7 @@ def agent_card_url(request: Request) -> str:
10851099
# DefaultRequestHandler's internal GetAgentCard RPC (buyers probe
10861100
# /.well-known/agent-card.json directly; the RPC fallback is rarely
10871101
# used). The well-known endpoints are served by
1088-
# _wrap_with_per_request_card which builds a fresh card per GET.
1102+
# _PerRequestCardMiddleware which builds a fresh card per GET.
10891103
fallback_card = _build_agent_card(
10901104
handler,
10911105
name=name,
@@ -1109,9 +1123,13 @@ def agent_card_url(request: Request) -> str:
11091123
)
11101124
jsonrpc_kwargs["request_handler"] = request_handler
11111125
routes = list(create_jsonrpc_routes(**jsonrpc_kwargs))
1126+
# Install the per-request card intercept via ``add_middleware``
1127+
# so ``app`` stays a Starlette instance — the unified-transport
1128+
# lifespan composer in ``serve._serve_mcp_and_a2a`` reaches
1129+
# ``a2a_inner.router.lifespan_context`` on this object.
11121130
app = Starlette(routes=routes)
1113-
app = _wrap_with_per_request_card(
1114-
app,
1131+
app.add_middleware(
1132+
_PerRequestCardMiddleware,
11151133
resolver=resolved_public_url,
11161134
handler=handler,
11171135
name=name,

tests/test_unified_mcp_a2a.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,49 @@ def test_unified_app_builds_end_to_end() -> None:
155155
assert callable(app)
156156

157157

158+
# ----- Lifespan composition with callable public_url -------------------
159+
160+
161+
def test_unified_app_starts_with_callable_public_url() -> None:
162+
"""Regression for #676: ``serve(transport="both", public_url=<callable>)``
163+
must complete lifespan startup. Previously ``create_a2a_server``
164+
returned a raw ASGI callable when ``public_url`` was a
165+
``PublicUrlResolver``, and the lifespan composer's
166+
``a2a_inner.router.lifespan_context(a2a_inner)`` access raised
167+
``AttributeError: 'function' object has no attribute 'router'``
168+
at startup — the process exited 0 with no requests served."""
169+
170+
def resolver(request) -> str: # type: ignore[no-untyped-def]
171+
host = request.headers.get("host", "localhost")
172+
return f"https://{host}/"
173+
174+
app = _build_mcp_and_a2a_app(
175+
_UnifiedTestHandler(),
176+
name="unified-callable-url",
177+
port=3001,
178+
host="127.0.0.1",
179+
instructions=None,
180+
test_controller=None,
181+
public_url=resolver,
182+
)
183+
# ``with TestClient(app)`` enters lifespan; the bug surfaced
184+
# here, before any request. A successful GET to the agent-card
185+
# endpoint additionally confirms the per-request middleware
186+
# still serves the well-known path after the refactor.
187+
with TestClient(app) as client:
188+
resp = client.get(
189+
"/.well-known/agent-card.json",
190+
headers={"host": "tenant-a.example.com"},
191+
)
192+
assert resp.status_code == 200
193+
body = resp.json()
194+
interfaces = body.get("supportedInterfaces") or body.get("supported_interfaces", [])
195+
urls = [iface.get("url", "") for iface in interfaces]
196+
assert any(
197+
"tenant-a.example.com" in u for u in urls
198+
), f"resolver URL not surfaced in card; got {urls}"
199+
200+
158201
# ----- Public surface: serve() validation -------------------------------
159202

160203

0 commit comments

Comments
 (0)