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
19 changes: 18 additions & 1 deletion src/adcp/server/a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
25 changes: 24 additions & 1 deletion src/adcp/server/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")))
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down
106 changes: 106 additions & 0 deletions tests/test_a2a_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading