Skip to content
Draft
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
8 changes: 8 additions & 0 deletions src/adcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
AuthorizationContext,
domain_matches,
fetch_adagents,
fetch_adcp_agents,
fetch_agent_authorizations,
get_all_properties,
get_all_tags,
Expand All @@ -32,6 +33,9 @@
AdagentsNotFoundError,
AdagentsTimeoutError,
AdagentsValidationError,
AdcpAgentsNotFoundError,
AdcpAgentsTimeoutError,
AdcpAgentsValidationError,
ADCPAuthenticationError,
ADCPConnectionError,
ADCPError,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -839,6 +844,9 @@ def get_adcp_version() -> str:
"AdagentsValidationError",
"AdagentsNotFoundError",
"AdagentsTimeoutError",
"AdcpAgentsValidationError",
"AdcpAgentsNotFoundError",
"AdcpAgentsTimeoutError",
"ConfigurationError",
"RegistryError",
# Validation utilities
Expand Down
116 changes: 115 additions & 1 deletion src/adcp/adagents.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


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

Expand Down
28 changes: 28 additions & 0 deletions src/adcp/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 10 additions & 2 deletions src/adcp/server/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
98 changes: 98 additions & 0 deletions src/adcp/server/serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

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

Expand All @@ -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)

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions tests/fixtures/public_api_snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
"AdagentsNotFoundError",
"AdagentsTimeoutError",
"AdagentsValidationError",
"AdcpAgentsNotFoundError",
"AdcpAgentsTimeoutError",
"AdcpAgentsValidationError",
"AdvertiserIndustry",
"AgentCapabilities",
"AgentCompliance",
Expand Down Expand Up @@ -342,6 +345,7 @@
"domain_matches",
"extract_webhook_result_data",
"fetch_adagents",
"fetch_adcp_agents",
"fetch_agent_authorizations",
"generate_webhook_idempotency_key",
"generated",
Expand Down
Loading
Loading