From faa436ad873ff28b646568a49756bc9c264735b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 12:31:18 +0000 Subject: [PATCH 1/3] 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 --- src/adcp/server/a2a_server.py | 19 ++++++- src/adcp/server/serve.py | 13 ++++- tests/test_a2a_server.py | 93 +++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 4f3a4a53..27c5221d 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -668,6 +668,7 @@ def _build_agent_card( advertise_all: bool = False, push_notifications_supported: bool = False, auth: BearerTokenAuth | None = None, + public_url: str | None = None, ) -> pb.AgentCard: """Build an A2A AgentCard from an ADCPHandler's tool definitions. @@ -703,7 +704,7 @@ def _build_agent_card( if extra_skills: skills.extend(extra_skills) - url = f"http://localhost:{port}/" + url = public_url or f"http://localhost:{port}/" security_schemes, security_requirements = _build_security_for_auth(auth) @@ -759,6 +760,7 @@ def create_a2a_server( validation: ValidationHookConfig | None = SERVER_DEFAULT_VALIDATION, context_builder: Any | None = None, auth: BearerTokenAuth | None = None, + public_url: str | None = None, ) -> Any: """Create an A2A Starlette application from an ADCP handler. @@ -854,11 +856,25 @@ def create_a2a_server( at the ASGI layer. Adopters calling ``create_a2a_server`` directly must wrap the returned app with :class:`A2ABearerAuthMiddleware` themselves. + public_url: Optional public base URL advertised in the A2A agent + card (``/.well-known/agent-card.json``). When set, this value + replaces the default ``http://localhost:{port}/`` in every + ``supported_interfaces`` URL entry. Use this when the agent + runs behind a load balancer or reverse proxy and the bound + socket address differs from the externally reachable URL + (e.g. ``https://agent.example.com/``). Falls back to the + ``PUBLIC_URL`` environment variable when ``public_url`` is + ``None`` and the env var is set, enabling zero-code-change + configuration on Cloud Run / Fly.io / Railway. When neither + is supplied the default ``http://localhost:{port}/`` is used — + correct for local development, incorrect for production + deployments behind a proxy. Returns: A Starlette app ready to be run with uvicorn. """ resolved_port = port or int(os.environ.get("PORT", "3001")) + resolved_public_url = public_url or os.environ.get("PUBLIC_URL") or None executor = ADCPAgentExecutor( handler, @@ -881,6 +897,7 @@ def create_a2a_server( advertise_all=advertise_all, push_notifications_supported=push_config_store is not None, auth=auth, + public_url=resolved_public_url, ) if task_store is None: diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index e480abea..bc1ac9cb 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -124,6 +124,7 @@ class ServeConfig: task_store: TaskStore | None = None push_config_store: PushNotificationConfigStore | None = None message_parser: MessageParser | None = None + public_url: str | None = None # --- Shared infrastructure --- test_controller: TestControllerStore | None = None @@ -144,7 +145,7 @@ class ServeConfig: debug_traffic_source: Callable[[], dict[str, int]] | None = None def __post_init__(self) -> None: - _a2a_only = ("task_store", "push_config_store", "message_parser") + _a2a_only = ("task_store", "push_config_store", "message_parser", "public_url") _mcp_only = ("instructions", "streaming_responses") if self.transport == "a2a": mcp_set = sorted(f for f in _mcp_only if getattr(self, f) not in (None, False)) @@ -533,6 +534,7 @@ def serve( allowed_origins: Sequence[str] | None = None, enable_dns_rebinding_protection: bool | None = None, auth: BearerTokenAuth | None = None, + public_url: str | None = None, ) -> None: """Start an MCP or A2A server from an ADCP handler or server builder. @@ -763,6 +765,7 @@ async def force_account_status(self, account_id, status): base_url = config.base_url specialisms = config.specialisms description = config.description + public_url = config.public_url # Accept ADCPServerBuilder from adcp_server() decorator pattern from adcp.server.builder import ADCPServerBuilder @@ -804,6 +807,7 @@ async def force_account_status(self, account_id, status): specialisms=specialisms, description=description, auth=auth, + public_url=public_url, ) elif transport in ("streamable-http", "sse", "stdio"): _serve_mcp( @@ -856,6 +860,7 @@ async def force_account_status(self, account_id, status): allowed_origins=allowed_origins, enable_dns_rebinding_protection=enable_dns_rebinding_protection, auth=auth, + public_url=public_url, ) else: valid = ", ".join(sorted(("a2a", "both", "streamable-http", "sse", "stdio"))) @@ -1386,6 +1391,7 @@ def _serve_a2a( specialisms: list[str] | None = None, description: str | None = None, auth: BearerTokenAuth | None = None, + public_url: str | None = None, ) -> None: """Start an A2A server using uvicorn.""" import uvicorn @@ -1410,6 +1416,7 @@ def _serve_a2a( advertise_all=advertise_all, validation=validation, auth=auth, + public_url=public_url, ) # Auth wraps the A2A app innermost (closer to the inner Starlette # router than the discovery + size-limit + asgi_middleware @@ -1469,6 +1476,7 @@ def _build_mcp_and_a2a_app( allowed_origins: Sequence[str] | None = None, enable_dns_rebinding_protection: bool | None = None, auth: BearerTokenAuth | None = None, + public_url: str | None = None, ) -> Any: """Build the unified MCP+A2A ASGI app without starting a server. @@ -1557,6 +1565,7 @@ def _build_mcp_and_a2a_app( advertise_all=advertise_all, validation=validation, auth=auth, + public_url=public_url, ) # Auth wraps both legs *before* ``_dispatch`` captures references — # otherwise the closure points at unwrapped apps and auth is @@ -1645,6 +1654,7 @@ def _serve_mcp_and_a2a( allowed_origins: Sequence[str] | None = None, enable_dns_rebinding_protection: bool | None = None, auth: BearerTokenAuth | None = None, + public_url: str | None = None, ) -> None: """Serve MCP and A2A on a single port via path dispatch. @@ -1691,6 +1701,7 @@ def _serve_mcp_and_a2a( allowed_origins=allowed_origins, enable_dns_rebinding_protection=enable_dns_rebinding_protection, auth=auth, + public_url=public_url, ) app = _apply_asgi_middleware(app, asgi_middleware) diff --git a/tests/test_a2a_server.py b/tests/test_a2a_server.py index bc3205a0..77a39f27 100644 --- a/tests/test_a2a_server.py +++ b/tests/test_a2a_server.py @@ -331,6 +331,99 @@ def test_build_agent_card_skills_tagged_adcp(): assert "adcp" in skill.tags +def test_build_agent_card_public_url_overrides_localhost(): + card = _build_agent_card( + _TestHandler(), + name="test", + port=8080, + public_url="https://agent.example.com/", + ) + for iface in card.supported_interfaces: + assert iface.url == "https://agent.example.com/" + + +def test_build_agent_card_public_url_none_uses_localhost(): + card = _build_agent_card(_TestHandler(), name="test", port=8080, public_url=None) + for iface in card.supported_interfaces: + assert iface.url == "http://localhost:8080/" + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="a2a-sdk starlette integration requires Python 3.11+", +) +def test_create_a2a_server_public_url_in_card(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("PUBLIC_URL", raising=False) + app = create_a2a_server( + _TestHandler(), + name="test-agent", + public_url="https://agent.example.com/", + ) + # The a2a-sdk DefaultRequestHandler stores the card; retrieve it via the + # request_handler attribute that Starlette routes capture. + from a2a.server.request_handlers import DefaultRequestHandler + + handler_ref = None + for route in app.routes: + h = getattr(route, "endpoint", None) or getattr(route, "app", None) + if isinstance(h, DefaultRequestHandler): + handler_ref = h + break + # Some a2a-sdk versions wrap the handler in a closure + if callable(h) and hasattr(h, "__self__") and isinstance(h.__self__, DefaultRequestHandler): + handler_ref = h.__self__ + break + # Fallback: retrieve the card via the well-known route response + from starlette.testclient import TestClient + + client = TestClient(app, raise_server_exceptions=True) + resp = client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + card_json = resp.json() + for iface in card_json.get("supportedInterfaces", []): + assert iface["url"] == "https://agent.example.com/" + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="a2a-sdk starlette integration requires Python 3.11+", +) +def test_create_a2a_server_public_url_env_var(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PUBLIC_URL", "https://env.example.com/") + app = create_a2a_server(_TestHandler(), name="test-agent") + from starlette.testclient import TestClient + + client = TestClient(app, raise_server_exceptions=True) + resp = client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + card_json = resp.json() + for iface in card_json.get("supportedInterfaces", []): + assert iface["url"] == "https://env.example.com/" + + +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="a2a-sdk starlette integration requires Python 3.11+", +) +def test_create_a2a_server_public_url_kwarg_takes_precedence_over_env( + monkeypatch: pytest.MonkeyPatch, +): + monkeypatch.setenv("PUBLIC_URL", "https://env.example.com/") + app = create_a2a_server( + _TestHandler(), + name="test-agent", + public_url="https://explicit.example.com/", + ) + from starlette.testclient import TestClient + + client = TestClient(app, raise_server_exceptions=True) + resp = client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + card_json = resp.json() + for iface in card_json.get("supportedInterfaces", []): + assert iface["url"] == "https://explicit.example.com/" + + # --------------------------------------------------------------------------- # create_a2a_server # --------------------------------------------------------------------------- From 5584d22b7513054c0600d8b0b4ba8b13d66cc717 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 12:34:58 +0000 Subject: [PATCH 2/3] 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 --- src/adcp/server/a2a_server.py | 4 ++-- src/adcp/server/serve.py | 12 ++++++++++++ tests/test_a2a_server.py | 15 --------------- 3 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 27c5221d..c586c8d5 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -704,7 +704,7 @@ def _build_agent_card( if extra_skills: skills.extend(extra_skills) - url = public_url or f"http://localhost:{port}/" + url = (public_url.rstrip("/") + "/") if public_url else f"http://localhost:{port}/" security_schemes, security_requirements = _build_security_for_auth(auth) @@ -874,7 +874,7 @@ def create_a2a_server( A Starlette app ready to be run with uvicorn. """ resolved_port = port or int(os.environ.get("PORT", "3001")) - resolved_public_url = public_url or os.environ.get("PUBLIC_URL") or None + resolved_public_url = public_url or os.environ.get("PUBLIC_URL") executor = ADCPAgentExecutor( handler, diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index bc1ac9cb..80478191 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -716,6 +716,18 @@ class of bug that shipped the ``pricing_options`` stdio, ``auth`` is ignored with a warning (no HTTP layer). For non-bearer schemes (mTLS, signed-request derivation), wire your own middleware via ``asgi_middleware=`` instead. + public_url: Optional public base URL for the A2A agent card + (``/.well-known/agent-card.json``). When set, replaces the + default ``http://localhost:{port}/`` in every + ``supportedInterfaces`` entry so external clients discover + the correct endpoint instead of the internal socket address. + Use this when the agent runs behind a load balancer, reverse + proxy, or cloud-run service (e.g. + ``public_url="https://agent.example.com/"``). Automatically + falls back to the ``PUBLIC_URL`` environment variable when + the kwarg is ``None``, enabling zero-code-change + configuration on Cloud Run, Fly.io, and Railway. Ignored for + MCP transports. Trailing slash is normalised automatically. Example (MCP): from adcp.server import ADCPHandler, serve diff --git a/tests/test_a2a_server.py b/tests/test_a2a_server.py index 77a39f27..37b03308 100644 --- a/tests/test_a2a_server.py +++ b/tests/test_a2a_server.py @@ -359,21 +359,6 @@ def test_create_a2a_server_public_url_in_card(monkeypatch: pytest.MonkeyPatch): name="test-agent", public_url="https://agent.example.com/", ) - # The a2a-sdk DefaultRequestHandler stores the card; retrieve it via the - # request_handler attribute that Starlette routes capture. - from a2a.server.request_handlers import DefaultRequestHandler - - handler_ref = None - for route in app.routes: - h = getattr(route, "endpoint", None) or getattr(route, "app", None) - if isinstance(h, DefaultRequestHandler): - handler_ref = h - break - # Some a2a-sdk versions wrap the handler in a closure - if callable(h) and hasattr(h, "__self__") and isinstance(h.__self__, DefaultRequestHandler): - handler_ref = h.__self__ - break - # Fallback: retrieve the card via the well-known route response from starlette.testclient import TestClient client = TestClient(app, raise_server_exceptions=True) From c41e6f73501de8636286696b2fd0525f6506b19d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 12:36:52 +0000 Subject: [PATCH 3/3] 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 --- tests/test_a2a_server.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_a2a_server.py b/tests/test_a2a_server.py index 37b03308..c6f65613 100644 --- a/tests/test_a2a_server.py +++ b/tests/test_a2a_server.py @@ -342,6 +342,17 @@ def test_build_agent_card_public_url_overrides_localhost(): assert iface.url == "https://agent.example.com/" +def test_build_agent_card_public_url_trailing_slash_normalised(): + card = _build_agent_card( + _TestHandler(), + name="test", + port=8080, + public_url="https://agent.example.com", + ) + for iface in card.supported_interfaces: + assert iface.url == "https://agent.example.com/" + + def test_build_agent_card_public_url_none_uses_localhost(): card = _build_agent_card(_TestHandler(), name="test", port=8080, public_url=None) for iface in card.supported_interfaces: @@ -369,6 +380,23 @@ def test_create_a2a_server_public_url_in_card(monkeypatch: pytest.MonkeyPatch): assert iface["url"] == "https://agent.example.com/" +@pytest.mark.skipif( + sys.version_info < (3, 11), + reason="a2a-sdk starlette integration requires Python 3.11+", +) +def test_create_a2a_server_no_public_url_defaults_to_localhost(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("PUBLIC_URL", raising=False) + app = create_a2a_server(_TestHandler(), name="test-agent", port=9000) + from starlette.testclient import TestClient + + client = TestClient(app, raise_server_exceptions=True) + resp = client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + card_json = resp.json() + for iface in card_json.get("supportedInterfaces", []): + assert iface["url"] == "http://localhost:9000/" + + @pytest.mark.skipif( sys.version_info < (3, 11), reason="a2a-sdk starlette integration requires Python 3.11+",