Skip to content
Merged
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
65 changes: 65 additions & 0 deletions .changeset/agent-type-required-and-brand-domain-backfill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
---

Make agent `type` a required, owner-declared field at every registration
surface, and auto-populate `primary_brand_domain` when an agent is registered
against a profile that has none.

**Why this changed.** A profile registered an agent at
`https://www.harvingupta.xyz/api/mcp` and showed up correctly in the dashboard,
but `GET /api/registry/operator?domain=harvingupta.xyz` returned
`{ member: null, agents: [] }`. Two compounding gaps:

1. The agent's stored `type` was absent. The `save_agent` MCP tool's
schema explicitly did not accept a `type` field — it relied on a
server-side capability-snapshot resolution that silently produced no value
when the probe failed. The REST `POST /api/me/agents` accepted
`Partial<AgentConfig>` and never validated `type` either. The operator
endpoint masked the missing field with `type: ac.type || "unknown"`, so
bad data flowed straight through to the public response.
2. The profile's `primary_brand_domain` was `NULL`. Agent registration writes
`member_profiles.agents` JSONB but never backfilled
`primary_brand_domain`, and the public operator lookup keys exact-match on
that column. Result: a registered agent that the profile owner could see
in the dashboard but no peer could discover via the registry.

**What this PR does.**

- `save_agent` (Addie MCP tool) requires `type` in its `input_schema`
(`brand` | `rights` | `measurement` | `governance` | `creative` |
`sales` | `buying` | `signals`) and persists it. The handler also updates
`type` on re-runs against an existing agent (so an owner who initially
declared the wrong type can correct it by re-saving). Tool description and
intake script in `behaviors.md` now instruct Addie to ask for type before
calling `save_agent` — the prior "do not ask about agent type" rule, and
the "resolved server-side from the capability snapshot" framing, are
removed. If the user describes capabilities, Addie may suggest a fit, but
the user must confirm before save.
- `POST /api/me/agents` (REST) returns `400` when `type` is missing or not
one of the eight valid values; `'unknown'` is rejected on input. `PATCH
/api/me/agents/:url` validates `type` when present (omission preserves the
existing value).
- The mutation helper backs an agent register/update with a single
transaction: when the profile's `primary_brand_domain` is `NULL` and every
agent in the resulting array agrees on the same hostname (after stripping
`www.`), the column is backfilled atomically with the JSONB write.
Conflicting hostnames are deliberately skipped — picking one would
mis-key registry lookups.
- `/api/registry/operator` no longer hides missing/invalid `type` behind a
`|| "unknown"` fallback. Out-of-enum values still serialize as `"unknown"`
to preserve the OpenAPI response contract, but a `warn`-level log fires so
ops can spot corrupt rows that slipped past the write gates. Server-side
smuggle protection (`resolveAgentTypes`) is unchanged: it remains the only
path that may stamp `'unknown'` on a write, when the capability snapshot
contradicts a client declaration without producing a classification.
- `MemberAgentInputSchema` (OpenAPI) now requires `type` and uses a new
`MemberAgentTypeInput` enum that excludes `'unknown'`; the read-side
`MemberAgentType` schema keeps `'unknown'` for the smuggle-protection
outcome.

