Skip to content

Commit 14d294c

Browse files
bokelleyclaude
andauthored
feat(a2a): add public_url param to agent card for production deployments (#621)
* feat(a2a): add public_url param to agent card for production deployments Fixes #616 Adds `public_url` to `_build_agent_card`, `create_a2a_server`, `serve`, and `ServeConfig` so adopters running behind a load balancer or reverse proxy can advertise the correct public URL in `/.well-known/agent-card.json` instead of leaking `http://localhost:{port}/`. Also checks the `PUBLIC_URL` environment variable as a zero-code-change fallback for Cloud Run / Fly.io / Railway deployments. https://claude.ai/code/session_01NXgGie4ARyxg3hWBYo5tG6 * fix(a2a): address pre-PR review findings for public_url - Add public_url docstring to serve() (was missing despite being present in create_a2a_server) - Normalise trailing slash so callers can pass "https://x.com" or "https://x.com/" - Remove redundant `or None` from resolved_public_url assignment - Remove dead handler_ref loop from test https://claude.ai/code/session_01NXgGie4ARyxg3hWBYo5tG6 * test(a2a): add missing public_url edge case tests - Assert trailing-slash normalisation (URL without trailing slash) - Assert create_a2a_server defaults to localhost when PUBLIC_URL unset https://claude.ai/code/session_01NXgGie4ARyxg3hWBYo5tG6 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2989101 commit 14d294c

3 files changed

Lines changed: 148 additions & 2 deletions

File tree

src/adcp/server/a2a_server.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,6 +668,7 @@ def _build_agent_card(
668668
advertise_all: bool = False,
669669
push_notifications_supported: bool = False,
670670
auth: BearerTokenAuth | None = None,
671+
public_url: str | None = None,
671672
) -> pb.AgentCard:
672673
"""Build an A2A AgentCard from an ADCPHandler's tool definitions.
673674
@@ -703,7 +704,7 @@ def _build_agent_card(
703704
if extra_skills:
704705
skills.extend(extra_skills)
705706

706-
url = f"http://localhost:{port}/"
707+
url = (public_url.rstrip("/") + "/") if public_url else f"http://localhost:{port}/"
707708

708709
security_schemes, security_requirements = _build_security_for_auth(auth)
709710

@@ -759,6 +760,7 @@ def create_a2a_server(
759760
validation: ValidationHookConfig | None = SERVER_DEFAULT_VALIDATION,
760761
context_builder: Any | None = None,
761762
auth: BearerTokenAuth | None = None,
763+
public_url: str | None = None,
762764
) -> Any:
763765
"""Create an A2A Starlette application from an ADCP handler.
764766
@@ -854,11 +856,25 @@ def create_a2a_server(
854856
at the ASGI layer. Adopters calling ``create_a2a_server``
855857
directly must wrap the returned app with
856858
:class:`A2ABearerAuthMiddleware` themselves.
859+
public_url: Optional public base URL advertised in the A2A agent
860+
card (``/.well-known/agent-card.json``). When set, this value
861+
replaces the default ``http://localhost:{port}/`` in every
862+
``supported_interfaces`` URL entry. Use this when the agent
863+
runs behind a load balancer or reverse proxy and the bound
864+
socket address differs from the externally reachable URL
865+
(e.g. ``https://agent.example.com/``). Falls back to the
866+
``PUBLIC_URL`` environment variable when ``public_url`` is
867+
``None`` and the env var is set, enabling zero-code-change
868+
configuration on Cloud Run / Fly.io / Railway. When neither
869+
is supplied the default ``http://localhost:{port}/`` is used —
870+
correct for local development, incorrect for production
871+
deployments behind a proxy.
857872
858873
Returns:
859874
A Starlette app ready to be run with uvicorn.
860875
"""
861876
resolved_port = port or int(os.environ.get("PORT", "3001"))
877+
resolved_public_url = public_url or os.environ.get("PUBLIC_URL")
862878

863879
executor = ADCPAgentExecutor(
864880
handler,
@@ -881,6 +897,7 @@ def create_a2a_server(
881897
advertise_all=advertise_all,
882898
push_notifications_supported=push_config_store is not None,
883899
auth=auth,
900+
public_url=resolved_public_url,
884901
)
885902

886903
if task_store is None:

src/adcp/server/serve.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ class ServeConfig:
124124
task_store: TaskStore | None = None
125125
push_config_store: PushNotificationConfigStore | None = None
126126
message_parser: MessageParser | None = None
127+
public_url: str | None = None
127128

128129
# --- Shared infrastructure ---
129130
test_controller: TestControllerStore | None = None
@@ -144,7 +145,7 @@ class ServeConfig:
144145
debug_traffic_source: Callable[[], dict[str, int]] | None = None
145146

146147
def __post_init__(self) -> None:
147-
_a2a_only = ("task_store", "push_config_store", "message_parser")
148+
_a2a_only = ("task_store", "push_config_store", "message_parser", "public_url")
148149
_mcp_only = ("instructions", "streaming_responses")
149150
if self.transport == "a2a":
150151
mcp_set = sorted(f for f in _mcp_only if getattr(self, f) not in (None, False))
@@ -533,6 +534,7 @@ def serve(
533534
allowed_origins: Sequence[str] | None = None,
534535
enable_dns_rebinding_protection: bool | None = None,
535536
auth: BearerTokenAuth | None = None,
537+
public_url: str | None = None,
536538
) -> None:
537539
"""Start an MCP or A2A server from an ADCP handler or server builder.
538540
@@ -714,6 +716,18 @@ class of bug that shipped the ``pricing_options``
714716
stdio, ``auth`` is ignored with a warning (no HTTP layer).
715717
For non-bearer schemes (mTLS, signed-request derivation),
716718
wire your own middleware via ``asgi_middleware=`` instead.
719+
public_url: Optional public base URL for the A2A agent card
720+
(``/.well-known/agent-card.json``). When set, replaces the
721+
default ``http://localhost:{port}/`` in every
722+
``supportedInterfaces`` entry so external clients discover
723+
the correct endpoint instead of the internal socket address.
724+
Use this when the agent runs behind a load balancer, reverse
725+
proxy, or cloud-run service (e.g.
726+
``public_url="https://agent.example.com/"``). Automatically
727+
falls back to the ``PUBLIC_URL`` environment variable when
728+
the kwarg is ``None``, enabling zero-code-change
729+
configuration on Cloud Run, Fly.io, and Railway. Ignored for
730+
MCP transports. Trailing slash is normalised automatically.
717731
718732
Example (MCP):
719733
from adcp.server import ADCPHandler, serve
@@ -763,6 +777,7 @@ async def force_account_status(self, account_id, status):
763777
base_url = config.base_url
764778
specialisms = config.specialisms
765779
description = config.description
780+
public_url = config.public_url
766781

767782
# Accept ADCPServerBuilder from adcp_server() decorator pattern
768783
from adcp.server.builder import ADCPServerBuilder
@@ -804,6 +819,7 @@ async def force_account_status(self, account_id, status):
804819
specialisms=specialisms,
805820
description=description,
806821
auth=auth,
822+
public_url=public_url,
807823
)
808824
elif transport in ("streamable-http", "sse", "stdio"):
809825
_serve_mcp(
@@ -856,6 +872,7 @@ async def force_account_status(self, account_id, status):
856872
allowed_origins=allowed_origins,
857873
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
858874
auth=auth,
875+
public_url=public_url,
859876
)
860877
else:
861878
valid = ", ".join(sorted(("a2a", "both", "streamable-http", "sse", "stdio")))
@@ -1386,6 +1403,7 @@ def _serve_a2a(
13861403
specialisms: list[str] | None = None,
13871404
description: str | None = None,
13881405
auth: BearerTokenAuth | None = None,
1406+
public_url: str | None = None,
13891407
) -> None:
13901408
"""Start an A2A server using uvicorn."""
13911409
import uvicorn
@@ -1410,6 +1428,7 @@ def _serve_a2a(
14101428
advertise_all=advertise_all,
14111429
validation=validation,
14121430
auth=auth,
1431+
public_url=public_url,
14131432
)
14141433
# Auth wraps the A2A app innermost (closer to the inner Starlette
14151434
# router than the discovery + size-limit + asgi_middleware
@@ -1469,6 +1488,7 @@ def _build_mcp_and_a2a_app(
14691488
allowed_origins: Sequence[str] | None = None,
14701489
enable_dns_rebinding_protection: bool | None = None,
14711490
auth: BearerTokenAuth | None = None,
1491+
public_url: str | None = None,
14721492
) -> Any:
14731493
"""Build the unified MCP+A2A ASGI app without starting a server.
14741494
@@ -1557,6 +1577,7 @@ def _build_mcp_and_a2a_app(
15571577
advertise_all=advertise_all,
15581578
validation=validation,
15591579
auth=auth,
1580+
public_url=public_url,
15601581
)
15611582
# Auth wraps both legs *before* ``_dispatch`` captures references —
15621583
# otherwise the closure points at unwrapped apps and auth is
@@ -1645,6 +1666,7 @@ def _serve_mcp_and_a2a(
16451666
allowed_origins: Sequence[str] | None = None,
16461667
enable_dns_rebinding_protection: bool | None = None,
16471668
auth: BearerTokenAuth | None = None,
1669+
public_url: str | None = None,
16481670
) -> None:
16491671
"""Serve MCP and A2A on a single port via path dispatch.
16501672
@@ -1691,6 +1713,7 @@ def _serve_mcp_and_a2a(
16911713
allowed_origins=allowed_origins,
16921714
enable_dns_rebinding_protection=enable_dns_rebinding_protection,
16931715
auth=auth,
1716+
public_url=public_url,
16941717
)
16951718
app = _apply_asgi_middleware(app, asgi_middleware)
16961719

tests/test_a2a_server.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,112 @@ def test_build_agent_card_skills_tagged_adcp():
331331
assert "adcp" in skill.tags
332332

333333

334+
def test_build_agent_card_public_url_overrides_localhost():
335+
card = _build_agent_card(
336+
_TestHandler(),
337+
name="test",
338+
port=8080,
339+
public_url="https://agent.example.com/",
340+
)
341+
for iface in card.supported_interfaces:
342+
assert iface.url == "https://agent.example.com/"
343+
344+
345+
def test_build_agent_card_public_url_trailing_slash_normalised():
346+
card = _build_agent_card(
347+
_TestHandler(),
348+
name="test",
349+
port=8080,
350+
public_url="https://agent.example.com",
351+
)
352+
for iface in card.supported_interfaces:
353+
assert iface.url == "https://agent.example.com/"
354+
355+
356+
def test_build_agent_card_public_url_none_uses_localhost():
357+
card = _build_agent_card(_TestHandler(), name="test", port=8080, public_url=None)
358+
for iface in card.supported_interfaces:
359+
assert iface.url == "http://localhost:8080/"
360+
361+
362+
@pytest.mark.skipif(
363+
sys.version_info < (3, 11),
364+
reason="a2a-sdk starlette integration requires Python 3.11+",
365+
)
366+
def test_create_a2a_server_public_url_in_card(monkeypatch: pytest.MonkeyPatch):
367+
monkeypatch.delenv("PUBLIC_URL", raising=False)
368+
app = create_a2a_server(
369+
_TestHandler(),
370+
name="test-agent",
371+
public_url="https://agent.example.com/",
372+
)
373+
from starlette.testclient import TestClient
374+
375+
client = TestClient(app, raise_server_exceptions=True)
376+
resp = client.get("/.well-known/agent-card.json")
377+
assert resp.status_code == 200
378+
card_json = resp.json()
379+
for iface in card_json.get("supportedInterfaces", []):
380+
assert iface["url"] == "https://agent.example.com/"
381+
382+
383+
@pytest.mark.skipif(
384+
sys.version_info < (3, 11),
385+
reason="a2a-sdk starlette integration requires Python 3.11+",
386+
)
387+
def test_create_a2a_server_no_public_url_defaults_to_localhost(monkeypatch: pytest.MonkeyPatch):
388+
monkeypatch.delenv("PUBLIC_URL", raising=False)
389+
app = create_a2a_server(_TestHandler(), name="test-agent", port=9000)
390+
from starlette.testclient import TestClient
391+
392+
client = TestClient(app, raise_server_exceptions=True)
393+
resp = client.get("/.well-known/agent-card.json")
394+
assert resp.status_code == 200
395+
card_json = resp.json()
396+
for iface in card_json.get("supportedInterfaces", []):
397+
assert iface["url"] == "http://localhost:9000/"
398+
399+
400+
@pytest.mark.skipif(
401+
sys.version_info < (3, 11),
402+
reason="a2a-sdk starlette integration requires Python 3.11+",
403+
)
404+
def test_create_a2a_server_public_url_env_var(monkeypatch: pytest.MonkeyPatch):
405+
monkeypatch.setenv("PUBLIC_URL", "https://env.example.com/")
406+
app = create_a2a_server(_TestHandler(), name="test-agent")
407+
from starlette.testclient import TestClient
408+
409+
client = TestClient(app, raise_server_exceptions=True)
410+
resp = client.get("/.well-known/agent-card.json")
411+
assert resp.status_code == 200
412+
card_json = resp.json()
413+
for iface in card_json.get("supportedInterfaces", []):
414+
assert iface["url"] == "https://env.example.com/"
415+
416+
417+
@pytest.mark.skipif(
418+
sys.version_info < (3, 11),
419+
reason="a2a-sdk starlette integration requires Python 3.11+",
420+
)
421+
def test_create_a2a_server_public_url_kwarg_takes_precedence_over_env(
422+
monkeypatch: pytest.MonkeyPatch,
423+
):
424+
monkeypatch.setenv("PUBLIC_URL", "https://env.example.com/")
425+
app = create_a2a_server(
426+
_TestHandler(),
427+
name="test-agent",
428+
public_url="https://explicit.example.com/",
429+
)
430+
from starlette.testclient import TestClient
431+
432+
client = TestClient(app, raise_server_exceptions=True)
433+
resp = client.get("/.well-known/agent-card.json")
434+
assert resp.status_code == 200
435+
card_json = resp.json()
436+
for iface in card_json.get("supportedInterfaces", []):
437+
assert iface["url"] == "https://explicit.example.com/"
438+
439+
334440
# ---------------------------------------------------------------------------
335441
# create_a2a_server
336442
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)