diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 737680099..50bd205c0 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,9 @@ AdagentsNotFoundError, AdagentsTimeoutError, AdagentsValidationError, + AdcpAgentsNotFoundError, + AdcpAgentsTimeoutError, + AdcpAgentsValidationError, ADCPAuthenticationError, ADCPConnectionError, ADCPError, @@ -803,6 +807,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 +844,9 @@ def get_adcp_version() -> str: "AdagentsValidationError", "AdagentsNotFoundError", "AdagentsTimeoutError", + "AdcpAgentsValidationError", + "AdcpAgentsNotFoundError", + "AdcpAgentsTimeoutError", "ConfigurationError", "RegistryError", # Validation utilities diff --git a/src/adcp/adagents.py b/src/adcp/adagents.py index 3a7e03623..296318f54 100644 --- a/src/adcp/adagents.py +++ b/src/adcp/adagents.py @@ -14,7 +14,14 @@ import httpx -from adcp.exceptions import AdagentsNotFoundError, AdagentsTimeoutError, AdagentsValidationError +from adcp.exceptions import ( + AdagentsNotFoundError, + AdagentsTimeoutError, + AdagentsValidationError, + AdcpAgentsNotFoundError, + AdcpAgentsTimeoutError, + AdcpAgentsValidationError, +) from adcp.validation import ValidationError, validate_adagents @@ -692,6 +699,113 @@ 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 + AdcpAgentsTimeoutError: If request times out + """ + 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 + + 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 AdcpAgentsTimeoutError(parsed.netloc, timeout) from exc + except (AdcpAgentsNotFoundError, AdcpAgentsValidationError, AdcpAgentsTimeoutError): + 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..2bd1e1836 100644 --- a/src/adcp/exceptions.py +++ b/src/adcp/exceptions.py @@ -285,6 +285,34 @@ 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 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/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..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 @@ -700,6 +701,86 @@ 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, + } + ], + } + + +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], + 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. + """ + + 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" + ): + await _send_adcp_agents_response(handler, name, advertise_all, send) + 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 +889,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 +904,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 +929,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 +994,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 +1108,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 @@ -1026,6 +1118,12 @@ async def _dispatch(scope: Scope, receive: Receive, send: Send) -> None: """ if scope["type"] == "http": path = scope.get("path", "") + if ( + path == "/.well-known/adcp-agents.json" + and scope.get("method", "GET") == "GET" + ): + await _send_adcp_agents_response(handler, name, advertise_all, send) + return if path == "/mcp" or path.startswith("/mcp/"): await mcp_app(scope, receive, send) return 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 new file mode 100644 index 000000000..9b2b12ef6 --- /dev/null +++ b/tests/test_adcp_agents_discovery.py @@ -0,0 +1,331 @@ +"""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 +from typing import Any + +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") + + +@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"