Skip to content
Merged
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
112 changes: 65 additions & 47 deletions src/adcp/server/a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions tests/test_unified_mcp_a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<callable>)``
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 -------------------------------


Expand Down
Loading