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." + )