Skip to content

serve(transport="both") + callable public_url crashes at startup: 'function' object has no attribute 'router' #676

@bokelley

Description

@bokelley

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:

  1. 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.

  2. 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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions