Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/adcp/server/a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -882,6 +882,9 @@ async def _middleware(scope: Any, receive: Any, send: Any) -> None:
return
await inner(scope, receive, send)

# Note: create_a2a_server attaches ._starlette_app = inner on the returned
# function so _build_mcp_and_a2a_app's lifespan composer can reach inner's
# router without exposing .router on a plain function.
return _middleware


Expand Down Expand Up @@ -1109,9 +1112,9 @@ def agent_card_url(request: Request) -> str:
)
jsonrpc_kwargs["request_handler"] = request_handler
routes = list(create_jsonrpc_routes(**jsonrpc_kwargs))
app = Starlette(routes=routes)
starlette_app = Starlette(routes=routes)
app = _wrap_with_per_request_card(
app,
starlette_app,
resolver=resolved_public_url,
handler=handler,
name=name,
Expand All @@ -1123,6 +1126,11 @@ def agent_card_url(request: Request) -> str:
push_notifications_supported=_push_supported,
auth=auth,
)
# Back-reference to the inner Starlette app used by
# _build_mcp_and_a2a_app's lifespan composer so it can reach
# .router.lifespan_context without exposing .router on a plain
# async function. See serve.py:_composed_lifespan.
app._starlette_app = starlette_app
else:
# Static card path: existing behaviour — card built once at
# server init and served unchanged on every card request.
Expand Down
8 changes: 7 additions & 1 deletion src/adcp/server/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -1701,8 +1701,14 @@ def _build_mcp_and_a2a_app(
# both initializers run before any request lands.
@contextlib.asynccontextmanager
async def _composed_lifespan(_app): # type: ignore[no-untyped-def]
# When a2a_inner is the _wrap_with_per_request_card middleware wrapper
# (callable public_url path), it carries a ._starlette_app back-reference
# to the inner Starlette app whose router holds the lifespan handlers.
# Fall back to a2a_inner itself for the static/None path where it IS a
# Starlette app already.
a2a_lifespan_src = getattr(a2a_inner, "_starlette_app", a2a_inner)
async with mcp_inner.router.lifespan_context(mcp_inner):
async with a2a_inner.router.lifespan_context(a2a_inner):
async with a2a_lifespan_src.router.lifespan_context(a2a_lifespan_src):
yield

parent = Starlette(lifespan=_composed_lifespan)
Expand Down
34 changes: 34 additions & 0 deletions tests/test_a2a_public_url_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,3 +253,37 @@ def test_public_url_resolver_is_exported() -> None:
from adcp.server import PublicUrlResolver as ImportedResolver # noqa: N814

assert ImportedResolver is PublicUrlResolver


# ---------------------------------------------------------------------------
# Regression: callable public_url + transport="both" (#676)
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_callable_public_url_transport_both_no_startup_crash() -> None:
"""Regression for #676: serve(transport="both", public_url=callable) must
not crash at lifespan startup with AttributeError: 'function' object has
no attribute 'router'.

The bug: create_a2a_server with a callable public_url returned a plain
ASGI middleware function, and _build_mcp_and_a2a_app's lifespan composer
tried to call .router.lifespan_context() on it.
"""
from adcp.server.serve import _build_mcp_and_a2a_app

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(
_OkHandler(),
name="test-agent",
port=3001,
host="127.0.0.1",
instructions=None,
test_controller=None,
public_url=resolver,
)
async with LifespanManager(app):
pass # must not raise AttributeError
Loading