Repro
def my_resolver(request):
return f"https://{request.headers.get('host')}/"
serve(handler, transport="both", public_url=my_resolver)
Observed
Process exits cleanly with code 0 at lifespan startup; uvicorn logs:
ERROR: Application startup failed. Exiting.
AttributeError: 'function' object has no attribute 'router'
Full traceback:
File "/.venv/.../adcp/server/serve.py", line 1705, in _composed_lifespan
async with a2a_inner.router.lifespan_context(a2a_inner):
^^^^^^^^^^^^^^^^
AttributeError: 'function' object has no attribute 'router'
Root cause
In _serve_mcp_and_a2a (adcp/server/serve.py), the lifespan composer at lines 1702–1706 assumes both mcp_inner and a2a_inner are Starlette apps with .router:
async def _composed_lifespan(_app):
async with mcp_inner.router.lifespan_context(mcp_inner):
async with a2a_inner.router.lifespan_context(a2a_inner):
yield
But a2a_inner is assigned from create_a2a_server(..., public_url=public_url) (line 1672). When public_url is a callable, create_a2a_server returns the _wrap_with_per_request_card middleware function (an async def _middleware(scope, receive, send): ...), not a Starlette app. The middleware has no .router.
The string and None cases pass create_a2a_server's "use the static URL or the bound localhost" branch and return a Starlette app — that's why transport="a2a" alone works (it doesn't go through _composed_lifespan) and transport="both" works with non-callable public_url.
Impact
Adopters following the docstring's per-tenant PublicUrlResolver pattern (parity with JS createTenantRegistry) on the unified MCP+A2A binary can't actually use the feature. Drove ~half a day of debugging on our side, including a CI run where every E2E and quickstart job timed out on "Server did not become healthy in time" because the container exited 0 instead of crashing loud.
Suggested fix
Either:
-
Have _wrap_with_per_request_card wrap the inner app instead of replacing it. Keep a2a_inner as the Starlette app create_a2a_server would have built with public_url=None, then layer the per-request handler as middleware. The lifespan composer keeps working unchanged.
-
Or make _composed_lifespan reach for the underlying app. When a2a_inner doesn't have .router, walk a known attribute (.app, or store the inner reference on the wrapper).
Option 1 keeps the lifespan composer trivial; option 2 is a local fix.
Workaround
Pass public_url as a string (e.g. from PUBLIC_URL env) and run your own ASGI middleware to rewrite /.well-known/agent-card.json from X-Forwarded-Host — same as the pre-5.1 pattern. We restored our 189-line AgentCardPublicUrlMiddleware for exactly this.
Environment
adcp==5.2.0
- Python 3.12
transport="both", multi-tenant subdomain deployment
Repro
Observed
Process exits cleanly with code 0 at lifespan startup; uvicorn logs:
Full traceback:
Root cause
In
_serve_mcp_and_a2a(adcp/server/serve.py), the lifespan composer at lines 1702–1706 assumes bothmcp_inneranda2a_innerare Starlette apps with.router:But
a2a_inneris assigned fromcreate_a2a_server(..., public_url=public_url)(line 1672). Whenpublic_urlis a callable,create_a2a_serverreturns the_wrap_with_per_request_cardmiddleware function (anasync def _middleware(scope, receive, send): ...), not a Starlette app. The middleware has no.router.The string and
Nonecases passcreate_a2a_server's "use the static URL or the bound localhost" branch and return a Starlette app — that's whytransport="a2a"alone works (it doesn't go through_composed_lifespan) andtransport="both"works with non-callablepublic_url.Impact
Adopters following the docstring's per-tenant
PublicUrlResolverpattern (parity with JScreateTenantRegistry) on the unified MCP+A2A binary can't actually use the feature. Drove ~half a day of debugging on our side, including a CI run where every E2E and quickstart job timed out on "Server did not become healthy in time" because the container exited 0 instead of crashing loud.Suggested fix
Either:
Have
_wrap_with_per_request_cardwrap the inner app instead of replacing it. Keepa2a_inneras the Starlette appcreate_a2a_serverwould have built withpublic_url=None, then layer the per-request handler as middleware. The lifespan composer keeps working unchanged.Or make
_composed_lifespanreach for the underlying app. Whena2a_innerdoesn't have.router, walk a known attribute (.app, or store the inner reference on the wrapper).Option 1 keeps the lifespan composer trivial; option 2 is a local fix.
Workaround
Pass
public_urlas a string (e.g. fromPUBLIC_URLenv) and run your own ASGI middleware to rewrite/.well-known/agent-card.jsonfromX-Forwarded-Host— same as the pre-5.1 pattern. We restored our 189-lineAgentCardPublicUrlMiddlewarefor exactly this.Environment
adcp==5.2.0transport="both", multi-tenant subdomain deployment