Skip to content

feat(a2a): per-request agent-card URL resolution via callable public_url#650

Merged
bokelley merged 3 commits into
mainfrom
claude/issue-647-callable-public-url
May 11, 2026
Merged

feat(a2a): per-request agent-card URL resolution via callable public_url#650
bokelley merged 3 commits into
mainfrom
claude/issue-647-callable-public-url

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

Closes #647

  • Adds PublicUrlResolver = Callable[[Any], str] type alias exported from adcp.server
  • Widens create_a2a_server(public_url=…) and serve(public_url=…) / ServeConfig.public_url to accept a callable in addition to str | None — fully backwards-compatible
  • When a callable is supplied, an ASGI middleware layer intercepts GET /.well-known/agent-card.json and GET /.well-known/agent.json per request, calls the resolver with the Starlette Request, validates the returned URL, and rebuilds the agent card on the fly — bypassing the a2a-sdk's build-time bake
  • Both sync and async resolvers are supported (inspect.iscoroutine detection + await)
  • Resolver errors and invalid URLs return {"error": "agent-card temporarily unavailable"} with HTTP 500 and a full traceback in the server log

Motivation

Multi-tenant subdomain deployments (acme.platform.io, beta.platform.io, …) share one server process. A static public_url can only name one host, so every tenant's agent card points at the same URL. The callable pattern puts host-resolution logic in adopter code (e.g. read the Host header) while keeping X-Forwarded-Host trust decisions out of the framework's unauthenticated agent-card endpoint.

Typical usage

from starlette.requests import Request
from adcp.server import serve, PublicUrlResolver

def agent_card_url(request: Request) -> str:
    host = request.headers.get("host", "localhost")
    return f"https://{host}/"

serve(handler, transport="a2a", public_url=agent_card_url)

Test plan

  • tests/test_a2a_public_url_resolver.py — 15 tests: 6 unit (_validate_card_url) + 9 integration (per-request card, multi-host, 0.3 alias, error→500, invalid URL→500, async resolver, static/None unchanged, export check)
  • ruff check src/ — all checks passed
  • mypy src/adcp/ — no issues in 2 changed source files
  • Pre-PR review: security-reviewer (no blockers — Option A / X-Forwarded-Host trust ruled out; Option B defers trust policy to adopter), dx-expert (0 blockers, 6 nits addressed), code-reviewer (2 blockers fixed: async support + test; 2 issues fixed: exc_info=True + brand names replaced)

Triage-managed PR — opened automatically by the issue-triage routine in response to issue #647. Review and merge as you would any other PR; the triage label on the issue will be updated when this PR lands.

https://claude.ai/code/session_01CTpSC8E52DSKTDFJx7Y4Gf


Generated by Claude Code

claude and others added 2 commits May 11, 2026 06:45
…ent-card URL resolution

Adds support for a `Callable[[Request], str]` in addition to the existing
static `str` for `serve(public_url=...)` and `create_a2a_server(public_url=...)`.

When a callable is supplied, an ASGI-layer intercept replaces the static
`create_agent_card_routes` for the well-known endpoints, building the card
per GET request.  The `DefaultRequestHandler` retains a localhost fallback
card for the `GetAgentCard` RPC path.

Option A (trust_forwarded_headers) was ruled out by security review:
the unauthenticated agent-card endpoint + unvalidated X-Forwarded-Host
= agent-card hijack with no clean mitigation short of an allow-list.
The callable puts the trust-policy decision in adopter code where it belongs.

Closes #647

https://claude.ai/code/session_01CTpSC8E52DSKTDFJx7Y4Gf
- Await async resolvers (inspect.iscoroutine) so async def resolvers work
- Add test_async_resolver_works integration test covering async path
- Use logger.error(..., exc_info=True) to preserve resolver tracebacks
- Use JSONResponse body for 500 errors instead of empty Response
- Consolidate serve.py type annotations to use PublicUrlResolver alias
- Replace real brand names in tests with RFC 2606 example.com hostnames

https://claude.ai/code/session_01CTpSC8E52DSKTDFJx7Y4Gf
- Type alias is now Callable[[Any], str | Awaitable[str]] so adopters
  annotating ``async def`` resolvers against the public alias type-check.
- Propagate the alias through create_a2a_server signature and the
  internal resolved_public_url type.
- Tighten _wrap_with_per_request_card resolver parameter to the alias.
- Use inspect.isawaitable (not iscoroutine) for resolver result, with
  a runtime assert to narrow for mypy.
- Replace real brand host async.scope3.com with async.example.com in
  tests per repo convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley marked this pull request as ready for review May 11, 2026 07:15
@bokelley bokelley merged commit 1b4f3e0 into main May 11, 2026
16 checks passed
@bokelley bokelley deleted the claude/issue-647-callable-public-url branch May 11, 2026 07:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(a2a): per-request agent-card URL resolution (follow-up to #616)

2 participants