diff --git a/README.md b/README.md
index 22c1dffa9..0a8b059c4 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,32 @@ serve(MySeller(), name="my-seller", test_controller=MyStore())
Each skill file in [`skills/`](skills/) contains the complete pattern, response shapes, and validation loop for coding agents (Claude, Codex) to generate passing servers.
+### Multi-agent discovery manifest
+
+Every HTTP transport (`streamable-http`, `a2a`, `both`) automatically
+serves the AdCP multi-agent topology manifest at
+`/.well-known/adcp-agents.json`. Buyers, conformance runners, and
+tooling fetch this once per origin to discover which agents the host
+serves and over which transports — no out-of-band configuration.
+
+```bash
+curl http://localhost:3001/.well-known/adcp-agents.json
+```
+
+Set `base_url`, `specialisms`, and `description` to populate the
+manifest with your public origin and AdCP specialisms:
+
+```python
+serve(
+ MySeller(),
+ name="my-seller",
+ transport="both",
+ base_url="https://sales.example.com",
+ specialisms=["sales-non-guaranteed", "sales-guaranteed"],
+ description="Premium publisher inventory.",
+)
+```
+
## Connecting to AdCP Agents
## The Core Concept
diff --git a/src/adcp/server/__init__.py b/src/adcp/server/__init__.py
index 8b5194a14..54de4a748 100644
--- a/src/adcp/server/__init__.py
+++ b/src/adcp/server/__init__.py
@@ -76,6 +76,11 @@ async def get_products(params, context=None):
from adcp.server.builder import ADCPServerBuilder, adcp_server
from adcp.server.compliance import ComplianceHandler
from adcp.server.content_standards import ContentStandardsHandler
+from adcp.server.discovery import (
+ DISCOVERY_PATH,
+ build_manifest,
+ make_discovery_route,
+)
from adcp.server.governance import GovernanceHandler
from adcp.server.helpers import ( # noqa: F401
CORRECTABLE_CODES,
@@ -201,6 +206,10 @@ async def get_products(params, context=None):
"SubdomainTenantRouter",
"Tenant",
"current_tenant",
+ # Multi-agent discovery manifest (/.well-known/adcp-agents.json)
+ "DISCOVERY_PATH",
+ "build_manifest",
+ "make_discovery_route",
# Test controller
"TestControllerStore",
"TestControllerError",
diff --git a/src/adcp/server/discovery.py b/src/adcp/server/discovery.py
new file mode 100644
index 000000000..172b94918
--- /dev/null
+++ b/src/adcp/server/discovery.py
@@ -0,0 +1,261 @@
+"""Multi-agent topology manifest served at ``/.well-known/adcp-agents.json``.
+
+Per AdCP spec (``schemas/source/adcp-agents.json``) every AdCP host
+publishes an origin-scoped manifest enumerating the agents it serves.
+Buyers, conformance runners, and tooling fetch the well-known URL once
+and discover the full topology of the publisher in a single request,
+instead of probing tenant URLs out of band.
+
+This module owns:
+
+1. :func:`build_manifest` — a pure function that produces the manifest
+ document from the configured handler name + transports + bind
+ coordinates. Easy to unit-test, no Starlette dependency.
+2. :func:`make_discovery_route` — wires the document into a Starlette
+ :class:`~starlette.routing.Route` so the SDK's ``serve()`` can
+ compose it onto every HTTP transport (``streamable-http``, ``a2a``,
+ ``both``).
+
+Stdio has no HTTP surface and skips the route entirely.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime, timezone
+from typing import Any, Literal
+
+from starlette.requests import Request
+from starlette.responses import JSONResponse
+from starlette.routing import Route
+
+#: Path the manifest is served at. Per AdCP spec — operators MUST NOT
+#: change this; consumers fetch from the well-known location only.
+DISCOVERY_PATH = "/.well-known/adcp-agents.json"
+
+#: Manifest schema version this builder emits. Consumers SHOULD ignore
+#: unknown top-level fields rather than fail on version mismatch (per
+#: spec), so bumping minor versions is safe.
+MANIFEST_VERSION = "1.0"
+
+#: ``$schema`` URI emitted in the manifest. Matches the canonical
+#: location consumers use for validation.
+MANIFEST_SCHEMA_URI = "/schemas/adcp-agents.json"
+
+
+Transport = Literal["mcp", "a2a"]
+
+
+def _normalize_agent_id(name: str) -> str:
+ """Coerce a human-friendly handler name to a manifest-legal
+ ``agent_id``.
+
+ The schema requires lowercase alphanumeric with hyphens/underscores,
+ no leading/trailing separators, 1-64 characters. Most adopters pass
+ something like ``"My Seller"`` to ``serve(name=...)``; lower-case it
+ and replace illegal runs with ``-``. Falls back to ``"agent"`` if
+ the input lowers to nothing legal (defensive — empty / all-symbol
+ names would otherwise produce an invalid manifest).
+ """
+ out: list[str] = []
+ for ch in name.lower():
+ if ch.isalnum() or ch in ("-", "_"):
+ out.append(ch)
+ else:
+ out.append("-")
+ cleaned = "".join(out).strip("-_")
+ # Collapse runs of separators — looks better and stays under the
+ # 64-char cap on long names. Strip BEFORE the length cap so the
+ # truncation never lands on a separator that would be stripped
+ # away (which would make ``agent_id`` len differ from len(cleaned)
+ # in surprising ways).
+ while "--" in cleaned:
+ cleaned = cleaned.replace("--", "-")
+ while "__" in cleaned:
+ cleaned = cleaned.replace("__", "_")
+ cleaned = cleaned.strip("-_")
+ if not cleaned:
+ return "agent"
+ return cleaned[:64].strip("-_") or "agent"
+
+
+def _agent_url(transport: Transport, base_url: str) -> str:
+ """Return the agent endpoint URL for a given transport.
+
+ For ``mcp`` the streamable-HTTP endpoint lives at ``/mcp``. For
+ ``a2a`` the agent's base URL is the root — the agent-card lives at
+ ``/.well-known/agent-card.json``.
+ """
+ base = base_url.rstrip("/")
+ if transport == "mcp":
+ return f"{base}/mcp"
+ return base or "/"
+
+
+def build_manifest(
+ *,
+ name: str,
+ transports: list[Transport],
+ base_url: str,
+ description: str | None = None,
+ specialisms: list[str] | None = None,
+) -> dict[str, Any]:
+ """Build the AdCP multi-agent topology manifest document.
+
+ Pure function — no I/O, no globals — so it's trivial to unit-test
+ and reuse in adopter tooling that wants to publish a static
+ manifest from CI.
+
+ :param name: Operator-supplied agent / platform name. Becomes the
+ ``agent_id`` (after normalization to the schema's character
+ class) and informs the contact ``name`` field.
+ :param transports: Transports the binary serves. ``["mcp"]``,
+ ``["a2a"]``, or ``["mcp", "a2a"]`` for ``transport="both"``.
+ One manifest entry is emitted per transport — buyers route by
+ transport, so each gets its own row even when they share a
+ process.
+ :param base_url: Origin the binary is reachable at, e.g.
+ ``"https://sales.example.com"``. The manifest URL is built as
+ ``/mcp`` for MCP and ```` for A2A.
+ :param description: Optional human-readable description surfaced in
+ operator UIs and conformance reports.
+ :param specialisms: Optional AdCP specialisms (e.g.
+ ``["sales-non-guaranteed"]``). The schema requires ``minItems:
+ 1`` so when nothing is supplied we fall back to a minimal
+ ``["adcp"]`` placeholder. Adopters who know their specialism
+ SHOULD pass it explicitly.
+ """
+ # TODO(#381): infer specialisms from the handler's advertised
+ # tools (e.g. presence of ``get_products`` → sales-non-guaranteed).
+ # For now adopters pass them explicitly or accept the placeholder.
+ effective_specialisms = list(specialisms) if specialisms else ["adcp"]
+
+ base_id = _normalize_agent_id(name)
+ agents: list[dict[str, Any]] = []
+ for transport in transports:
+ # When emitting two rows from the same binary the schema requires
+ # unique agent_ids — suffix with the transport so ``foo-mcp`` and
+ # ``foo-a2a`` are both legal and self-describing.
+ agent_id = f"{base_id}-{transport}" if len(transports) > 1 else base_id
+ entry: dict[str, Any] = {
+ "agent_id": agent_id,
+ "url": _agent_url(transport, base_url),
+ "transport": transport,
+ "specialisms": effective_specialisms,
+ }
+ if description:
+ entry["description"] = description
+ agents.append(entry)
+
+ # Truncate to whole-hour granularity so consecutive requests within
+ # the same hour produce byte-identical manifests — lets HTTP caches
+ # (CDNs, conformance runners polling on a loop) collapse repeated
+ # fetches instead of seeing a fresh second-resolution timestamp on
+ # every hit. Hour-resolution is well within the spec's "informational
+ # only" semantics for ``last_updated``.
+ last_updated = (
+ datetime.now(timezone.utc)
+ .replace(minute=0, second=0, microsecond=0)
+ .strftime("%Y-%m-%dT%H:%M:%SZ")
+ )
+ manifest: dict[str, Any] = {
+ "$schema": MANIFEST_SCHEMA_URI,
+ "version": MANIFEST_VERSION,
+ "agents": agents,
+ "last_updated": last_updated,
+ }
+ if name:
+ manifest["contact"] = {"name": name}
+ return manifest
+
+
+def make_discovery_route(
+ *,
+ name: str,
+ transports: list[Transport],
+ base_url: str,
+ description: str | None = None,
+ specialisms: list[str] | None = None,
+) -> Route:
+ """Build a Starlette :class:`Route` serving the discovery manifest.
+
+ The route is GET-only — POST / PUT / etc. fall through to
+ Starlette's default 405 handler, which is the correct behavior for
+ a read-only, unauthenticated discovery document.
+
+ The manifest is rebuilt per request so ``last_updated`` reflects
+ the current time. The build is cheap (a few hundred bytes of JSON),
+ well below the noise floor of any production traffic.
+ """
+
+ async def _handler(_request: Request) -> JSONResponse:
+ manifest = build_manifest(
+ name=name,
+ transports=transports,
+ base_url=base_url,
+ description=description,
+ specialisms=specialisms,
+ )
+ return JSONResponse(manifest)
+
+ return Route(DISCOVERY_PATH, _handler, methods=["GET"])
+
+
+#: Hosts the spec lets us project as ``http://`` — the AdCP discovery
+#: schema's ``url`` field requires ``^https://`` for non-loopback
+#: targets, but consumers MAY accept ``http://`` for literal localhost
+#: so a dev binary works without TLS scaffolding.
+_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
+
+
+def resolve_base_url(host: str, port: int, base_url: str | None = None) -> str:
+ """Construct an origin URL from a bound host/port pair, enforcing
+ the spec's ``https://`` requirement for non-loopback targets.
+
+ The AdCP discovery schema requires ``url`` to match ``^https://``
+ on every agent entry; the only documented exception is loopback
+ (``127.0.0.1`` / ``localhost`` / ``::1``) for dev binaries that
+ haven't terminated TLS yet. This resolver therefore:
+
+ * Projects ``0.0.0.0`` (wildcard) to ``http://127.0.0.1:`` —
+ it's a dev-only convenience and the projection IS loopback.
+ * Returns ``http://:`` for literal loopback hosts.
+ * Pass-through any caller-supplied ``base_url`` that already starts
+ with ``https://``.
+ * Raises :class:`ValueError` for non-loopback binds without an
+ explicit ``base_url=`` (operator MUST publish a TLS URL), and for
+ explicit ``base_url=`` that uses ``http://`` against a non-
+ loopback host (refuse to publish a non-conformant manifest).
+
+ Raise-at-boot is deliberate: a quietly-mis-published manifest
+ survives in CDNs and conformance reports for hours, so we make the
+ operator notice on launch instead.
+ """
+ is_loopback = host in _LOOPBACK_HOSTS or host in ("0.0.0.0", "::", "")
+
+ if base_url is not None:
+ if base_url.startswith("https://"):
+ return base_url
+ if base_url.startswith("http://") and is_loopback:
+ return base_url
+ raise ValueError(
+ "Discovery manifest requires an https:// base_url for non-"
+ f"localhost binds (got base_url={base_url!r}, host={host!r}). "
+ "The AdCP discovery schema mandates https:// on every "
+ "agent entry — pass base_url='https://your-host:port' to "
+ "serve()."
+ )
+
+ if not is_loopback:
+ raise ValueError(
+ "Discovery manifest requires base_url= for non-localhost "
+ f"binds (host={host!r}); the AdCP discovery schema mandates "
+ "https:// URLs and the SDK won't synthesize an http:// URL "
+ "for a routable interface. Pass base_url='https://your-"
+ "host:port' to serve()."
+ )
+
+ # ``0.0.0.0`` is a wildcard bind, not a routable origin. Project to
+ # localhost so a default-config dev binary serves a usable manifest
+ # for local testing.
+ display_host = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
+ return f"http://{display_host}:{port}"
diff --git a/src/adcp/server/serve.py b/src/adcp/server/serve.py
index 68e7f84d0..acbf50cf0 100644
--- a/src/adcp/server/serve.py
+++ b/src/adcp/server/serve.py
@@ -416,6 +416,9 @@ def serve(
validation: ValidationHookConfig | None = None,
enable_debug_endpoints: bool = False,
debug_traffic_source: Callable[[], dict[str, int]] | None = None,
+ base_url: str | None = None,
+ specialisms: list[str] | None = None,
+ description: str | None = None,
) -> None:
"""Start an MCP or A2A server from an ADCP handler or server builder.
@@ -525,6 +528,21 @@ def serve(
per-method count snapshot for ``/_debug/traffic``. Required
when ``enable_debug_endpoints=True``; otherwise ignored.
Typically ``mock_ad_server.get_traffic``.
+ base_url: Optional public origin URL for the binary, used to
+ populate the ``url`` field of each entry in the
+ ``/.well-known/adcp-agents.json`` discovery manifest.
+ Adopters behind a TLS-terminating reverse proxy SHOULD set
+ this (e.g. ``"https://sales.example.com"``). When ``None``
+ the manifest URLs fall back to ``http://:``,
+ which is correct for local development but wrong for
+ production.
+ specialisms: Optional list of AdCP specialism tags surfaced in
+ the discovery manifest (e.g. ``["sales-non-guaranteed"]``).
+ See :data:`adcp.server.discovery` for the full list.
+ Defaults to a placeholder when omitted — adopters who know
+ their specialism SHOULD pass it.
+ description: Optional human-readable description surfaced in
+ the discovery manifest's per-agent ``description`` field.
validation: Optional :class:`ValidationHookConfig` enabling
schema validation of every request and response against the
bundled AdCP JSON schemas. ``requests="strict"`` raises
@@ -600,6 +618,9 @@ async def force_account_status(self, account_id, status):
advertise_all=advertise_all,
max_request_size=max_request_size,
validation=validation,
+ base_url=base_url,
+ specialisms=specialisms,
+ description=description,
)
elif transport in ("streamable-http", "sse", "stdio"):
_serve_mcp(
@@ -617,6 +638,9 @@ async def force_account_status(self, account_id, status):
max_request_size=max_request_size,
streaming_responses=streaming_responses,
validation=validation,
+ base_url=base_url,
+ specialisms=specialisms,
+ description=description,
)
elif transport == "both":
_serve_mcp_and_a2a(
@@ -636,6 +660,9 @@ async def force_account_status(self, account_id, status):
max_request_size=max_request_size,
streaming_responses=streaming_responses,
validation=validation,
+ base_url=base_url,
+ specialisms=specialisms,
+ description=description,
)
else:
valid = ", ".join(sorted(("a2a", "both", "streamable-http", "sse", "stdio")))
@@ -699,6 +726,55 @@ def _apply_asgi_middleware(
return app
+def _wrap_with_discovery(
+ app: Any,
+ *,
+ name: str,
+ transports: list[Literal["mcp", "a2a"]],
+ base_url: str,
+ description: str | None = None,
+ specialisms: list[str] | None = None,
+) -> Any:
+ """Wrap an ASGI app to serve ``/.well-known/adcp-agents.json``.
+
+ Intercepts the discovery path and serves the AdCP multi-agent
+ topology manifest; every other request passes through unchanged.
+ Sits outside the inner transport apps (FastMCP / a2a-sdk Starlette)
+ so adding the route doesn't require monkey-patching either upstream.
+
+ GET returns the manifest as JSON; non-GET methods at the discovery
+ path 404 back to the inner app — letting the inner Starlette
+ return its standard 405 / 404 keeps the well-known surface
+ read-only without baking method-policy into this wrapper.
+ """
+ from adcp.server.discovery import (
+ DISCOVERY_PATH,
+ build_manifest,
+ )
+
+ async def _middleware(scope: Any, receive: Any, send: Any) -> None:
+ if (
+ scope.get("type") == "http"
+ and scope.get("path") == DISCOVERY_PATH
+ and scope.get("method") == "GET"
+ ):
+ from starlette.responses import JSONResponse
+
+ manifest = build_manifest(
+ name=name,
+ transports=transports,
+ base_url=base_url,
+ description=description,
+ specialisms=specialisms,
+ )
+ response = JSONResponse(manifest)
+ await response(scope, receive, send)
+ return
+ await app(scope, receive, send)
+
+ return _middleware
+
+
def _wrap_with_path_normalize(app: Any) -> Any:
"""Wrap an ASGI app so trailing-slash variants of the same path
route to the same handler instead of returning 307.
@@ -861,6 +937,9 @@ def _serve_mcp(
max_request_size: int | None = None,
streaming_responses: bool = False,
validation: ValidationHookConfig | None = None,
+ base_url: str | None = None,
+ specialisms: list[str] | None = None,
+ description: str | None = None,
) -> None:
"""Start an MCP server."""
mcp = create_mcp_server(
@@ -888,6 +967,10 @@ def _serve_mcp(
transport=transport,
max_request_size=max_request_size,
asgi_middleware=asgi_middleware,
+ discovery_name=name,
+ discovery_base_url=base_url,
+ discovery_specialisms=specialisms,
+ discovery_description=description,
)
else:
# stdio — no listening socket, nothing to configure.
@@ -900,6 +983,10 @@ def _run_mcp_http(
transport: str,
max_request_size: int | None = None,
asgi_middleware: Sequence[tuple[type, dict[str, Any]]] | None = None,
+ discovery_name: str = "adcp-agent",
+ discovery_base_url: str | None = None,
+ discovery_specialisms: list[str] | None = None,
+ discovery_description: str | None = None,
) -> None:
"""Run FastMCP's HTTP transports with a pre-bound SO_REUSEADDR socket.
@@ -921,7 +1008,19 @@ def _run_mcp_http(
else:
app = mcp.sse_app()
+ from adcp.server.discovery import resolve_base_url
+
+ resolved_base_url = resolve_base_url(host, port, discovery_base_url)
+
app = _wrap_with_path_normalize(app)
+ app = _wrap_with_discovery(
+ app,
+ name=discovery_name,
+ transports=["mcp"],
+ base_url=resolved_base_url,
+ description=discovery_description,
+ specialisms=discovery_specialisms,
+ )
app = _wrap_with_size_limit(app, max_request_size)
app = _apply_asgi_middleware(app, asgi_middleware)
@@ -966,13 +1065,18 @@ def _serve_a2a(
advertise_all: bool = False,
max_request_size: int | None = None,
validation: ValidationHookConfig | None = None,
+ base_url: str | None = None,
+ specialisms: list[str] | None = None,
+ description: str | None = None,
) -> None:
"""Start an A2A server using uvicorn."""
import uvicorn
from adcp.server.a2a_server import create_a2a_server
+ from adcp.server.discovery import resolve_base_url
resolved_port = port or int(os.environ.get("PORT", "3001"))
+ resolved_base_url = resolve_base_url("0.0.0.0", resolved_port, base_url)
app = create_a2a_server(
handler,
@@ -987,6 +1091,14 @@ def _serve_a2a(
advertise_all=advertise_all,
validation=validation,
)
+ app = _wrap_with_discovery(
+ app,
+ name=name,
+ transports=["a2a"],
+ base_url=resolved_base_url,
+ description=description,
+ specialisms=specialisms,
+ )
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)
@@ -1024,6 +1136,9 @@ def _build_mcp_and_a2a_app(
max_request_size: int | None = None,
streaming_responses: bool = False,
validation: ValidationHookConfig | None = None,
+ base_url: str | None = None,
+ specialisms: list[str] | None = None,
+ description: str | None = None,
) -> Any:
"""Build the unified MCP+A2A ASGI app without starting a server.
@@ -1126,6 +1241,17 @@ async def _dispatch(scope: Scope, receive: Receive, send: Send) -> None:
await a2a_app(scope, receive, send)
app: ASGIApp = _dispatch
+ from adcp.server.discovery import resolve_base_url
+
+ resolved_base_url = resolve_base_url(host, port, base_url)
+ app = _wrap_with_discovery(
+ app,
+ name=name,
+ transports=["mcp", "a2a"],
+ base_url=resolved_base_url,
+ description=description,
+ specialisms=specialisms,
+ )
return _wrap_with_size_limit(app, max_request_size)
@@ -1147,6 +1273,9 @@ def _serve_mcp_and_a2a(
max_request_size: int | None = None,
streaming_responses: bool = False,
validation: ValidationHookConfig | None = None,
+ base_url: str | None = None,
+ specialisms: list[str] | None = None,
+ description: str | None = None,
) -> None:
"""Serve MCP and A2A on a single port via path dispatch.
@@ -1185,6 +1314,9 @@ def _serve_mcp_and_a2a(
max_request_size=max_request_size,
streaming_responses=streaming_responses,
validation=validation,
+ base_url=base_url,
+ specialisms=specialisms,
+ description=description,
)
app = _apply_asgi_middleware(app, asgi_middleware)
diff --git a/tests/test_discovery_endpoint.py b/tests/test_discovery_endpoint.py
new file mode 100644
index 000000000..5ba14358c
--- /dev/null
+++ b/tests/test_discovery_endpoint.py
@@ -0,0 +1,409 @@
+"""Tests for ``/.well-known/adcp-agents.json`` discovery endpoint.
+
+Per AdCP spec (``schemas/source/adcp-agents.json``), every AdCP host
+publishes an origin-scoped manifest enumerating the agents it serves.
+The SDK's ``serve()`` exposes this on every HTTP transport
+(``streamable-http``, ``a2a``, ``both``); ``stdio`` has no HTTP surface
+and skips the route.
+
+Coverage:
+
+* GET on each transport returns 200 with a schema-conformant manifest.
+* Non-GET methods at the discovery path do not serve the manifest.
+* The :func:`build_manifest` builder is pure and validates standalone.
+* agent_id normalization handles whitespace / mixed-case input names.
+"""
+
+from __future__ import annotations
+
+import json
+
+import jsonschema
+import pytest
+
+starlette = pytest.importorskip("starlette")
+
+from starlette.applications import Starlette
+from starlette.testclient import TestClient
+
+from adcp.server import ADCPHandler, ToolContext
+from adcp.server.discovery import (
+ DISCOVERY_PATH,
+ build_manifest,
+ make_discovery_route,
+ resolve_base_url,
+)
+from adcp.server.responses import capabilities_response
+from adcp.server.serve import (
+ _build_mcp_and_a2a_app,
+ _wrap_with_discovery,
+)
+
+# Inline copy of the AdCP discovery schema (PR #3903 / spec
+# adcontextprotocol/adcp@5c3e3e626). Inlined rather than fetched so
+# tests stay deterministic and offline. Update when the upstream
+# schema bumps.
+_DISCOVERY_SCHEMA: dict = {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "/schemas/adcp-agents.json",
+ "title": "AdCP Multi-Agent Topology Manifest",
+ "type": "object",
+ "properties": {
+ "$schema": {"type": "string"},
+ "version": {
+ "type": "string",
+ "pattern": r"^[0-9]+\.[0-9]+(\.[0-9]+)?$",
+ },
+ "agents": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "agent_id": {
+ "type": "string",
+ "pattern": r"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$",
+ "minLength": 1,
+ "maxLength": 64,
+ },
+ "url": {
+ "type": "string",
+ "format": "uri",
+ "minLength": 1,
+ "pattern": r"^https://",
+ },
+ "transport": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ },
+ "specialisms": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ },
+ "minItems": 1,
+ "maxItems": 64,
+ "uniqueItems": True,
+ },
+ "auth_hint": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 64,
+ },
+ "description": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500,
+ },
+ },
+ "required": ["agent_id", "url", "transport", "specialisms"],
+ "additionalProperties": True,
+ },
+ "minItems": 1,
+ "maxItems": 256,
+ },
+ "contact": {
+ "type": "object",
+ "properties": {
+ "name": {"type": "string", "minLength": 1, "maxLength": 255},
+ "email": {"type": "string", "format": "email"},
+ "url": {"type": "string", "format": "uri"},
+ },
+ "required": ["name"],
+ "additionalProperties": True,
+ },
+ "last_updated": {"type": "string", "format": "date-time"},
+ },
+ "required": ["version", "agents"],
+ "additionalProperties": True,
+}
+
+
+def _validate_manifest(payload: dict) -> None:
+ """Raise on schema-mismatch — pytest reports the JSON-Pointer."""
+ jsonschema.validate(payload, _DISCOVERY_SCHEMA)
+
+
+# ----- build_manifest pure builder --------------------------------------
+
+
+def test_build_manifest_minimal_validates() -> None:
+ """Default-args manifest matches the spec — placeholder specialism
+ keeps the schema's ``minItems: 1`` constraint happy without forcing
+ every adopter to declare specialisms before they can publish."""
+ manifest = build_manifest(
+ name="my-agent",
+ transports=["mcp"],
+ base_url="https://example.com",
+ )
+ _validate_manifest(manifest)
+ assert manifest["agents"][0]["agent_id"] == "my-agent"
+ assert manifest["agents"][0]["url"] == "https://example.com/mcp"
+ assert manifest["agents"][0]["transport"] == "mcp"
+
+
+def test_build_manifest_both_transports_unique_agent_ids() -> None:
+ """When the binary serves both transports, the manifest emits two
+ rows with distinct agent_ids — required by readers that key on
+ agent_id, and required by the schema's no-duplicate-id contract."""
+ manifest = build_manifest(
+ name="seller",
+ transports=["mcp", "a2a"],
+ base_url="https://seller.example.com",
+ specialisms=["sales-non-guaranteed"],
+ )
+ _validate_manifest(manifest)
+ ids = [a["agent_id"] for a in manifest["agents"]]
+ assert ids == ["seller-mcp", "seller-a2a"]
+ transports = [a["transport"] for a in manifest["agents"]]
+ assert transports == ["mcp", "a2a"]
+
+
+def test_build_manifest_normalizes_agent_id() -> None:
+ """Human-friendly names with spaces / mixed case become legal
+ agent_ids — the schema's character class is lowercase + hyphens
+ + underscores only, so we coerce eagerly rather than reject."""
+ manifest = build_manifest(
+ name="My Cool Seller!",
+ transports=["mcp"],
+ base_url="https://example.com",
+ )
+ _validate_manifest(manifest)
+ assert manifest["agents"][0]["agent_id"] == "my-cool-seller"
+
+
+def test_build_manifest_a2a_url_has_no_mcp_suffix() -> None:
+ """A2A's url is the agent's base URL (the agent-card lives at
+ ``/.well-known/agent-card.json``); the ``/mcp`` suffix only
+ applies to the MCP transport."""
+ manifest = build_manifest(
+ name="a2a-agent",
+ transports=["a2a"],
+ base_url="https://example.com",
+ )
+ _validate_manifest(manifest)
+ assert manifest["agents"][0]["url"] == "https://example.com"
+
+
+def test_build_manifest_passes_through_specialisms_and_description() -> None:
+ """Operator-supplied specialisms + description appear verbatim in
+ the manifest entry — buyers route on these and can't infer them."""
+ manifest = build_manifest(
+ name="seller",
+ transports=["mcp"],
+ base_url="https://example.com",
+ description="A handcrafted ad agent.",
+ specialisms=["sales-guaranteed", "sales-non-guaranteed"],
+ )
+ _validate_manifest(manifest)
+ entry = manifest["agents"][0]
+ assert entry["specialisms"] == ["sales-guaranteed", "sales-non-guaranteed"]
+ assert entry["description"] == "A handcrafted ad agent."
+
+
+def test_resolve_base_url_projects_wildcard_to_localhost() -> None:
+ """``0.0.0.0`` is a wildcard bind, not a routable URL — the manifest
+ uses a localhost projection so a default-config dev binary serves
+ a usable URL for local testing. Loopback hosts keep ``http://``
+ because the schema allows that exception."""
+ assert resolve_base_url("0.0.0.0", 3001) == "http://127.0.0.1:3001"
+ assert resolve_base_url("127.0.0.1", 3001) == "http://127.0.0.1:3001"
+ assert resolve_base_url("localhost", 3001) == "http://localhost:3001"
+
+
+def test_resolve_base_url_rejects_non_localhost_without_base_url() -> None:
+ """The discovery schema mandates ``https://`` on every agent ``url``;
+ the SDK refuses to synthesize an ``http://`` URL for a routable
+ interface so the operator notices at boot rather than publishing a
+ non-conformant manifest to a CDN."""
+ with pytest.raises(ValueError, match="base_url"):
+ resolve_base_url("my-internal.example.com", 8080)
+
+
+def test_resolve_base_url_passes_through_https_base_url() -> None:
+ """Operator-supplied ``https://`` URLs are returned verbatim — no
+ re-projection through host/port, since the public origin frequently
+ differs from the bound socket (TLS terminates upstream)."""
+ assert (
+ resolve_base_url("0.0.0.0", 3001, base_url="https://sales.example.com")
+ == "https://sales.example.com"
+ )
+ assert (
+ resolve_base_url(
+ "my-internal.example.com",
+ 8080,
+ base_url="https://sales.example.com",
+ )
+ == "https://sales.example.com"
+ )
+
+
+def test_resolve_base_url_rejects_http_base_url_for_non_loopback() -> None:
+ """An explicit ``http://`` ``base_url`` against a non-loopback host
+ is a configuration mistake — the manifest would publish an URL
+ that fails the schema's ``^https://`` pattern, so we raise instead
+ of silently emitting it."""
+ with pytest.raises(ValueError, match="https://"):
+ resolve_base_url(
+ "my-internal.example.com",
+ 8080,
+ base_url="http://my-internal.example.com:8080",
+ )
+
+
+def test_resolve_base_url_allows_http_base_url_for_loopback() -> None:
+ """An explicit ``http://localhost`` ``base_url`` is fine — the
+ schema's documented loopback exception covers it, and adopters
+ sometimes set this to override the default 127.0.0.1 projection."""
+ assert (
+ resolve_base_url("127.0.0.1", 3001, base_url="http://localhost:3001")
+ == "http://localhost:3001"
+ )
+
+
+# ----- ASGI integration --------------------------------------------------
+
+
+class _DiscoveryTestHandler(ADCPHandler[ToolContext]):
+ """Minimal handler — discovery endpoint serves regardless of the
+ handler's tool surface, but we need a real one to build the apps."""
+
+ async def get_adcp_capabilities(self, params, context=None):
+ return capabilities_response(["media_buy"])
+
+
+def _inner_404_app() -> Starlette:
+ """Bare Starlette app — Inner 404s confirm the discovery wrapper
+ only intercepts the well-known path and lets everything else fall
+ through unchanged."""
+ return Starlette()
+
+
+def test_discovery_route_serves_get_with_valid_json() -> None:
+ """A standalone Route built by :func:`make_discovery_route` returns
+ a schema-valid JSON document — exercises the route in isolation
+ without the full transport stack."""
+ route = make_discovery_route(
+ name="standalone",
+ transports=["mcp"],
+ base_url="https://example.com",
+ )
+ app = Starlette(routes=[route])
+ with TestClient(app) as client:
+ resp = client.get(DISCOVERY_PATH)
+ assert resp.status_code == 200
+ assert resp.headers["content-type"].startswith("application/json")
+ payload = resp.json()
+ _validate_manifest(payload)
+
+
+def test_discovery_route_rejects_post() -> None:
+ """The discovery endpoint is read-only — POST returns 405,
+ confirming Starlette's default method-not-allowed handler covers
+ the unauthenticated public surface."""
+ route = make_discovery_route(
+ name="standalone",
+ transports=["mcp"],
+ base_url="https://example.com",
+ )
+ app = Starlette(routes=[route])
+ with TestClient(app) as client:
+ resp = client.post(DISCOVERY_PATH, json={})
+ assert resp.status_code == 405
+
+
+def test_wrap_with_discovery_on_streamable_http_transport() -> None:
+ """The MCP streamable-http wrapper serves the manifest at the
+ well-known path; non-discovery requests fall through to the inner
+ app unchanged."""
+ inner = _inner_404_app()
+ wrapped = _wrap_with_discovery(
+ inner,
+ name="mcp-only",
+ transports=["mcp"],
+ base_url="https://mcp.example.com",
+ )
+ with TestClient(wrapped) as client:
+ resp = client.get(DISCOVERY_PATH)
+ assert resp.status_code == 200
+ payload = resp.json()
+ _validate_manifest(payload)
+ assert payload["agents"][0]["transport"] == "mcp"
+
+ # Non-discovery requests pass through unchanged.
+ passthrough = client.get("/mcp")
+ assert passthrough.status_code == 404
+
+
+def test_wrap_with_discovery_on_a2a_transport() -> None:
+ """The A2A wrapper serves the manifest with ``transport: "a2a"``
+ and a base URL that has no ``/mcp`` suffix."""
+ inner = _inner_404_app()
+ wrapped = _wrap_with_discovery(
+ inner,
+ name="a2a-only",
+ transports=["a2a"],
+ base_url="https://a2a.example.com",
+ )
+ with TestClient(wrapped) as client:
+ resp = client.get(DISCOVERY_PATH)
+ assert resp.status_code == 200
+ payload = resp.json()
+ _validate_manifest(payload)
+ assert payload["agents"][0]["transport"] == "a2a"
+ assert payload["agents"][0]["url"] == "https://a2a.example.com"
+
+
+def test_discovery_endpoint_on_unified_transport() -> None:
+ """``transport="both"`` exposes a manifest with two agent rows —
+ one for MCP, one for A2A — at the same well-known path that
+ standalone transports use."""
+ app = _build_mcp_and_a2a_app(
+ _DiscoveryTestHandler(),
+ name="unified",
+ port=3001,
+ host="127.0.0.1",
+ instructions=None,
+ test_controller=None,
+ base_url="https://unified.example.com",
+ specialisms=["sales-non-guaranteed"],
+ )
+ with TestClient(app) as client:
+ resp = client.get(DISCOVERY_PATH)
+ assert resp.status_code == 200
+ payload = resp.json()
+ _validate_manifest(payload)
+ transports_seen = {a["transport"] for a in payload["agents"]}
+ assert transports_seen == {"mcp", "a2a"}
+ urls = {a["url"] for a in payload["agents"]}
+ assert urls == {
+ "https://unified.example.com/mcp",
+ "https://unified.example.com",
+ }
+
+
+def test_discovery_endpoint_post_falls_through_on_unified() -> None:
+ """POST to ``/.well-known/adcp-agents.json`` is not the discovery
+ GET — the wrapper must NOT serve the manifest on POST, leaving
+ the inner transport apps to return their own response."""
+ app = _build_mcp_and_a2a_app(
+ _DiscoveryTestHandler(),
+ name="unified",
+ port=3001,
+ host="127.0.0.1",
+ instructions=None,
+ test_controller=None,
+ )
+ with TestClient(app) as client:
+ resp = client.post(DISCOVERY_PATH, json={})
+ # The wrapper passes through to A2A (which doesn't route this
+ # path); the response MUST NOT be the manifest. Anything other
+ # than 200-with-manifest-body is acceptable here.
+ if resp.status_code == 200:
+ body = json.loads(resp.text)
+ assert "agents" not in body, (
+ "POST should not return the discovery manifest — wrapper "
+ "leaked GET response on POST."
+ )