Skip to content

Commit 5c3e3e6

Browse files
bokelleyclaude
andauthored
spec: /.well-known/adcp-agents.json for multi-agent topology discovery (closes #3895) (#3903)
Adds an origin-scoped well-known manifest enumerating every AdCP agent served from a host. Replaces the publisher-side "Notion page or CSV" hop with a single fetch returning agent_id, url, transport, specialisms[], and an optional informational auth_hint per agent. Schema (static/schemas/source/adcp-agents.json) is registered next to adagents and brand. Doc (docs/protocol/multi-agent-discovery.mdx) covers the discovery chain, consumer security requirements (HTTPS-only, RFC 1918 / metadata blocking, origin binding, canonicalization, body cap), and reconciliation rules naming adagents.json authoritative for sell-authorization and the agent-card authoritative on URL/transport disagreement. Training agent serves the manifest at test-agent.adcontextprotocol.org. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bc00453 commit 5c3e3e6

7 files changed

Lines changed: 398 additions & 4 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
"adcontextprotocol": minor
3+
---
4+
5+
feat(spec): `/.well-known/adcp-agents.json` for multi-agent topology discovery (#3895)
6+
7+
Defines a new origin-scoped well-known endpoint that enumerates every AdCP agent served from a host — `agent_id`, `url`, `transport`, `specialisms[]`, and an optional informational `auth_hint`. Lets buyers and conformance runners learn an operator's full multi-agent topology in a single fetch instead of reading a Notion page or hardcoding tenant lists.
8+
9+
**Why.** Today a publisher running sales / signals / governance / creative / brand on one origin has no standard way to advertise the full set. `/.well-known/agent-card.json` describes one agent at a time; `/.well-known/adagents.json` covers authorization, not topology. The training agent demonstrated the gap with a custom `_training_agent_tenants` extension on `adagents.json` — useful but non-standard.
10+
11+
**What's in this PR**
12+
13+
- New schema: `static/schemas/source/adcp-agents.json`. Required: `version`, `agents[]` (each with `agent_id`, `url`, `transport`, `specialisms[]`). Optional: `agents[].auth_hint`, `agents[].description`, `contact`, `last_updated`. `transport` is an open string with documented common values (`"mcp"`, `"a2a"`) so future transports don't require a schema-breaking change. `agents[].url` is `https://`-only with explicit consumer rules for origin-binding and SSRF defence. `agents[]` capped at 256, `specialisms[]` at 64. `additionalProperties: true` elsewhere for forward compatibility.
14+
- Schema registered in `static/schemas/source/index.json` next to `adagents` and `brand` with `file_location: "/.well-known/adcp-agents.json"`.
15+
- New doc: `docs/protocol/multi-agent-discovery.mdx`. Covers shape, fields, `auth_hint` common values, relation to `agent-card.json` / `adagents.json` / `brand.json` / `oauth-authorization-server`, the discovery chain (linked into [Calling an agent](/docs/protocol/calling-an-agent#discovery-chain)), and a reconciliation table that names `adagents.json` as authoritative for "can this agent sell my inventory?" and the agent-card as authoritative when URL/transport disagree. Includes a Consumer Requirements section: HTTPS-only in production, blocking RFC 1918 / loopback / metadata addresses, origin-binding before sending credentials cross-origin, URL canonicalization before comparison, and a 1 MB body cap. Caching and error-semantics section covers missing-manifest fallback to single-agent, malformed-manifest non-blocking degradation, and `X-Forwarded-Host` cache-poisoning guidance. Wired into both navigations in `docs.json`.
16+
- Reference implementation: training agent serves `/.well-known/adcp-agents.json` listing all six tenants (`sales`, `signals`, `governance`, `creative`, `creative-builder`, `brand`). The pre-existing `_training_agent_tenants` extension on `adagents.json` stays in place — it carries tenants that don't fit the `authorized_agents` discriminator (governance / creative / creative-builder / brand) — with its comment pointing at the standard endpoint.
17+
- Smoke test added in `server/src/training-agent/tenants/tenant-smoke.test.ts` verifying the manifest enumerates every tenant with the right key/URL/transport.
18+
19+
**`auth_hint`** is an open string with documented common values (`shared_bearer`, `per_agent_bearer`, `oauth`). `none` was deliberately dropped from the suggested vocabulary to remove a downgrade footgun — a buyer that uses the hint to decide whether to attach credentials would silently send unauthenticated requests on a hostile manifest. The schema and doc now require consumers MUST NOT use `auth_hint` to make credential-attachment decisions; that policy belongs to the consumer's trust configuration for the target origin.
20+
21+
**Out of scope (filed as follow-ups)**
22+
23+
- Per-agent signed manifests — TLS chain trust on the origin is sufficient for v1.
24+
- Capability-aware filtering at the manifest level — operators advertise full claims, runners fetch each agent's `get_adcp_capabilities` for per-tool detail.
25+
- Versioning beyond a top-level `version` field; future revisions will be additive within the `1.x` line.
26+
27+
Closes #3895.

docs.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
"docs/protocol/architecture",
104104
"docs/protocol/required-tasks",
105105
"docs/protocol/calling-an-agent",
106+
"docs/protocol/multi-agent-discovery",
106107
"docs/protocol/format-references",
107108
{
108109
"group": "Understanding AdCP",
@@ -621,6 +622,7 @@
621622
"docs/protocol/architecture",
622623
"docs/protocol/required-tasks",
623624
"docs/protocol/calling-an-agent",
625+
"docs/protocol/multi-agent-discovery",
624626
"docs/protocol/format-references",
625627
{
626628
"group": "Understanding AdCP",
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
---
2+
title: Multi-agent topology discovery
3+
sidebarTitle: Multi-agent discovery
4+
description: "/.well-known/adcp-agents.json — origin-scoped manifest enumerating every AdCP agent served from a host. Lets buyers and conformance runners learn the full multi-agent topology in a single fetch."
5+
"og:title": "AdCP — Multi-agent topology discovery"
6+
---
7+
8+
# Multi-agent topology discovery
9+
10+
A single AdCP origin often serves several agents — a sales agent at `/sales`, signals at `/signals`, governance at `/governance`, creative at `/creative`. `/.well-known/adcp-agents.json` is the standard machine-readable way for that operator to advertise which agents are live, what each one's URL is, and which [specialisms](/docs/protocol/required-tasks) each one claims.
11+
12+
A single-agent operator MAY publish a one-entry manifest — the value to tooling is consistent: discovery is one fetch instead of "ask the operator for a doc."
13+
14+
## Endpoint
15+
16+
```
17+
GET /.well-known/adcp-agents.json
18+
```
19+
20+
- MUST be readable without authentication (parity with `/.well-known/agent-card.json` and `/.well-known/adagents.json`).
21+
- For privacy, operators MAY return only public/test agents from this endpoint and gate production agents behind authenticated discovery on a different path.
22+
- Origin-scoped per RFC 5785 — a host serves one manifest covering every agent on that origin, regardless of path prefix.
23+
- On multi-tenant deployments where each tenant lives at its own subdomain, the manifest at `tenant-X.host` MUST contain only that tenant's agents and MUST NOT include `agent_id`, `description`, or `contact` values that disclose other tenants. Treat all manifest fields as public.
24+
25+
## Shape
26+
27+
```json
28+
{
29+
"$schema": "/schemas/adcp-agents.json",
30+
"version": "1.0",
31+
"agents": [
32+
{
33+
"agent_id": "sales",
34+
"url": "https://example.com/sales/mcp",
35+
"transport": "mcp",
36+
"specialisms": ["sales-non-guaranteed", "sales-guaranteed"],
37+
"auth_hint": "shared_bearer"
38+
},
39+
{
40+
"agent_id": "signals",
41+
"url": "https://example.com/signals/mcp",
42+
"transport": "mcp",
43+
"specialisms": ["signal-marketplace", "signal-owned"],
44+
"auth_hint": "shared_bearer"
45+
},
46+
{
47+
"agent_id": "governance",
48+
"url": "https://example.com/governance/mcp",
49+
"transport": "mcp",
50+
"specialisms": [
51+
"governance-spend-authority",
52+
"governance-delivery-monitor",
53+
"property-lists",
54+
"collection-lists",
55+
"content-standards"
56+
]
57+
}
58+
],
59+
"contact": { "name": "Example Publisher Ad Ops", "email": "adops@example.com" },
60+
"last_updated": "2026-05-02T00:00:00Z"
61+
}
62+
```
63+
64+
### Fields
65+
66+
| Field | Required | Description |
67+
|---|---|---|
68+
| `version` | Required | Manifest version (`"1.0"` for this revision). Consumers SHOULD ignore unknown top-level fields rather than fail on a future minor revision. A future `2.x` will be served at a different well-known path or signalled via media-type parameter. |
69+
| `agents[]` | Required | Every AdCP agent served from this origin. One entry per endpoint. Max 256. |
70+
| `agents[].agent_id` | Required | Stable, operator-defined identifier for this agent within the manifest (e.g. `sales`, `signals`). Lowercase alphanumeric with hyphens/underscores, 1–64 characters, no leading/trailing separator. Unique within `agents[]`. |
71+
| `agents[].url` | Required | Agent endpoint URL. `https://` only in production. For MCP, the streamable-HTTP URL clients POST to (typically ending in `/mcp`). For A2A, the agent's base URL (the agent-card lives at `<url>/.well-known/agent-card.json`). |
72+
| `agents[].transport` | Required | Wire protocol — common values `"mcp"`, `"a2a"`. Open string; new transports may be added. Consumers SHOULD treat unknown values as unsupported rather than fail. Both MCP and A2A share AdCP semantics ([Calling an agent](/docs/protocol/calling-an-agent)). |
73+
| `agents[].specialisms` | Required | AdCP specialisms this agent currently implements. See [Required tasks by protocol](/docs/protocol/required-tasks) for the canonical list. Operators MUST list only specialisms whose required tasks the agent supports at publication time. Max 64. |
74+
| `agents[].auth_hint` | Optional | Informational hint for tooling. Common values below. Consumers MUST NOT use this to decide whether to attach credentials. |
75+
| `agents[].description` | Optional | Human-readable description, surfaced in operator UIs and conformance reports. Max 500 chars. Treat as public. |
76+
| `contact` | Optional | Operator contact information. When present, `contact.name` is required; `contact.email` and `contact.url` are optional. |
77+
| `last_updated` | Optional | ISO 8601 timestamp of the manifest's last change. |
78+
79+
### `auth_hint` common values
80+
81+
The hint is informational only — the actual auth contract is negotiated out-of-band. Buyers MUST NOT treat `auth_hint` as authoritative, and MUST NOT use it to decide whether to attach credentials (that decision belongs to the consumer's configured trust policy for the target origin).
82+
83+
| Value | Meaning |
84+
|---|---|
85+
| `shared_bearer` | One bearer token works across every agent on this origin. |
86+
| `per_agent_bearer` | Each agent needs its own bearer token. |
87+
| `oauth` | OAuth 2.1 flow — see [`/.well-known/oauth-authorization-server`](https://datatracker.ietf.org/doc/html/rfc8414). |
88+
89+
Operators MAY publish other values; tooling that doesn't recognise a value SHOULD fall back to "ask the operator".
90+
91+
## Consumer requirements
92+
93+
Manifests come from arbitrary origins. A misimplemented or compromised origin can publish URLs that point anywhere. Consumers walking this manifest MUST:
94+
95+
- **HTTPS-only in production.** Reject any `agents[].url` that is not `https://` outside an explicit opt-in to a localhost/sandbox origin.
96+
- **Block private and metadata addresses.** Reject URLs that resolve (after DNS) to RFC 1918 ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), or cloud-metadata addresses (`169.254.169.254`, `metadata.google.internal`, `fd00:ec2::254`). This includes redirect targets if HTTP redirects are followed.
97+
- **Enforce origin binding.** A URL listed in this manifest is trusted only for the origin that served the manifest. Before sending credentials or making a transactional call to an `agents[].url` whose origin differs from the manifest origin, consumers MUST independently verify authorization via the target publisher's [`/.well-known/adagents.json`](/docs/governance/property/adagents) — the manifest is a discovery hint, not a trust anchor for cross-origin agents.
98+
- **Canonicalize before comparing.** When comparing `agents[].url` against an `adagents.json` entry, brand registry lookup, or any other authorization signal, apply the [AdCP URL canonicalization rules](/docs/reference/url-canonicalization). Two URLs that differ only in case, default port, or unreserved-character percent-encoding are the same agent.
99+
- **Cap the response.** Manifests SHOULD parse under a 1 MB body cap; reject larger responses outright.
100+
101+
## Reconciliation with neighbouring well-known files
102+
103+
| Question | Authoritative source |
104+
|---|---|
105+
| What agents does this operator run? | `/.well-known/adcp-agents.json` (this file) |
106+
| What does a specific agent advertise to A2A clients? | `<agent-url>/.well-known/agent-card.json` |
107+
| Can a given agent sell my (a publisher's) inventory? | The publisher's [`/.well-known/adagents.json`](/docs/governance/property/adagents)**always**. Listing in `adcp-agents.json` does not imply authorization. |
108+
| What is the correct URL or transport for an agent? | When `adcp-agents.json` and the agent's `agent-card.json` disagree on URL or transport, the agent-card wins. The manifest is a hint; the agent's self-description is canonical. |
109+
110+
| Endpoint | Scope | Purpose |
111+
|---|---|---|
112+
| `/.well-known/adcp-agents.json` | **Multi-agent topology** for one origin | "What agents does this operator run, and where?" |
113+
| `/.well-known/agent-card.json` | **One agent's** A2A descriptor | "What does this single agent advertise to A2A clients?" Per-agent. For multi-agent operators this card describes one agent at a time and is fetched at each agent's base URL. |
114+
| `/.well-known/adagents.json` | **Publisher-side authorization** for inventory and signals | "Which sales/signals agents am I authorising to represent my properties or signals?" Orthogonal to topology discovery. |
115+
| `/.well-known/brand.json` | **Brand identity** for one domain | Orthogonal — declares brand identity, not agent topology. |
116+
| `/.well-known/oauth-authorization-server` | **OAuth metadata** | Orthogonal — RFC 8414. Used when `auth_hint` is `oauth`. |
117+
118+
## Discovery chain
119+
120+
`adcp-agents.json` answers *what agents exist*, not *how to call them*. Once a buyer has picked an agent URL, they walk the standard chain documented in [Calling an AdCP agent](/docs/protocol/calling-an-agent#discovery-chain):
121+
122+
1. Pick the agent for the specialism you need from `/.well-known/adcp-agents.json`.
123+
2. **Agent card** (A2A) or **`tools/list`** (MCP) — returns tool *names*. AdCP MCP servers do not publish per-tool parameter schemas in `tools/list`; don't infer shape from there.
124+
3. **`get_adcp_capabilities`** — supported protocols, AdCP versions, and feature flags.
125+
4. **`get_schema(tool_name)`** *(when exposed; pending standardization in [#3057](https://github.com/adcontextprotocol/adcp/issues/3057))* — JSON Schema for a specific tool's request/response.
126+
5. **Bundled schemas** — every published AdCP version ships JSON Schemas signed via Sigstore; let your SDK's loader find them rather than hardcoding paths.
127+
128+
Capability-aware filtering at the manifest level (e.g. "only list agents that support `create_media_buy`") is intentionally out of scope. The `specialisms[]` claim is enough to route, and `get_adcp_capabilities` is the canonical truth for per-tool support.
129+
130+
## Caching and error semantics
131+
132+
- **Cache-Control.** Public/test manifests MAY use `public, max-age=300`. Production manifests that include any non-public agents SHOULD use `private, max-age=60` and `Vary: Authorization` if the response varies on auth.
133+
- **Missing manifest (404).** Consumers SHOULD fall back to fetching `/.well-known/agent-card.json` at the same origin and treating the operator as a single-agent deployment. The absence of the manifest is not an error.
134+
- **Malformed JSON or schema-invalid manifest.** Consumers MUST NOT block startup on this fetch; treat parse / validation failure as "no topology hint available" and degrade to single-agent fallback or operator-supplied configuration.
135+
- **Cache poisoning via `X-Forwarded-Host`.** Operators serving the manifest behind a reverse proxy MUST either set the manifest's host explicitly (e.g. via a `BASE_URL` config) or trust forwarded-host headers only from known proxies. A misconfigured edge that echoes attacker-controlled `X-Forwarded-Host` into emitted `agents[].url` becomes a cache-poisoning vector.
136+
137+
## For tooling authors
138+
139+
Common consumers:
140+
141+
- **Buyers** — replace "ask the publisher for a doc" with one fetch. Cache the manifest with a short TTL; respect `Cache-Control` in production.
142+
- **Conformance runners**`adcp storyboard run --discover https://publisher.example` can replace `--agents-map` for the common case where a publisher hosts a multi-agent topology.
143+
- **AAO Verified** — certification can verify a publisher's declared topology in one fetch; per-specialism capability verification still happens via `get_adcp_capabilities` against each listed agent.
144+
145+
When parsing, ignore unknown top-level fields and unknown fields inside `agents[].*` — the schema declares `additionalProperties: true` so operators MAY add custom metadata without breaking consumers.
146+
147+
## Out of scope (v1)
148+
149+
- **Per-agent signed manifests.** TLS chain trust on the origin is sufficient for v1; per-entry signatures may follow.
150+
- **Capability-aware filtering at the manifest level.** Operators advertise full claims; runners fetch each agent's `get_adcp_capabilities` for the per-specialism detail.
151+
- **Versioning beyond a top-level `version` field.** Future revisions are additive within the `1.x` line; a `2.x` revision will be served at a different well-known path or signalled via media-type parameter.
152+
153+
## Related
154+
155+
- [Calling an AdCP agent](/docs/protocol/calling-an-agent) — the discovery chain after you've picked an agent URL.
156+
- [Required tasks by protocol](/docs/protocol/required-tasks) — canonical specialism names.
157+
- [`get_adcp_capabilities`](/docs/protocol/get_adcp_capabilities) — first call against any newly discovered agent.
158+
- [`adagents.json`](/docs/governance/property/adagents) — publisher-side authorization, the trust anchor for cross-origin agent calls.
159+
- [URL canonicalization](/docs/reference/url-canonicalization) — required when comparing manifest URLs against any authorization signal.

server/src/training-agent/index.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,32 @@ export function createTrainingAgentRouter(): Router {
317317
res.json(getPublicJwks());
318318
});
319319

320+
// Multi-agent topology manifest — RFC 5785 well-known, origin-scoped.
321+
// Lists every per-specialism tenant served from this origin in a single
322+
// fetch. See docs/protocol/multi-agent-discovery and
323+
// static/schemas/source/adcp-agents.json.
324+
router.get('/.well-known/adcp-agents.json', (req: Request, res: Response) => {
325+
const baseUrl = getBaseUrl(req);
326+
const agentUrl = `${baseUrl}${req.baseUrl}`;
327+
res.setHeader('Cache-Control', 'public, max-age=300');
328+
res.json({
329+
$schema: '/schemas/adcp-agents.json',
330+
version: '1.0',
331+
agents: TENANT_IDS.map(tenantId => ({
332+
agent_id: tenantId,
333+
url: `${agentUrl}/${tenantId}/mcp`,
334+
transport: 'mcp' as const,
335+
specialisms: TENANT_SPECIALISMS[tenantId],
336+
auth_hint: 'shared_bearer',
337+
})),
338+
contact: {
339+
name: 'AdCP Training Agent',
340+
url: 'https://adcontextprotocol.org',
341+
},
342+
last_updated: STARTUP_TIME,
343+
});
344+
});
345+
320346
// adagents.json discovery. Schema-conformant per
321347
// `static/schemas/source/adagents.json`:
322348
// - `authorized_agents[]` is a discriminated union — sales agents use
@@ -380,10 +406,11 @@ export function createTrainingAgentRouter(): Router {
380406
first_party: { name: 'First-party signals', description: 'Publisher subscriber and CDP audience signals' },
381407
},
382408
// Custom extension (allowed under schema's additionalProperties:true).
383-
// Lists all six per-specialism tenants so a developer hitting
384-
// adagents.json gets the full multi-tenant picture in one request —
385-
// even for tenants that don't fit the schema's authorized_agents
386-
// discriminator (governance, creative, creative-builder, brand).
409+
// Lists per-specialism tenants alongside the standard authorization
410+
// entries — including tenants that don't fit the schema's
411+
// authorized_agents discriminator (governance, creative,
412+
// creative-builder, brand). Standard topology discovery is at
413+
// /.well-known/adcp-agents.json above.
387414
_training_agent_tenants: TENANT_IDS.map(tenantId => ({
388415
tenant_id: tenantId,
389416
url: `${agentUrl}/${tenantId}/mcp`,

0 commit comments

Comments
 (0)