**Existing rows.** Pre-existing profiles with `primary_brand_domain IS NULL`
or agents missing `type` are not touched by this PR — they were handled by a
manual migration run on the pod (backfill `primary_brand_domain` from the
unanimous agent hostname; default any agent with missing/invalid `type` to
`sales`). Keeping the migration out of `release_command` so it doesn't
auto-fire on deploy.
47 changes: 37 additions & 10 deletions server/src/addie/mcp/member-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ import {
deleteCommitteeDocument as deleteCommitteeDocumentService,
WorkingGroupContentError,
} from '../../services/working-group-content-service.js';
import type { CommitteeDocumentType } from '../../types.js';
import type { AgentType, CommitteeDocumentType } from '../../types.js';
import { isValidAgentType } from '../../types.js';
import { getPool, query } from '../../db/client.js';
import { MemberSearchAnalyticsDatabase } from '../../db/member-search-analytics-db.js';
import { OrganizationDatabase } from '../../db/organization-db.js';
Expand Down Expand Up @@ -1195,13 +1196,18 @@ export const MEMBER_TOOLS: AddieTool[] = [
{
name: 'save_agent',
description:
'Register an agent in the AAO registry on behalf of the current organization. Adds the agent to the org\'s member profile; surfaces in `/dashboard/agents`. New agents land with `members_only` visibility (discoverable to other Professional-tier-or-higher AAO members; not publicly listed in the directory or brand.json). To list publicly, the caller promotes the agent via the dashboard; public visibility requires Professional tier or higher and a primary brand domain. Auth modes: (1) none — public agent, no credentials; (2) static `auth_token` + `auth_type` (`bearer` or `basic`, stored encrypted); (3) `oauth_client_credentials` for machine-to-machine (RFC 6749 §4.4). For interactive OAuth user authorization, save with no auth fields and have the user complete the dashboard\'s **Authorize** flow afterward — `save_agent` does not collect end-user OAuth state. The agent\'s `type` is resolved server-side from the capability snapshot; the schema does not accept a `type` field. If the user mentions their MCP endpoint requires auth, lives at a non-root path (e.g. /adcp/mcp), or shows up as offline after saving, suggest setting `health_check_url` for a liveness fallback while they fix the underlying URL. See the "Registering an Agent in the AAO Registry" section of the rules for the intake script.',
usage_hints: 'use for "register my agent", "add an agent", "save my agent", "store my auth token", "configure client credentials". When the user opens the conversation with a registration intent and no details, follow the intake script in the rules — do not call save_agent until you have `agent_url` and an explicit auth-mode choice.',
'Register an agent in the AAO registry on behalf of the current organization. Adds the agent to the org\'s member profile; surfaces in `/dashboard/agents`. New agents land with `members_only` visibility (discoverable to other Professional-tier-or-higher AAO members; not publicly listed in the directory or brand.json). To list publicly, the caller promotes the agent via the dashboard; public visibility requires Professional tier or higher and a primary brand domain. Auth modes: (1) none — public agent, no credentials; (2) static `auth_token` + `auth_type` (`bearer` or `basic`, stored encrypted); (3) `oauth_client_credentials` for machine-to-machine (RFC 6749 §4.4). For interactive OAuth user authorization, save with no auth fields and have the user complete the dashboard\'s **Authorize** flow afterward — `save_agent` does not collect end-user OAuth state. The caller MUST declare the agent\'s `type` (`brand`, `rights`, `measurement`, `governance`, `creative`, `sales`, `buying`, `signals`); ask the owner — do not guess. Server-side smuggle protection still validates the declared type against the capability snapshot when one is available. If the user mentions their MCP endpoint requires auth, lives at a non-root path (e.g. /adcp/mcp), or shows up as offline after saving, suggest setting `health_check_url` for a liveness fallback while they fix the underlying URL. See the "Registering an Agent in the AAO Registry" section of the rules for the intake script.',
usage_hints: 'use for "register my agent", "add an agent", "save my agent", "store my auth token", "configure client credentials". When the user opens the conversation with a registration intent and no details, follow the intake script in the rules — do not call save_agent until you have `agent_url`, `type`, and an explicit auth-mode choice.',
input_schema: {
type: 'object',
properties: {
agent_url: { type: 'string', description: 'Agent URL' },
agent_name: { type: 'string', description: 'Agent name' },
type: {
type: 'string',
enum: ['brand', 'rights', 'measurement', 'governance', 'creative', 'sales', 'buying', 'signals'],
description: 'What kind of agent this is. Required — ask the owner; do not guess. `brand` (brand-side intent), `rights` (rights/clearance), `measurement` (verification/attribution), `governance` (policy/compliance), `creative` (creative production/format), `sales` (publisher/sell-side inventory), `buying` (DSP/buy-side execution), `signals` (audience/signal provider).',
},
auth_token: { type: 'string', description: 'Static auth token (stored encrypted). Mutually exclusive with oauth_client_credentials on any given save call.' },
auth_type: { type: 'string', enum: ['bearer', 'basic'], description: 'How the auth_token is sent. "bearer" (default): sends Authorization: Bearer <token>. "basic": auth_token must be the base64-encoded "user:password" string, sent as Authorization: Basic <token>' },
oauth_client_credentials: {
Expand All @@ -1221,7 +1227,7 @@ export const MEMBER_TOOLS: AddieTool[] = [
protocol: { type: 'string', enum: ['mcp', 'a2a'], description: 'Protocol (default: mcp)' },
health_check_url: { type: 'string', description: 'Optional fallback liveness URL. The dashboard probe tries the protocol handshake first; if it fails and this URL is set, the probe GETs it and treats any 2xx as "online." Used by sellers whose protocol endpoint requires auth or is path-prefixed (e.g. /adcp/mcp). Liveness only — does not populate type or tools. Pass an empty string to clear a previously-set value.' },
},
required: ['agent_url'],
required: ['agent_url', 'type'],
},
},
{
Expand Down Expand Up @@ -5587,6 +5593,18 @@ export function createMemberToolHandlers(
const authType: 'bearer' | 'basic' = rawAuthType === 'basic' ? 'basic' : 'bearer';
const protocol = (input.protocol as 'mcp' | 'a2a') || 'mcp';

// Caller-declared agent type. Required at the schema level — the JSON-RPC
// layer has already enforced presence via input_schema.required, so this
// check is the runtime belt to the schema's suspenders. We only accept the
// 8 real values; 'unknown' is reserved for server-side smuggle protection
// when a snapshot exists but couldn't classify (resolveAgentTypes), never
// for client input.
const declaredType = input.type;
if (typeof declaredType !== 'string' || !isValidAgentType(declaredType) || declaredType === 'unknown') {
return 'Agent type is required. Please specify one of: brand, rights, measurement, governance, creative, sales, buying, signals.';
}
const agentType = declaredType as Exclude<AgentType, 'unknown'>;

// null sentinel and explicit empty string both clear a previously-set
// value; a present non-empty string is validated through the same SSRF
// guard the OAuth token-endpoint path uses (cloud-metadata blocked
Expand Down Expand Up @@ -5652,16 +5670,25 @@ export function createMemberToolHandlers(
agents.push({
url: agentUrl,
name: displayName,
type: agentType,
visibility: 'members_only',
...(healthCheckUrl ? { health_check_url: healthCheckUrl } : {}),
});
await memberDb.updateProfile(profile.id, { agents });
} else if (clearHealthCheckUrl && (existing as any).health_check_url) {
delete (existing as any).health_check_url;
await memberDb.updateProfile(profile.id, { agents });
} else if (healthCheckUrl && (existing as any).health_check_url !== healthCheckUrl) {
(existing as any).health_check_url = healthCheckUrl;
await memberDb.updateProfile(profile.id, { agents });
} else {
let dirty = false;
if ((existing as any).type !== agentType) {
(existing as any).type = agentType;
dirty = true;
}
if (clearHealthCheckUrl && (existing as any).health_check_url) {
delete (existing as any).health_check_url;
dirty = true;
} else if (healthCheckUrl && (existing as any).health_check_url !== healthCheckUrl) {
(existing as any).health_check_url = healthCheckUrl;
dirty = true;
}
if (dirty) await memberDb.updateProfile(profile.id, { agents });
}
return { ok: true, createdProfile };
} catch (err) {
Expand Down
28 changes: 19 additions & 9 deletions server/src/addie/rules/behaviors.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,34 +333,44 @@ Practitioner certification culminates in building a working agent that passes st

When the user's intent is **register** (e.g. "register my agent", "add my agent to the registry", or arrival via the `+ Register agent` button on `/dashboard/agents`), drive a short intake before calling `save_agent`. This **overrides** the "act immediately on a pasted agent URL" rule above when the intent is registration, not test/validate. If intent is ambiguous (a bare URL with no verb), ask once: "Do you want to register this agent in the registry, or test it?"

**Shortcut — paste-it-all.** If the user supplies `agent_url` plus an explicit auth choice in one message (e.g. "register `https://agent.example.com/mcp` with bearer token `abc123`"), skip the intake and call `save_agent` directly. Confirm afterward.
**Shortcut — paste-it-all.** If the user supplies `agent_url`, an explicit `type`, and an explicit auth choice in one message (e.g. "register `https://agent.example.com/mcp` as a sales agent with bearer token `abc123`"), skip the intake and call `save_agent` directly. Confirm afterward.

**Otherwise, the intake script. Send one question per turn until you have `agent_url` and an explicit auth-mode choice.**
**Otherwise, the intake script. Send one question per turn until you have `agent_url`, `type`, and an explicit auth-mode choice.**

1. **Agent URL** (required). Ask: "What's the URL of the agent you want to register? (e.g. `https://agent.yourcompany.com/mcp`)"
2. **Display name** (optional). Ask: "What name should we show for this agent in your dashboard?" — skip if obvious from the URL.
3. **Auth method** (required choice). Ask: "How does your agent authenticate callers? Pick one:
2. **Agent type** (required). Ask: "What kind of agent is this? Pick one:
- **brand** — brand-side intent / planning
- **sales** — publisher / sell-side inventory (most MCP servers exposing inventory)
- **buying** — DSP / buy-side execution
- **creative** — creative production or format conversion
- **measurement** — verification, attribution, brand-safety
- **signals** — audience or signal provider
- **rights** — rights / clearance
- **governance** — policy / compliance
Don't know? Tell me what the agent does and I'll suggest the closest fit — but you confirm before I save."
3. **Display name** (optional). Ask: "What name should we show for this agent in your dashboard?" — skip if obvious from the URL.
4. **Auth method** (required choice). Ask: "How does your agent authenticate callers? Pick one:
- **None** — public, no auth required
- **Static bearer token** — a long-lived API key you paste once (stored encrypted)
- **Static basic auth** — `user:password` (base64-encoded, stored encrypted)
- **OAuth client credentials** — machine-to-machine, RFC 6749 §4.4. You'll need the token endpoint, client ID, and client secret.

*(Interactive OAuth user authorization is also supported, but it isn't configured here — save with **None**, then click **Authorize** on the agent card in `/dashboard/agents` to complete the sign-in flow.)*"
4. **Auth fields** — collect only what the chosen method needs:
5. **Auth fields** — collect only what the chosen method needs:
- Bearer/basic → `auth_token` (+ `auth_type: "basic"` if basic)
- OAuth client credentials → `oauth_client_credentials.token_endpoint`, `client_id`, `client_secret`, plus optional `scope`, `resource`, `audience`, `auth_method`
5. **Protocol** (optional). Default `mcp`. Ask only if the URL is ambiguous: "Is this an MCP or A2A endpoint?"
6. **Protocol** (optional). Default `mcp`. Ask only if the URL is ambiguous: "Is this an MCP or A2A endpoint?"

**When to actually call `save_agent`.** Only when you have (a) `agent_url` and (b) an explicit auth-mode choice from the user. Anything else → ask, don't infer. If the user defers ("you pick", "whatever's easiest"), default to `none` and tell them you'll save without credentials; if the agent rejects calls later, they can re-run register and add a token.
**When to actually call `save_agent`.** Only when you have (a) `agent_url`, (b) `type` declared by the user, and (c) an explicit auth-mode choice. Anything else → ask, don't infer. If the user defers on auth ("you pick", "whatever's easiest"), default to `none` and tell them you'll save without credentials; if the agent rejects calls later, they can re-run register and add a token. **Never default `type`** — if the user can't or won't say, stop and explain that the registry needs a declared type so peers know what they're looking at.

**Never echo secrets.** When the user pastes an `auth_token`, `client_secret`, or any credential, do not repeat it back. In confirmations, mask as `••••••••<last4>`. If the user picks the OAuth user-authorization path and pastes an access token by mistake, refuse it and explain the agent will mint its own token via the dashboard's Authorize flow.

**Do not ask about agent type.** The schema doesn't accept a `type` field — type (`brand`, `sales`, `buying`, `measurement`, etc.) is resolved server-side from the agent's capability snapshot. If the user volunteers a type, acknowledge it but tell them detection is automatic.
**Always ask for agent type — never guess.** The owner declares it. If the user describes capabilities ("it returns inventory and accepts media buys") you may suggest the closest fit (`sales` in that example), but the user must confirm before you call `save_agent`. Server-side smuggle protection still cross-checks the declared type against the capability snapshot once one exists; if the capability probe disagrees later, the dashboard surfaces the conflict.

**After `save_agent` succeeds**, confirm what landed and tell them the visibility default is **Members only** — discoverable to other Professional-tier-or-higher AAO members, not publicly listed. Point them to the visibility selector on the agent card if they want to go **Public** (Public requires Professional tier or higher and a primary brand domain).

**If `save_agent` fails**, do not abandon the registration. Read the error and route:
- **Probe timeout / unreachable** → the agent record may still have saved, but type couldn't be detected. Tell them, and offer to retry once the agent is reachable.
- **Probe timeout / unreachable** → the agent record may still have saved with the declared type. Tell them, and offer to retry once the agent is reachable.
- **Auth rejected (401/403)** → the credentials they supplied don't work. Ask them to verify the token / client credentials and offer to re-run with new values.
- **Validation error (bad URL, unsupported auth combination)** → quote the error message and ask for the corrected field.
- **Permission denied (not signed in, not in an org)** → see the next paragraph.
Expand Down
Loading
Loading