From 5f302cbf0a83a648ee314c5d7133dad7356d4416 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 00:45:33 +0000 Subject: [PATCH 1/2] feat(server): /.well-known/adcp-agents.json discovery endpoint Implements AdCP spec PR #3903 (commit 5c3e3e626). Sellers now serve a dynamic multi-agent topology document on all three transport modes; buyers can discover it via fetch_adcp_agents(). - serve() exposes /.well-known/adcp-agents.json on streamable-http, a2a, and both transports via a thin ASGI wrapper injected before auth middleware - Document is generated at request time from get_tools_for_handler() so per-instance capability filters are always accurate; comply_test_controller is excluded from the public surface - BearerTokenAuthMiddleware.dispatch() now bypasses auth for GET /.well-known/ requests (spec-mandated public discovery; JSON-RPC peek was silently 401ing these requests before) - fetch_adcp_agents(agent_base_url) client-side helper mirrors fetch_adagents() with SSRF-safe URL construction - AdcpAgentsNotFoundError and AdcpAgentsValidationError added to exceptions hierarchy with context-correct suggestion text - 16 smoke tests cover all three transports, advertise_all gating, comply_test_controller exclusion, and client-side 200/404 paths Note: CI schema validation against adcp-agents.json requires sync_schemas.py to pull the upstream schema once adcontextprotocol/adcp PR #3903 releases. Refs #381 https://claude.ai/code/session_01S3J5mFsF94rDjmrhNg8G8a --- src/adcp/__init__.py | 6 + src/adcp/adagents.py | 98 ++++++++++- src/adcp/exceptions.py | 16 ++ src/adcp/server/auth.py | 12 +- src/adcp/server/serve.py | 107 +++++++++++ tests/test_adcp_agents_discovery.py | 264 ++++++++++++++++++++++++++++ 6 files changed, 500 insertions(+), 3 deletions(-) create mode 100644 tests/test_adcp_agents_discovery.py diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 737680099..07322f3ab 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -14,6 +14,7 @@ AuthorizationContext, domain_matches, fetch_adagents, + fetch_adcp_agents, fetch_agent_authorizations, get_all_properties, get_all_tags, @@ -32,6 +33,8 @@ AdagentsNotFoundError, AdagentsTimeoutError, AdagentsValidationError, + AdcpAgentsNotFoundError, + AdcpAgentsValidationError, ADCPAuthenticationError, ADCPConnectionError, ADCPError, @@ -803,6 +806,7 @@ def get_adcp_version() -> str: # Adagents validation "AuthorizationContext", "fetch_adagents", + "fetch_adcp_agents", "fetch_agent_authorizations", "verify_agent_authorization", "verify_agent_for_property", @@ -839,6 +843,8 @@ def get_adcp_version() -> str: "AdagentsValidationError", "AdagentsNotFoundError", "AdagentsTimeoutError", + "AdcpAgentsValidationError", + "AdcpAgentsNotFoundError", "ConfigurationError", "RegistryError", # Validation utilities diff --git a/src/adcp/adagents.py b/src/adcp/adagents.py index 3a7e03623..30fd734ab 100644 --- a/src/adcp/adagents.py +++ b/src/adcp/adagents.py @@ -14,7 +14,13 @@ import httpx -from adcp.exceptions import AdagentsNotFoundError, AdagentsTimeoutError, AdagentsValidationError +from adcp.exceptions import ( + AdagentsNotFoundError, + AdagentsTimeoutError, + AdagentsValidationError, + AdcpAgentsNotFoundError, + AdcpAgentsValidationError, +) from adcp.validation import ValidationError, validate_adagents @@ -692,6 +698,96 @@ def get_properties_by_agent(adagents_data: dict[str, Any], agent_url: str) -> li return [] +async def fetch_adcp_agents( + agent_base_url: str, + timeout: float = 10.0, + user_agent: str = "AdCP-Client/1.0", + client: httpx.AsyncClient | None = None, +) -> dict[str, Any]: + """Fetch and parse adcp-agents.json from an agent server. + + Buyers use this to discover a seller's declared agents and capabilities + from their ``/.well-known/adcp-agents.json`` multi-agent topology document. + + Args: + agent_base_url: Base URL of the agent server + (e.g. ``"https://seller.example.com"``). Any path component + is stripped; only the scheme + host are used. + timeout: Request timeout in seconds + user_agent: User-Agent header for HTTP request + client: Optional httpx.AsyncClient for connection pooling. + If provided, caller is responsible for client lifecycle. + If None, a new client is created for this request. + + Returns: + Parsed adcp-agents.json document. + + Raises: + AdcpAgentsNotFoundError: If adcp-agents.json not found (404) + AdcpAgentsValidationError: If JSON is invalid or malformed + AdagentsTimeoutError: If request times out + """ + from urllib.parse import urlparse as _urlparse + + parsed = _urlparse(agent_base_url.rstrip("/")) + base = f"{parsed.scheme}://{parsed.netloc}" + url = f"{base}/.well-known/adcp-agents.json" + + try: + if client is not None: + response = await client.get( + url, + headers={"User-Agent": user_agent}, + timeout=timeout, + follow_redirects=True, + ) + else: + async with httpx.AsyncClient() as new_client: + response = await new_client.get( + url, + headers={"User-Agent": user_agent}, + timeout=timeout, + follow_redirects=True, + ) + + if response.status_code == 404: + raise AdcpAgentsNotFoundError(parsed.netloc) + + if response.status_code != 200: + raise AdcpAgentsValidationError( + f"Failed to fetch adcp-agents.json: HTTP {response.status_code}" + ) + + try: + data = response.json() + except Exception as exc: + raise AdcpAgentsValidationError( + f"Invalid JSON in adcp-agents.json: {exc}" + ) from exc + + if not isinstance(data, dict): + raise AdcpAgentsValidationError("adcp-agents.json must be a JSON object") + + if "agents" not in data: + raise AdcpAgentsValidationError( + "adcp-agents.json must have an 'agents' field" + ) + + if not isinstance(data["agents"], list): + raise AdcpAgentsValidationError("'agents' must be an array") + + return data + + except httpx.TimeoutException as exc: + raise AdagentsTimeoutError(parsed.netloc, timeout) from exc + except (AdcpAgentsNotFoundError, AdcpAgentsValidationError, AdagentsTimeoutError): + raise + except httpx.RequestError as exc: + raise AdcpAgentsValidationError( + f"Failed to fetch adcp-agents.json: {exc}" + ) from exc + + class AuthorizationContext: """Authorization context for a publisher domain. diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index 65f89558f..ca0ac2826 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -285,6 +285,22 @@ def __init__(self, publisher_domain: str, timeout: float): super().__init__(message, None, None, suggestion) +class AdcpAgentsValidationError(AdagentsValidationError): + """Error for adcp-agents.json validation issues.""" + + +class AdcpAgentsNotFoundError(AdcpAgentsValidationError): + """adcp-agents.json file not found (404).""" + + def __init__(self, agent_domain: str): + message = f"adcp-agents.json not found for agent: {agent_domain}" + suggestion = ( + "Verify that the agent server has deployed adcp-agents.json to:\n" + f" https://{agent_domain}/.well-known/adcp-agents.json" + ) + super().__init__(message, None, None, suggestion) + + class ADCPTaskError(ADCPError): """A task returned an ADCP error response. diff --git a/src/adcp/server/auth.py b/src/adcp/server/auth.py index b745d0b7d..d0b52d7b8 100644 --- a/src/adcp/server/auth.py +++ b/src/adcp/server/auth.py @@ -237,12 +237,20 @@ def __init__( self._unauth_body = unauthenticated_response or {"error": "unauthenticated"} async def dispatch(self, request: Request, call_next: Any) -> Any: - method, tool = await self._peek_jsonrpc(request) - principal_token = None tenant_token = None metadata_token = None try: + # .well-known discovery endpoints are public by spec — exempt + # before reading the body (GET requests carry no JSON-RPC). + if request.method == "GET" and request.url.path.startswith("/.well-known/"): + principal_token = current_principal.set(None) + tenant_token = current_tenant.set(None) + metadata_token = current_principal_metadata.set(None) + return await call_next(request) + + method, tool = await self._peek_jsonrpc(request) + if self.is_discovery_request(method, tool): principal_token = current_principal.set(None) tenant_token = current_tenant.set(None) diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index 093bc7b37..d5eb471a2 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -700,6 +700,77 @@ def _wrap_with_size_limit(app: Any, max_request_size: int | None) -> Any: return RequestSizeLimitMiddleware(app, max_bytes=cap) +def _build_adcp_agents_doc( + handler: ADCPHandler[Any], + name: str, + advertise_all: bool = False, +) -> dict[str, Any]: + """Generate the /.well-known/adcp-agents.json discovery document. + + Called at request time rather than startup so per-instance capability + filters and flag-gated features are reflected accurately. + """ + from adcp._version import resolve_adcp_version + + tool_defs = get_tools_for_handler(handler, advertise_all=advertise_all) + capabilities = [ + td["name"] + for td in tool_defs + if td["name"] != "comply_test_controller" + ] + return { + "adcp_version": resolve_adcp_version(None), + "agents": [ + { + "name": name, + "capabilities": capabilities, + } + ], + } + + +def _wrap_with_adcp_agents_route( + app: Any, + handler: ADCPHandler[Any], + name: str, + advertise_all: bool = False, +) -> Any: + """Inject a GET /.well-known/adcp-agents.json route before the inner app. + + Fires before the inner ASGI app (and before auth middleware when placed + correctly in the wrapper stack) so discovery requests are always served + unauthenticated per spec. + """ + import json as _json + + async def _middleware(scope: Any, receive: Any, send: Any) -> None: + if ( + scope.get("type") == "http" + and scope.get("path") == "/.well-known/adcp-agents.json" + and scope.get("method", "GET") == "GET" + ): + body = _json.dumps( + _build_adcp_agents_doc(handler, name, advertise_all), + separators=(",", ":"), + ).encode() + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + [b"content-type", b"application/json"], + [b"content-length", str(len(body)).encode()], + [b"cache-control", b"no-cache"], + ], + } + ) + await send({"type": "http.response.body", "body": body, "more_body": False}) + return + await app(scope, receive, send) + + return _middleware + + def _bind_reusable_socket(host: str, port: int) -> Any: """Create a listening socket with SO_REUSEADDR set. @@ -808,6 +879,9 @@ def _serve_mcp( transport=transport, max_request_size=max_request_size, asgi_middleware=asgi_middleware, + handler=handler, + name=name, + advertise_all=advertise_all, ) else: # stdio — no listening socket, nothing to configure. @@ -820,6 +894,9 @@ def _run_mcp_http( transport: str, max_request_size: int | None = None, asgi_middleware: Sequence[tuple[type, dict[str, Any]]] | None = None, + handler: ADCPHandler[Any] | None = None, + name: str = "adcp-agent", + advertise_all: bool = False, ) -> None: """Run FastMCP's HTTP transports with a pre-bound SO_REUSEADDR socket. @@ -842,6 +919,8 @@ def _run_mcp_http( app = mcp.sse_app() app = _wrap_with_path_normalize(app) + if handler is not None: + app = _wrap_with_adcp_agents_route(app, handler, name, advertise_all) app = _wrap_with_size_limit(app, max_request_size) app = _apply_asgi_middleware(app, asgi_middleware) @@ -905,6 +984,7 @@ def _serve_a2a( message_parser=message_parser, advertise_all=advertise_all, ) + app = _wrap_with_adcp_agents_route(app, handler, name, advertise_all) app = _wrap_with_size_limit(app, max_request_size) app = _apply_asgi_middleware(app, asgi_middleware) sock = _bind_reusable_socket("0.0.0.0", resolved_port) @@ -1018,6 +1098,8 @@ async def _composed_lifespan(_app): # type: ignore[no-untyped-def] async def _dispatch(scope: Scope, receive: Receive, send: Send) -> None: """Path-based ASGI dispatcher. + ``/.well-known/adcp-agents.json`` is served inline (before both + inner apps) so it is available on both transports uniformly. ``/mcp`` and ``/mcp/...`` route to the FastMCP streamable-http app with the full original path preserved (FastMCP's inner route is at ``/mcp``). Everything else goes to A2A. Lifespan @@ -1025,7 +1107,32 @@ async def _dispatch(scope: Scope, receive: Receive, send: Send) -> None: inner lifespans. """ if scope["type"] == "http": + import json as _json + path = scope.get("path", "") + if ( + path == "/.well-known/adcp-agents.json" + and scope.get("method", "GET") == "GET" + ): + body = _json.dumps( + _build_adcp_agents_doc(handler, name, advertise_all), + separators=(",", ":"), + ).encode() + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + [b"content-type", b"application/json"], + [b"content-length", str(len(body)).encode()], + [b"cache-control", b"no-cache"], + ], + } + ) + await send( + {"type": "http.response.body", "body": body, "more_body": False} + ) + return if path == "/mcp" or path.startswith("/mcp/"): await mcp_app(scope, receive, send) return diff --git a/tests/test_adcp_agents_discovery.py b/tests/test_adcp_agents_discovery.py new file mode 100644 index 000000000..b9f1498ff --- /dev/null +++ b/tests/test_adcp_agents_discovery.py @@ -0,0 +1,264 @@ +"""Smoke tests for the /.well-known/adcp-agents.json discovery endpoint. + +Covers all three transport modes (streamable-http, a2a, both) via the +Starlette TestClient, plus advertise_all gating and comply_test_controller +exclusion. +""" + +from __future__ import annotations + +import json + +import pytest + +starlette = pytest.importorskip("starlette") + +from starlette.testclient import TestClient + +from adcp.server import ADCPHandler, ToolContext +from adcp.server.a2a_server import create_a2a_server +from adcp.server.responses import capabilities_response +from adcp.server.serve import _build_mcp_and_a2a_app, _wrap_with_adcp_agents_route, create_mcp_server + +DISCOVERY_PATH = "/.well-known/adcp-agents.json" + + +class _MinimalHandler(ADCPHandler[ToolContext]): + async def get_adcp_capabilities(self, params, context=None): + return capabilities_response(["media_buy"]) + + async def get_products(self, params, context=None): + return {"products": []} + + +class _FullHandler(ADCPHandler[ToolContext]): + """Handler that overrides multiple tools so capability list is non-trivial.""" + + async def get_adcp_capabilities(self, params, context=None): + return capabilities_response(["media_buy", "creative"]) + + async def get_products(self, params, context=None): + return {"products": []} + + async def create_media_buy(self, params, context=None): + return {"media_buy_id": "mb_1", "packages": []} + + async def build_creative(self, params, context=None): + return {"creative_id": "cr_1"} + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mcp_test_client(handler, name="test-agent", advertise_all=False): + mcp = create_mcp_server(handler, name=name, port=3099) + inner = mcp.streamable_http_app() + app = _wrap_with_adcp_agents_route(inner, handler, name, advertise_all) + return TestClient(app, raise_server_exceptions=True) + + +def _a2a_test_client(handler, name="test-agent", advertise_all=False): + a2a_app = create_a2a_server(handler, name=name, port=3099) + app = _wrap_with_adcp_agents_route(a2a_app, handler, name, advertise_all) + return TestClient(app, raise_server_exceptions=True) + + +def _both_test_client(handler, name="test-agent", advertise_all=False): + app = _build_mcp_and_a2a_app( + handler, + name=name, + port=3099, + host="127.0.0.1", + instructions=None, + test_controller=None, + advertise_all=advertise_all, + ) + return TestClient(app, raise_server_exceptions=True) + + +# --------------------------------------------------------------------------- +# streamable-http transport +# --------------------------------------------------------------------------- + + +def test_mcp_discovery_returns_200(): + with _mcp_test_client(_MinimalHandler()) as client: + resp = client.get(DISCOVERY_PATH) + assert resp.status_code == 200 + + +def test_mcp_discovery_content_type(): + with _mcp_test_client(_MinimalHandler()) as client: + resp = client.get(DISCOVERY_PATH) + assert "application/json" in resp.headers["content-type"] + + +def test_mcp_discovery_body_is_valid_json(): + with _mcp_test_client(_MinimalHandler()) as client: + resp = client.get(DISCOVERY_PATH) + data = resp.json() + assert isinstance(data, dict) + assert "agents" in data + assert isinstance(data["agents"], list) + assert len(data["agents"]) >= 1 + + +def test_mcp_discovery_agent_name(): + with _mcp_test_client(_MinimalHandler(), name="my-seller") as client: + resp = client.get(DISCOVERY_PATH) + data = resp.json() + assert data["agents"][0]["name"] == "my-seller" + + +def test_mcp_discovery_includes_adcp_version(): + with _mcp_test_client(_MinimalHandler()) as client: + resp = client.get(DISCOVERY_PATH) + data = resp.json() + assert "adcp_version" in data + assert isinstance(data["adcp_version"], str) + assert len(data["adcp_version"]) > 0 + + +def test_mcp_discovery_capabilities_list(): + with _mcp_test_client(_FullHandler()) as client: + resp = client.get(DISCOVERY_PATH) + data = resp.json() + caps = data["agents"][0]["capabilities"] + assert "get_products" in caps + assert "create_media_buy" in caps + assert "build_creative" in caps + + +def test_mcp_discovery_excludes_comply_test_controller_advertise_all(): + with _mcp_test_client(_MinimalHandler(), advertise_all=True) as client: + resp = client.get(DISCOVERY_PATH) + data = resp.json() + caps = data["agents"][0]["capabilities"] + assert "comply_test_controller" not in caps + + +def test_mcp_discovery_advertise_all_false_only_implemented_tools(): + with _mcp_test_client(_MinimalHandler(), advertise_all=False) as client: + resp = client.get(DISCOVERY_PATH) + data = resp.json() + caps = data["agents"][0]["capabilities"] + # _MinimalHandler only overrides get_adcp_capabilities + get_products + assert "get_products" in caps + # A non-overridden tool should not appear + assert "create_media_buy" not in caps + + +def test_mcp_discovery_advertise_all_expands_capabilities(): + with_all = _mcp_test_client(_MinimalHandler(), advertise_all=True) + without_all = _mcp_test_client(_MinimalHandler(), advertise_all=False) + + with with_all as c1, without_all as c2: + caps_all = c1.get(DISCOVERY_PATH).json()["agents"][0]["capabilities"] + caps_impl = c2.get(DISCOVERY_PATH).json()["agents"][0]["capabilities"] + + assert len(caps_all) >= len(caps_impl) + + +# --------------------------------------------------------------------------- +# A2A transport +# --------------------------------------------------------------------------- + + +def test_a2a_discovery_returns_200(): + with _a2a_test_client(_MinimalHandler()) as client: + resp = client.get(DISCOVERY_PATH) + assert resp.status_code == 200 + + +def test_a2a_discovery_body_structure(): + with _a2a_test_client(_MinimalHandler(), name="a2a-seller") as client: + resp = client.get(DISCOVERY_PATH) + data = resp.json() + assert data["agents"][0]["name"] == "a2a-seller" + assert isinstance(data["agents"][0]["capabilities"], list) + + +# --------------------------------------------------------------------------- +# both transport (unified MCP+A2A dispatcher) +# --------------------------------------------------------------------------- + + +def test_both_discovery_returns_200(): + with _both_test_client(_MinimalHandler()) as client: + resp = client.get(DISCOVERY_PATH) + assert resp.status_code == 200 + + +def test_both_discovery_body_structure(): + with _both_test_client(_MinimalHandler(), name="both-seller") as client: + resp = client.get(DISCOVERY_PATH) + data = resp.json() + assert data["agents"][0]["name"] == "both-seller" + + +def test_both_discovery_does_not_affect_mcp_path(): + """The /mcp path still routes to FastMCP after the discovery route is added.""" + with _both_test_client(_MinimalHandler()) as client: + disc = client.get(DISCOVERY_PATH) + # /mcp itself routes to FastMCP; any 2xx or 4xx from FastMCP confirms routing + mcp_resp = client.get("/mcp") + assert disc.status_code == 200 + assert mcp_resp.status_code not in (500, 502) + + +# --------------------------------------------------------------------------- +# fetch_adcp_agents client helper +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_fetch_adcp_agents_success(monkeypatch): + """fetch_adcp_agents returns the parsed document from /.well-known/adcp-agents.json.""" + from unittest.mock import AsyncMock, MagicMock + + import httpx + + from adcp import fetch_adcp_agents + + doc = { + "adcp_version": "3.0", + "agents": [{"name": "my-seller", "capabilities": ["get_products"]}], + } + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = doc + + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + monkeypatch.setattr(httpx, "AsyncClient", lambda: mock_client) + + result = await fetch_adcp_agents("https://seller.example.com") + assert result["agents"][0]["name"] == "my-seller" + + +@pytest.mark.asyncio +async def test_fetch_adcp_agents_404(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + import httpx + + from adcp import fetch_adcp_agents + from adcp.exceptions import AdcpAgentsNotFoundError + + mock_response = MagicMock(spec=httpx.Response) + mock_response.status_code = 404 + + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + monkeypatch.setattr(httpx, "AsyncClient", lambda: mock_client) + + with pytest.raises(AdcpAgentsNotFoundError): + await fetch_adcp_agents("https://seller.example.com") From 0d0fe201b6a65e10a3fa7915dc1c87770061fbaf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 01:03:01 +0000 Subject: [PATCH 2/2] fix(server): address pre-PR review blockers for adcp-agents discovery - Add AdcpAgentsTimeoutError to exceptions hierarchy (namespace fix) - Add SSRF protection to fetch_adcp_agents: validates scheme (http/https only) and rejects private/reserved IPs and localhost before making any network call - Remove shadow urlparse import inside fetch_adcp_agents; use module-level urlparse - Raise AdcpAgentsTimeoutError instead of AdagentsTimeoutError on timeout - Export AdcpAgentsTimeoutError from adcp.__init__ and update __all__ - Extract _send_adcp_agents_response() helper to eliminate duplicate response-assembly code in serve.py - Move import json to module level in serve.py - Add 5 new tests: SSRF rejection, timeout namespace, auth middleware bypass, and update public API snapshot https://claude.ai/code/session_01S3J5mFsF94rDjmrhNg8G8a --- src/adcp/__init__.py | 2 + src/adcp/adagents.py | 28 +++++++++-- src/adcp/exceptions.py | 12 +++++ src/adcp/server/serve.py | 65 +++++++++++------------- tests/fixtures/public_api_snapshot.json | 4 ++ tests/test_adcp_agents_discovery.py | 67 +++++++++++++++++++++++++ 6 files changed, 136 insertions(+), 42 deletions(-) diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 07322f3ab..50bd205c0 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -34,6 +34,7 @@ AdagentsTimeoutError, AdagentsValidationError, AdcpAgentsNotFoundError, + AdcpAgentsTimeoutError, AdcpAgentsValidationError, ADCPAuthenticationError, ADCPConnectionError, @@ -845,6 +846,7 @@ def get_adcp_version() -> str: "AdagentsTimeoutError", "AdcpAgentsValidationError", "AdcpAgentsNotFoundError", + "AdcpAgentsTimeoutError", "ConfigurationError", "RegistryError", # Validation utilities diff --git a/src/adcp/adagents.py b/src/adcp/adagents.py index 30fd734ab..296318f54 100644 --- a/src/adcp/adagents.py +++ b/src/adcp/adagents.py @@ -19,6 +19,7 @@ AdagentsTimeoutError, AdagentsValidationError, AdcpAgentsNotFoundError, + AdcpAgentsTimeoutError, AdcpAgentsValidationError, ) from adcp.validation import ValidationError, validate_adagents @@ -725,11 +726,28 @@ async def fetch_adcp_agents( Raises: AdcpAgentsNotFoundError: If adcp-agents.json not found (404) AdcpAgentsValidationError: If JSON is invalid or malformed - AdagentsTimeoutError: If request times out + AdcpAgentsTimeoutError: If request times out """ - from urllib.parse import urlparse as _urlparse + parsed = urlparse(agent_base_url.rstrip("/")) + + if parsed.scheme not in ("http", "https"): + raise AdcpAgentsValidationError( + f"agent_base_url must use http or https scheme, got {parsed.scheme!r}" + ) + + # SSRF protection: reject private/reserved IPs and localhost + hostname = parsed.hostname or "" + if hostname in ("localhost", "localhost.localdomain") or hostname.endswith(".local"): + raise AdcpAgentsValidationError("agent_base_url must not target localhost") + try: + ip = ipaddress.ip_address(hostname) + if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved: + raise AdcpAgentsValidationError( + "agent_base_url must not target private/reserved addresses" + ) + except ValueError: + pass # Not an IP literal — hostname is fine - parsed = _urlparse(agent_base_url.rstrip("/")) base = f"{parsed.scheme}://{parsed.netloc}" url = f"{base}/.well-known/adcp-agents.json" @@ -779,8 +797,8 @@ async def fetch_adcp_agents( return data except httpx.TimeoutException as exc: - raise AdagentsTimeoutError(parsed.netloc, timeout) from exc - except (AdcpAgentsNotFoundError, AdcpAgentsValidationError, AdagentsTimeoutError): + raise AdcpAgentsTimeoutError(parsed.netloc, timeout) from exc + except (AdcpAgentsNotFoundError, AdcpAgentsValidationError, AdcpAgentsTimeoutError): raise except httpx.RequestError as exc: raise AdcpAgentsValidationError( diff --git a/src/adcp/exceptions.py b/src/adcp/exceptions.py index ca0ac2826..2bd1e1836 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -301,6 +301,18 @@ def __init__(self, agent_domain: str): super().__init__(message, None, None, suggestion) +class AdcpAgentsTimeoutError(AdcpAgentsValidationError): + """Request for adcp-agents.json timed out.""" + + def __init__(self, agent_domain: str, timeout: float): + message = f"Request to fetch adcp-agents.json timed out after {timeout}s" + suggestion = ( + "The agent server may be slow or unresponsive.\n" + " Try increasing the timeout value or check the domain is correct." + ) + super().__init__(message, None, None, suggestion) + + class ADCPTaskError(ADCPError): """A task returned an ADCP error response. diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py index d5eb471a2..c83672779 100644 --- a/src/adcp/server/serve.py +++ b/src/adcp/server/serve.py @@ -18,6 +18,7 @@ async def get_adcp_capabilities(self, params, context=None): from __future__ import annotations +import json as _json import logging import os import warnings @@ -729,6 +730,31 @@ def _build_adcp_agents_doc( } +async def _send_adcp_agents_response( + handler: ADCPHandler[Any], + name: str, + advertise_all: bool, + send: Any, +) -> None: + """Send a 200 JSON response for the adcp-agents.json discovery document.""" + body = _json.dumps( + _build_adcp_agents_doc(handler, name, advertise_all), + separators=(",", ":"), + ).encode() + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [ + [b"content-type", b"application/json"], + [b"content-length", str(len(body)).encode()], + [b"cache-control", b"no-cache"], + ], + } + ) + await send({"type": "http.response.body", "body": body, "more_body": False}) + + def _wrap_with_adcp_agents_route( app: Any, handler: ADCPHandler[Any], @@ -741,7 +767,6 @@ def _wrap_with_adcp_agents_route( correctly in the wrapper stack) so discovery requests are always served unauthenticated per spec. """ - import json as _json async def _middleware(scope: Any, receive: Any, send: Any) -> None: if ( @@ -749,22 +774,7 @@ async def _middleware(scope: Any, receive: Any, send: Any) -> None: and scope.get("path") == "/.well-known/adcp-agents.json" and scope.get("method", "GET") == "GET" ): - body = _json.dumps( - _build_adcp_agents_doc(handler, name, advertise_all), - separators=(",", ":"), - ).encode() - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [ - [b"content-type", b"application/json"], - [b"content-length", str(len(body)).encode()], - [b"cache-control", b"no-cache"], - ], - } - ) - await send({"type": "http.response.body", "body": body, "more_body": False}) + await _send_adcp_agents_response(handler, name, advertise_all, send) return await app(scope, receive, send) @@ -1107,31 +1117,12 @@ async def _dispatch(scope: Scope, receive: Receive, send: Send) -> None: inner lifespans. """ if scope["type"] == "http": - import json as _json - path = scope.get("path", "") if ( path == "/.well-known/adcp-agents.json" and scope.get("method", "GET") == "GET" ): - body = _json.dumps( - _build_adcp_agents_doc(handler, name, advertise_all), - separators=(",", ":"), - ).encode() - await send( - { - "type": "http.response.start", - "status": 200, - "headers": [ - [b"content-type", b"application/json"], - [b"content-length", str(len(body)).encode()], - [b"cache-control", b"no-cache"], - ], - } - ) - await send( - {"type": "http.response.body", "body": body, "more_body": False} - ) + await _send_adcp_agents_response(handler, name, advertise_all, send) return if path == "/mcp" or path.startswith("/mcp/"): await mcp_app(scope, receive, send) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 831d1fc88..b5f28a743 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -29,6 +29,9 @@ "AdagentsNotFoundError", "AdagentsTimeoutError", "AdagentsValidationError", + "AdcpAgentsNotFoundError", + "AdcpAgentsTimeoutError", + "AdcpAgentsValidationError", "AdvertiserIndustry", "AgentCapabilities", "AgentCompliance", @@ -342,6 +345,7 @@ "domain_matches", "extract_webhook_result_data", "fetch_adagents", + "fetch_adcp_agents", "fetch_agent_authorizations", "generate_webhook_idempotency_key", "generated", diff --git a/tests/test_adcp_agents_discovery.py b/tests/test_adcp_agents_discovery.py index b9f1498ff..9b2b12ef6 100644 --- a/tests/test_adcp_agents_discovery.py +++ b/tests/test_adcp_agents_discovery.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +from typing import Any import pytest @@ -262,3 +263,69 @@ async def test_fetch_adcp_agents_404(monkeypatch): with pytest.raises(AdcpAgentsNotFoundError): await fetch_adcp_agents("https://seller.example.com") + + +@pytest.mark.asyncio +async def test_fetch_adcp_agents_timeout(monkeypatch): + """fetch_adcp_agents raises AdcpAgentsTimeoutError (not AdagentsTimeoutError) on timeout.""" + from unittest.mock import AsyncMock, MagicMock + + import httpx + + from adcp import fetch_adcp_agents + from adcp.exceptions import AdcpAgentsTimeoutError + + mock_client = MagicMock() + mock_client.get = AsyncMock(side_effect=httpx.TimeoutException("timed out")) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + + monkeypatch.setattr(httpx, "AsyncClient", lambda: mock_client) + + with pytest.raises(AdcpAgentsTimeoutError): + await fetch_adcp_agents("https://seller.example.com", timeout=5.0) + + +@pytest.mark.asyncio +async def test_fetch_adcp_agents_rejects_private_addresses(): + """fetch_adcp_agents raises AdcpAgentsValidationError for private/localhost targets (SSRF).""" + from adcp import fetch_adcp_agents + from adcp.exceptions import AdcpAgentsValidationError + + with pytest.raises(AdcpAgentsValidationError, match="localhost"): + await fetch_adcp_agents("https://localhost") + + with pytest.raises(AdcpAgentsValidationError, match="private"): + await fetch_adcp_agents("https://192.168.1.1") + + with pytest.raises(AdcpAgentsValidationError, match="private"): + await fetch_adcp_agents("https://10.0.0.1") + + +def test_auth_middleware_bypass_discovery(): + """BearerTokenAuthMiddleware does not block GET /.well-known/adcp-agents.json.""" + from adcp.server.auth import BearerTokenAuthMiddleware + + async def _reject_all(scope: Any, receive: Any, send: Any) -> None: + if scope["type"] == "lifespan": + while True: + msg = await receive() + if msg["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif msg["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + elif scope["type"] == "http": + await send({"type": "http.response.start", "status": 403, "headers": []}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + handler = _MinimalHandler() + discovery_app = _wrap_with_adcp_agents_route(_reject_all, handler, "auth-bypass-agent", False) + authed_app = BearerTokenAuthMiddleware(discovery_app, validate_token=lambda t: None) + + with TestClient(authed_app) as client: + resp = client.get(DISCOVERY_PATH) + + assert resp.status_code == 200 + doc = resp.json() + assert doc["agents"][0]["name"] == "auth-bypass-agent"