diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 4f3a4a53..c586c8d5 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.rstrip("/") + "/") if public_url else 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") 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..80478191 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. @@ -714,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 @@ -763,6 +777,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 +819,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 +872,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 +1403,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 +1428,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 +1488,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 +1577,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 +1666,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 +1713,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..c6f65613 100644 --- a/tests/test_a2a_server.py +++ b/tests/test_a2a_server.py @@ -331,6 +331,112 @@ 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_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: + 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/", + ) + 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_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+", +) +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 # ---------------------------------------------------------------------------